Skip to content

Fix HttpClient deadlocks: Addendum to #2367#2371

Open
deAtog wants to merge 1 commit intorestsharp:devfrom
deAtog:dev
Open

Fix HttpClient deadlocks: Addendum to #2367#2371
deAtog wants to merge 1 commit intorestsharp:devfrom
deAtog:dev

Conversation

@deAtog
Copy link
Copy Markdown

@deAtog deAtog commented Mar 17, 2026

Description

  • Modifies RunAsync helper method to:
    1. Use Task.Run to force asynchronous tasks to run on the thread pool.
    2. This method avoids potential deadlocks at the cost of requiring at least one thread pool thread per task.
  • 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 prevent deadlocks from calling async methods synchronously by executing them on a thread pool thread and capturing the result after the task completes.

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. This implementation can only run tasks that do not need to run concurrently and use locks or other synchronization mechanisms to ensure thread safety. This implementation is not suitable for wrapping methods of the HttpClient 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

Qodo Logo

Comment thread src/RestSharp/AsyncHelpers.cs Outdated
@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

@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.

@deAtog
Copy link
Copy Markdown
Author

deAtog commented Apr 23, 2026

@alexeyzimarev I squashed the commits and updated the description of this pull request.

I understand the reason a lambda expression is used when calling RunSync. The lambda expression creates an anonymous function which executes the async function which returns a Task. When passed to Task.Run, the async function is executed on the thread pool and current context would be that of the thread pool, which avoids capturing the caller's context. Task.Run() has an overload that takes a Func<Task<T>>, so the new version of RunSync merely passes the lambda function to Task.Run to be executed on the thread pool. Older versions of Task.Run did not have this overload, so another lambda method would have been needed to capture the result and return it. That is no longer necessary.

return RunSync(() => client.ExecuteAsync(request));

Is therefore equivalent to the following after these changes:

return Task.Run(() => client.ExecuteAsync(request)).GetAwaiter().GetResult();

This change resolves the deadlock in my application without any other modifications.

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

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