Skip to content

Fixes #5636 - Scope MainLoopSyncContext to running sessions (await-before-RunAsync deadlock) - #5641

Merged
tig merged 2 commits into
tig/remove-cm-followupfrom
tig/fix-5636-runasync-deadlock
Aug 23, 2026
Merged

Fixes #5636 - Scope MainLoopSyncContext to running sessions (await-before-RunAsync deadlock)#5641
tig merged 2 commits into
tig/remove-cm-followupfrom
tig/fix-5636-runasync-deadlock

Conversation

@tig

@tig tig commented Aug 23, 2026

Copy link
Copy Markdown
Member

Fixes #5636. Stacked on #5416 (tig/remove-cm-followup).

Root cause

Since #5588, Init installed the app's MainLoopSyncContext as the calling thread's ambient SynchronizationContext. Any await between Init and Run/RunAsync captured that context; its Post queues 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

  • 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 SynchronizationContext is not correctly implemented in v2 #5579 contract is preserved — Open_Calls_ContinueWith_On_UIThread and both Init_Posts_* tests still pass).
  • Run saves the caller's ambient context and restores it in a finally, so an await after Run returns can't capture a stopped-pumping context.
  • ResetState clears the ambient context only when it's this app's own; a caller-owned context is left untouched.
  • MainLoopSyncContext.Post/Send fall back to 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 reproduces the exact #5636 scenario (Initawait Task.Yield ()RunAsync) on a dedicated thread: it failed with a 10s timeout before the fix and passes in ~2s after. New SyncContextLifecycleTests pin the Init/Run/Dispose ambient-context contract and the post-dispose Post fallback. Tests that encoded the #5588 ambient-at-Init behavior (SynchronizationContextTests, Dispose_Resets_SyncContext, the Init_Posts_* post-run assertions) were updated to the new contract.

Verification

  • Parallelizable: 17,564 passed
  • Non-parallelizable: 32 passed
  • Integration: 437 passed

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

…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Terminal.Gui/App/ApplicationImpl.Run.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Init and restores ambient contexts after Run.
  • 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 null when it sees the app's context, but the caller's context underneath it is not retained for direct Begin usage. For example, with a foreign context installed, Begin replaces it, and Dispose while 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 Send blocking forever after a session ends: Initialized is still true, so a non-main-thread caller enters _app.Invoke and waits for a callback that no loop can drain. Send needs to use the same inactive-session fallback as Post (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.

Comment thread Terminal.Gui/App/ApplicationImpl.Lifecycle.cs
Comment thread Terminal.Gui/App/ApplicationImpl.Run.cs
Comment thread Terminal.Gui/App/MainLoop/MainLoopSyncContext.cs Outdated
…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
@tig
tig merged commit 37fb986 into tig/remove-cm-followup Aug 23, 2026
1 check passed
@tig
tig deleted the tig/fix-5636-runasync-deadlock branch August 23, 2026 15:33
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.

3 participants