Fixes #5636 - Scope MainLoopSyncContext to running sessions (await-before-RunAsync deadlock) - #5641
Conversation
…k await-before-RunAsync. Since #5588, Init installed the app's MainLoopSyncContext as the calling thread's ambient SynchronizationContext. Any await between Init and Run/RunAsync then captured a context whose Post queues onto the main loop - which is not running yet - so the continuation (the code about to start the loop) was stranded and the app hung at startup. ConfigureAwait(false) avoided the capture, matching the workaround shipped in tui-cs/clet. - Init creates the context but no longer installs it as ambient; Begin still installs it for the duration of a session, so awaits inside event handlers keep marshaling to the main loop (the #5579 contract). - Run saves the caller's ambient context and restores it on exit, so an await after Run cannot capture a context that stopped pumping. - ResetState clears the ambient context only when it is this app's own; a foreign (caller-owned) context is left untouched. - MainLoopSyncContext.Post/Send fall back to the thread pool / inline execution once the app is no longer Initialized - callbacks posted after Shutdown/Dispose execute instead of being stranded (previously Post threw NotInitializedException via Invoke). Test-first: RunAsync_AfterAwaitFollowingInit_DoesNotDeadlock reproduced the hang (10s timeout) before the fix and passes in ~2s after. New SyncContextLifecycleTests cover the Init/Run/Dispose ambient-context contract and the post-dispose Post fallback; existing tests that encoded the #5588 ambient-at-Init behavior were updated to the new contract. Suites: 17,564 parallelizable, 32 non-parallelizable, 437 integration - all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGq38GbuZPAEa5wZHkmPiZ
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9286cf8c1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
This PR fixes #5636 by scoping MainLoopSyncContext to active application sessions and adding lifecycle regression tests.
Changes:
- Stops installing the context during
Initand restores ambient contexts afterRun. - Adds fallback dispatch behavior after shutdown or disposal.
- Adds and updates synchronization-context and deadlock tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Review result |
|---|---|
Tests/UnitTestsParallelizable/Application/SyncContextLifecycleTests.cs |
Adds lifecycle regression tests. No final review comments. |
Tests/UnitTestsParallelizable/Application/RunAsyncTests.cs |
Adds the startup deadlock regression test. No final review comments. |
Tests/UnitTestsParallelizable/Application/ApplicationImplTests.cs |
Updates context-related assertions. No final review comments. |
Tests/UnitTests.NonParallelizable/Application/SynchronizationContextTests.cs |
Updates synchronization-context contract tests. No final review comments. |
Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs |
Critical (3 votes): Posts after Run/End can still queue to a stopped loop while Initialized remains true; gate dispatch on active session state while retaining pre-run queueing behavior. |
Terminal.Gui/App/ApplicationImpl.Run.cs |
Critical (2 votes): Begin installs the app context but End does not restore the replaced context, including for nested sessions, allowing subsequent awaits to deadlock. |
Terminal.Gui/App/ApplicationImpl.Lifecycle.cs |
Critical (1 vote): Direct Begin/End sessions can leave the stopped app context ambient; save and restore the caller context across these sessions, including nested sessions. |
Suppressed comments (2)
Terminal.Gui/App/ApplicationImpl.Lifecycle.cs:317
- This cleanup clears the ambient context to
nullwhen it sees the app's context, but the caller's context underneath it is not retained for directBeginusage. For example, with a foreign context installed,Beginreplaces it, andDisposewhile that session is active reaches this branch and loses the foreign context instead of restoring it. Preserve the prior context when Begin installs the app context and restore it during End/ResetState.
if (SynchronizationContext is { } ownContext && System.Threading.SynchronizationContext.Current == ownContext)
{
System.Threading.SynchronizationContext.SetSynchronizationContext (null);
}
Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs:50
- The same liveness check leaves
Sendblocking forever after a session ends:Initializedis still true, so a non-main-thread caller enters_app.Invokeand waits for a callback that no loop can drain.Sendneeds to use the same inactive-session fallback asPost(while preserving synchronous dispatch during an active run).
// After Shutdown/Dispose no main loop can ever pump the queue; execute inline.
if (!_app.Initialized || _app.MainThreadId == Thread.CurrentThread.ManagedThreadId)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…back after sessions end. Codex/Copilot review of #5641 flagged two gaps in the session scoping: - The documented Begin/End building-block sequence installed the app context but never restored the caller's. Begin now saves the caller's ambient context on the SessionToken and End restores it (guarded so a foreign thread's context is never clobbered); for nested sessions the outermost End restores the caller's context. - Post/Send gated only on Initialized, so a continuation captured during a session that resumed after End was queued onto a loop that may never pump again. They now gate on CanPumpPostedWork: pump while a session is running, or before the first Run completes (preserving the documented post-Init/pre-Run queueing); otherwise fall back to the thread pool / inline execution. Test-first: Begin_End_RestoresAmbientSyncContext_IncludingNestedSessions and Post_AfterSessionEnded_StillExecutesCallback both failed before the change and pass after. Suites: 17,566 parallelizable, 32 non-parallelizable, 437 integration - all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGq38GbuZPAEa5wZHkmPiZ
Fixes #5636. Stacked on #5416 (
tig/remove-cm-followup).Root cause
Since #5588,
Initinstalled the app'sMainLoopSyncContextas the calling thread's ambientSynchronizationContext. AnyawaitbetweenInitandRun/RunAsynccaptured that context; itsPostqueues work onto the main loop — which is not running yet — so the continuation (the code about to start the loop) was stranded and the app hung at startup while the input thread spun.ConfigureAwait (false)avoids the capture, which is exactly the workaround tui-cs/clet shipped (clet#202).Fix
Initcreates the context but no longer installs it as ambient.Beginstill installs it for the duration of a session, soawaits inside event handlers keep marshaling to the main loop (the SynchronizationContext is not correctly implemented in v2 #5579 contract is preserved —Open_Calls_ContinueWith_On_UIThreadand bothInit_Posts_*tests still pass).Runsaves the caller's ambient context and restores it in afinally, so anawaitafterRunreturns can't capture a stopped-pumping context.ResetStateclears the ambient context only when it's this app's own; a caller-owned context is left untouched.MainLoopSyncContext.Post/Sendfall back to thread pool / inline execution once the app is no longerInitialized— callbacks posted afterShutdown/Disposeexecute instead of being stranded (previouslyPostthrewNotInitializedExceptionviaInvoke).Test-first
RunAsync_AfterAwaitFollowingInit_DoesNotDeadlockreproduces the exact #5636 scenario (Init→await Task.Yield ()→RunAsync) on a dedicated thread: it failed with a 10s timeout before the fix and passes in ~2s after. NewSyncContextLifecycleTestspin the Init/Run/Dispose ambient-context contract and the post-disposePostfallback. Tests that encoded the #5588 ambient-at-Init behavior (SynchronizationContextTests,Dispose_Resets_SyncContext, theInit_Posts_*post-run assertions) were updated to the new contract.Verification
Once this merges into the 5416 branch, tui-cs/clet can drop its
ConfigureAwait (false)workaround (clet#202).🤖 Generated with Claude Code
https://claude.ai/code/session_01NGq38GbuZPAEa5wZHkmPiZ