Version
Terminal.Gui 2.5.0 (not reproducible on 2.4.17). .NET 10, xUnit 2.9.3, ANSI driver, headless.
What happens
IApplication.Begin(runnable) installs the app's MainLoopSyncContext as the calling thread's ambient SynchronizationContext (ApplicationImpl.Run.cs) and leaves it there. Only Run (in its finally) or End restores the caller's context. A caller that uses Begin to stand a view up without ever entering Run — a headless test harness that drives LayoutAndDraw and RunTimers itself — is left with the toolkit's context on its thread for the rest of the test.
From that point, every await on that thread resumes through MainLoopSyncContext.Post. While a session is expected to pump (CanPumpPostedWork is true after Begin), Post calls Invoke, which from a non-main thread queues the continuation on TimedEvents. Nothing drains TimedEvents unless a loop iterates. The continuation is never run, the awaiting method never completes, and nothing is blocked, so the process just sits: in our CI the test host idled for the remaining 28 minutes of a 30-minute job with no thread inside the test.
It is timing-dependent: if the awaited task has already completed when the await looks, no post happens and everything passes. Slower machines lose that race more often; our arm64 legs hung, x64 rarely did.
Minimal reproduction (xUnit, ANSI driver)
[Fact]
public async Task Await_after_begin_never_resumes()
{
IApplication app = Application.Create();
app.Init(DriverRegistry.Names.ANSI);
app.Screen = new Rectangle(0, 0, 80, 24);
var view = new Window();
app.Begin(view); // installs MainLoopSyncContext on this thread
app.LayoutAndDraw();
// SynchronizationContext.Current is now Terminal.Gui.App.MainLoopSyncContext
await Task.Run(() => Thread.Sleep(50)); // continuation posts to the toolkit
// never reached: the continuation sits in TimedEvents, nothing iterates
app.Dispose();
}
Probe output from our harness, before and after the await, on the hanging run:
before: ctx=Terminal.Gui.App.MainLoopSyncContext thread=7
(no "after" line)
and a hang dump showed the input loop in its normal 20ms delay, no thread inside the test.
Expected
One of:
Begin restores the caller's context before returning, the way Run does, and installs it only for the duration of Run; or
Post while no loop is iterating falls back to the thread pool (as it already does once HasEndedSession), rather than queueing on TimedEvents; or
- the
Begin docs say that the caller must restore the context or call Run/End.
Workaround
Restore the ambient context after Begin:
var ambient = SynchronizationContext.Current;
app.Begin(view);
SynchronizationContext.SetSynchronizationContext(ambient);
Our fix and a regression test asserting the context is unchanged after standing a screen up: ntatschner/loadout-cli#68
Related: #5636 (deadlock after await on the ambient context at Init, fixed by #5641 moving installation to Begin — this is the same shape one step later, for callers that Begin without Run).
Version
Terminal.Gui 2.5.0 (not reproducible on 2.4.17). .NET 10, xUnit 2.9.3, ANSI driver, headless.
What happens
IApplication.Begin(runnable)installs the app'sMainLoopSyncContextas the calling thread's ambientSynchronizationContext(ApplicationImpl.Run.cs) and leaves it there. OnlyRun(in itsfinally) orEndrestores the caller's context. A caller that usesBeginto stand a view up without ever enteringRun— a headless test harness that drivesLayoutAndDrawandRunTimersitself — is left with the toolkit's context on its thread for the rest of the test.From that point, every
awaiton that thread resumes throughMainLoopSyncContext.Post. While a session is expected to pump (CanPumpPostedWorkis true afterBegin),PostcallsInvoke, which from a non-main thread queues the continuation onTimedEvents. Nothing drainsTimedEventsunless a loop iterates. The continuation is never run, the awaiting method never completes, and nothing is blocked, so the process just sits: in our CI the test host idled for the remaining 28 minutes of a 30-minute job with no thread inside the test.It is timing-dependent: if the awaited task has already completed when the
awaitlooks, no post happens and everything passes. Slower machines lose that race more often; our arm64 legs hung, x64 rarely did.Minimal reproduction (xUnit, ANSI driver)
Probe output from our harness, before and after the await, on the hanging run:
and a hang dump showed the input loop in its normal 20ms delay, no thread inside the test.
Expected
One of:
Beginrestores the caller's context before returning, the wayRundoes, and installs it only for the duration ofRun; orPostwhile no loop is iterating falls back to the thread pool (as it already does onceHasEndedSession), rather than queueing onTimedEvents; orBegindocs say that the caller must restore the context or callRun/End.Workaround
Restore the ambient context after
Begin:Our fix and a regression test asserting the context is unchanged after standing a screen up: ntatschner/loadout-cli#68
Related: #5636 (deadlock after await on the ambient context at Init, fixed by #5641 moving installation to Begin — this is the same shape one step later, for callers that Begin without Run).