Sync ExecuteReader on an STA thread deadlocks against a StateChange handler that marshals to the UI thread (OpenAsyncRetry path) #4534
Description
Activity
🔍 Triage Summary
Check Result Issue type Feature (documentation request with optional code consideration) Environment All required environment details provided for investigation Area Area\Documentation(primary request);Area\Netfx(secondary — sync-over-async hazard specific to .NET Framework STA threading)Duplicates Potentially related: #1213 (WinForms SynchronizationContext deadlock fix for token acquisition — same family of sync-over-async hazard) Regression Not indicated — this is a latent design hazard surfaced by Entra auth's async-retry path Analysis
SqlCommand.ExecuteReader(sync) usesAsyncHelper.WaitForCompletion→Task.Wait, which on an STA thread becomes a COM pumping wait that dispatches COM calls but not posted window messages. Independently,OpenAsyncRetry.RetryfiresDbConnection.StateChangeon a thread-pool thread rather than the caller's thread. When aStateChangehandler does a blocking marshal back to the UI thread (Control.Invoke), both threads wait on each other permanently. The reporter explicitly acknowledges the root cause is in the SSMS consumer, but theStateChange-on-threadpool-thread behaviour is undocumented, and #1213 noted this residual sync-over-async risk. Severity P2 — impacts .NET Framework WinForms/WPF consumers using synchronous SqlClient APIs on the UI thread.Next Steps
- Documentation (highest value): Add a warning note to
SqlConnection.StateChangeXML docs and the connection-pooling docs clarifying that the event may be raised on a thread-pool thread during the async-retry open path, and that handlers must not block (e.g., must not callControl.InvokeorDispatcher.Invokesynchronously). - Assign to Copilot coding agent to: (a) locate the
StateChange/OnStateChangecall sites inSqlConnectionFactory.SetInnerConnectionEventand related connection-pool internals, (b) update XML doc comments onSqlConnection.StateChangewith a threading note, and (c) updatedoc/connection-pooling documentation with the same warning. - Optional code investigation: Evaluate whether
OpenAsyncRetryshould marshalStateChangeback to the original synchronization context (if captured), or document explicitly why it does not. The reporter has also raised whetherRunExecuteReaderTdscallingWaitForCompletionon an STA thread should be guarded — this is the broader sync-over-async hazard flagged in Fix Async thread blocking on SqlConnection open for AAD modes #1213 and warrants separate consideration. - The reporter is willing to provide the full repro project and raw stack dumps — accept if the team wants them for the SSMS routing effort (Developer Community 10857872).
Note: This triage summary is auto-generated by an AI agent. The analysis and suggestions above have not been verified by a human maintainer. Please treat as preliminary guidance only.
Generated by SqlClient Issue Auto-Triage for issue #4534 · 31.2 AIC · ⌖ 9.17 AIC · ⊞ 8.6K · ◷
- Documentation (highest value): Add a warning note to
- addedExternal 🔗Issue is in an external componentIssue is in an external component
on Aug 17, 2026 - added a parent issue
on Sep 18, 2026 We'll investigate how to correct this as part of #3459
UI Thread (STA) ThreadPool Thread ──────────────── ────────────────── SqlCommand.ExecuteReader() └─ RunExecuteReaderTds() └─ _activeConnection.ValidateAndReconnect() │ connection needs (re)open ⇒ kicks off │ async reconnect/open task ───────────┐ ▼ ▼ AsyncHelper.WaitForCompletion(reconnectTask) SqlConnection.OpenAsyncRetry.Retry └─ Task.Wait() ◄─────────┐ └─ TryOpen / TryOpenInner │ │ └─ DbConnection.OnStateChange │ STA ⇒ COM-pump │ └─ consumer's StateChange handler │ wait: services │ └─ Control.Invoke() ───┐ │ COM + *sent* msgs │ (blocking marshal │ │ only, NOT *posted* │ back to UI thread) │ │ window messages │ │ ▼ │ │ ╔═══════════════╗ │ │ ║ BLOCKED ║◄─────────┘ reconnectTask can't complete until StateChange │ ║ waiting on ║ handler returns │ ║ reconnectTask ║ │ ╚═══════════════╝ │ ▲ │ │ Invoke posts a message to the UI thread and blocks until dispatched — │ │ but the UI thread only pumps COM/sent messages while inside Task.Wait, │ │ so the posted message is never serviced │ └────────────────────────────────────────────────────────────────────────────────┘ DEADLOCK (circular wait): ExecuteReader waits on reconnectTask ⇄ reconnectTask waits on StateChange handler's Invoke ⇄ Invoke waits on the UI thread's message pump (which ExecuteReader is occupying)
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Summary
This is not a claim that SqlClient is at fault. The blocking call is in a closed-source consumer (SSMS). I am raising it here because the deadlock is only reachable through two SqlClient behaviours, one of which was explicitly flagged as a residual risk in #1213, and because a note in the docs would probably prevent the next occurrence of it.
Two SqlClient behaviours combine:
SqlCommand.ExecuteReaderfunnels intoAsyncHelper.WaitForCompletion→Task.Wait. On an STA thread that becomes a COM pumping wait, which dispatches COM calls and sent window messages but not posted ones.SqlConnection.OpenAsyncRetry.RetryraisesDbConnection.StateChangeon a thread-pool thread, not on the thread that initiated the open.If a consumer's
StateChangehandler marshals to the UI thread with a blocking call (Control.Invoke,Dispatcher.Invoke), and that UI thread is simultaneously inside a synchronous SqlClient call, the two deadlock permanently. Neither behaviour is a bug on its own.Versions
Microsoft.Data.SqlClient6.1.5 (file 6.15.26114.3)Observed stacks
Captured with ClrMD against the live wedged process.
UI thread (STA):
Thread-pool worker:
The query is never sent. Server-side the sessions show
cpu_time0 andlast_request_end_timeequal tolast_request_start_time.Minimal repro
.NET Framework 4.8 WinForms,
[STAThread],Microsoft.Data.SqlClient6.1.5. On a button click:Wedges every time and stays wedged. Swapping the single
InvokeforBeginInvokecompletes in ~1.5s with no other change and no external stimulus.Worth noting for anyone debugging something similar: while wedged,
IsHungAppWindowreturns false and the process sits near 0% CPU, because the COM wait is still dispatching. It does not look like a hang from outside.Relationship to #1213
#1213 fixed the token-acquisition leg of this same family by wrapping
AcquireTokenAsyncinTask.Runto escape the WinFormsSynchronizationContext. In review, @roji noted:and also:
This looks like a concrete instance of that residual risk in the wild, four majors later, reached through the
StateChangecallback rather than through token acquisition.What I am actually asking for
Not necessarily a code change. In rough order of value:
DbConnection.StateChangebeing raised on a thread-pool thread during the async-retry path is not obvious, and it means handlers must never block. A note onSqlConnection.StateChangeor in the connection-pooling docs would be cheap and would have saved this investigation.Microsoft.SqlServer.Management.DataTools(SSMS, closed source), which is in the same org. It is also reported at Developer Community 10857872, "SSMS Studio is busy after timeout", where it has been open and under investigation for a couple of years. The fix looks like one word:Invoke→BeginInvoke.RunExecuteReaderTdsshould be able to reachWaitForCompletionat all when called synchronously on an STA thread, given the known hazard.Happy to attach the full repro project and raw stack dumps.