Skip to content

Begin() without Run() leaves MainLoopSyncContext installed, and Post() then queues continuations nothing drains #5680

Description

@ntatschner

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions