Skip to content

Fix HttpClient deadlocks: Addendum to #2367#2371

Open
deAtog wants to merge 5 commits intorestsharp:devfrom
deAtog:dev
Open

Fix HttpClient deadlocks: Addendum to #2367#2371
deAtog wants to merge 5 commits intorestsharp:devfrom
deAtog:dev

Conversation

@deAtog
Copy link
Copy Markdown

@deAtog deAtog commented Mar 17, 2026

Description

  • Adds missing ConfigureAwait(false) to await call in RunSync method.
  • Refactors the CustomSynchronizationContext and associated RunSync method for code clarity.
  • Drops the ConfigureAwait(false) method call on the task executed from the CustomSynchronizationContext to ensure continuations occur on the custom context, not the thread pool.
  • Modifies RunAsync helper method to:
    1. Use Task.Run to force asynchronous tasks to run on the thread pool.
    2. Ensure that the task does not try to continue on the current context by calling ConfigureAwait(false).
  • Removes the now unused CustomSynchronizationContext that did not resolve deadlocks.

This https://learn.microsoft.com/en-us/archive/blogs/jpsanders/asp-net-do-not-use-task-result-in-main-context describes the approach taken here to resolve the deadlock. Instead of using Task.Wait, we use Task.GetAwaiter().GetResult() as it will unwrap all exceptions instead of throwing an AggregateException.

Purpose

These changes should prevent deadlocks from calling async methods and ensure that continuations happen synchronously on the CustomSynchronizationContext instead of the thread pool.

These changes prevent deadlocks from calling async methods synchronously by executing them on a thread pool thread and capturing the result after the task completes. ConfigureAwait(false) is used to ensure that the task does not capture the context and cause a deadlock.

History

The CustomSychronizationContext implementation was originally pulled from https://github.com/rebus-org/Rebus/blob/27b212a2380d55edc16a0036dfefd8e6b3ad9c2c/Rebus/Bus/Advanced/RebusAsyncHelpers.cs

Their implementation does not call ConfigureAwait(false) on the task executed from within the CustomSynchronizationContext to ensure any continuations are queued on the CustomSynchronnizationContext, not the thread pool. While this should work it still causes a deadlock when methods of the HttpClient are called synchronously and has been removed as a result.

@deAtog
Copy link
Copy Markdown
Author

deAtog commented Mar 17, 2026

@dotnet-policy-service agree company="The Davey Tree Expert Company"

@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Refactor CustomSynchronizationContext to prevent deadlocks

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Refactored CustomSynchronizationContext to manage context setup internally
• Removed ConfigureAwait(false) from task execution to ensure continuations stay on custom context
• Added ConfigureAwait(false) to RunSync<T> method's await call
• Simplified RunSync method by moving context management into CustomSynchronizationContext.Run()
Diagram
flowchart LR
  A["RunSync calls<br/>CustomSynchronizationContext"] -->|"simplified"| B["CustomSynchronizationContext.Run<br/>manages context"]
  B -->|"sets context"| C["SynchronizationContext<br/>set to custom"]
  C -->|"executes"| D["PostCallback<br/>without ConfigureAwait"]
  D -->|"ensures continuations<br/>stay on context"| E["Prevents deadlocks<br/>and thread pool jumps"]
Loading

Grey Divider

File Changes

1. src/RestSharp/AsyncHelpers.cs 🐞 Bug fix +26/-20

Refactor context management and ConfigureAwait usage

• Simplified RunSync method by removing context management logic
• Moved SynchronizationContext.SetSynchronizationContext calls into
 CustomSynchronizationContext.Run() method
• Removed ConfigureAwait(false) from PostCallback to keep continuations on custom context
• Added ConfigureAwait(false) to RunSync<T> method's await call for consistency
• Added explanatory comments clarifying the context behavior and continuation handling

src/RestSharp/AsyncHelpers.cs


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown
Contributor

qodo-free-for-open-source-projects Bot commented Mar 17, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. RunSync<T> escapes context🐞 Bug ≡ Correctness
Description
AsyncHelpers.RunSync<T> now uses ConfigureAwait(false), causing the continuation that assigns the
result (and any future logic after the await) to run on the thread pool instead of the custom
synchronization context/calling thread. This breaks the method’s documented contract and affects all
synchronous RestClient APIs implemented via AsyncHelpers.RunSync.
Code

src/RestSharp/AsyncHelpers.cs[41]

+        RunSync(async () => { result = await task().ConfigureAwait(false); });
Evidence
RunSync/RunSync<T> is documented to run tasks synchronously on the calling thread by queueing
continuations onto a temporary synchronization context, and PostCallback explicitly avoids
ConfigureAwait(false) to keep continuations on that context. Adding ConfigureAwait(false) in
RunSync<T> bypasses that context for the continuation that assigns the result, and since many
synchronous RestClient APIs are implemented via AsyncHelpers.RunSync, the behavior change propagates
broadly.

src/RestSharp/AsyncHelpers.cs[22-42]
src/RestSharp/AsyncHelpers.cs[103-109]
src/RestSharp/RestClient.Extensions.Get.cs[37-43]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`AsyncHelpers.RunSync&amp;lt;T&amp;gt;` uses `await task().ConfigureAwait(false)`, which allows the continuation that assigns `result` to run on the thread pool rather than the custom synchronization context/calling thread. This contradicts the documented behavior of running synchronously on the calling thread via a pumped synchronization context and changes behavior for all synchronous RestClient APIs built on `RunSync`.
## Issue Context
`CustomSynchronizationContext.PostCallback` explicitly avoids `ConfigureAwait(false)` to keep continuations queued on the custom context, but `RunSync&amp;lt;T&amp;gt;` reintroduces a thread-pool continuation via `ConfigureAwait(false)`.
## Fix Focus Areas
- src/RestSharp/AsyncHelpers.cs[33-43]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread src/RestSharp/AsyncHelpers.cs Outdated
David Ellingsworth added 3 commits March 18, 2026 10:35
…chronizationContext.

This simplifies the RunSync method and clarifies the logic of the CustomSynchronizationContext.
As such, the Run method should never be called more than once.
Make the constructor and Run method private and add a public static Run method to
run a single task on an instance of the context.
@sonarqubecloud
Copy link
Copy Markdown

@deAtog
Copy link
Copy Markdown
Author

deAtog commented Mar 19, 2026

Even with these changes, I'm still getting a deadlock here. It seems #2083 still isn't quite fixed.

image

…un async methods safely from the Thread Pool.
@sonarqubecloud
Copy link
Copy Markdown

@deAtog
Copy link
Copy Markdown
Author

deAtog commented Apr 20, 2026

@alexeyzimarev With commit 113594e, my application no longer deadlocks upon calling the synchronous methods of the RestClient. This commit also makes my prior changes irrelevant. Based upon my analysis and testing, this should permanently resolve any deadlocks that people have been experiencing. Let me know if you want me to flatten this into the single final commit which actually solves this issue.

@deAtog deAtog changed the title Addendum to #2367 Fix HttpClient deadlocks: Addendum to #2367 Apr 20, 2026
@alexeyzimarev
Copy link
Copy Markdown
Member

There's no need for ConfigureAwait(false) as it was done upstream already. There's indeed a big change mentioned by Qodo. I am not against it, but this is exactly the code that was there before for sync over async and people complained it didn't work. Maybe that was due to lacking ConfigureAwait(false) in upstream code.

@alexeyzimarev
Copy link
Copy Markdown
Member

alexeyzimarev commented Apr 21, 2026

Suggestions:

  1. Simplify by removing ConfigureAwait(false) — same semantics, no redundant async state machine, no misleading ConfigureAwait.
  2. Func<Task> and Func<Task<T>> must be kept (later on that).
  3. Fix the PR description so future readers don't cargo-cult the ConfigureAwait(false) thinking it's load-bearing here.
  4. Acknowledge the behaviour change vs. the old pumping context: every sync call now costs a thread-pool thread while the caller blocks. Previous implementation ran the continuations inline on the caller thread. Under heavy sync usage this shifts the failure mode from "deadlock" to "thread-pool starvation" — arguably better, but different. Worth a note, not a blocker.
  5. Squash, yes — the intermediate commits are dead ends.

Why the delegate is required:

With Func<Task>:

RunSync(() => client.GetAsync(request));
// inside: Task.Run(task) → invokes the delegate on a pool thread           

The call to client.GetAsync(...) happens inside Task.Run. The synchronous prefix of the async method (everything before the first await) runs on the pool thread, where SynchronizationContext.Current is null. Any await that captures a context captures a null context. Safe.

With Task directly:

RunSync(client.GetAsync(request));
// client.GetAsync(...) is evaluated on the caller's thread BEFORE RunSync is even entered

By the time the Task<T> reaches RunSync, the async state machine has already started on the caller's thread. Its sync prefix ran on the UI thread with the UI's sync context. Any internal await without ConfigureAwait(false) already captured that context. Then you block the caller thread with GetAwaiter().GetResult() → classic deadlock, exactly what you were trying to avoid.

And wrapping it after the fact doesn't help:

Task.Run(() => task).GetAwaiter().GetResult(); // task is already running!

Task.Run on an already-started task just awaits it; the captured-context damage is already done.

Side note: this is also why every call site in RestSharp's extensions uses () => client.XxxAsync(...) rather than materialising the task first. That pattern is correct and should stay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants