You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add awaitable, cancellable UI dispatch with optional session ownership while keeping the existing Invoke API and required IApplication surface unchanged.
Investigation
At this PR's develop base, worker-thread Invoke callbacks queue through zero-duration timeouts. Callers cannot await execution, observe callback exceptions through a task, or prevent queued work from running after its owning session ends. The synchronization context handles async continuations but does not provide this dispatch contract. The timer queue is cleared during disposal, so a wrapper that only waits for the queued callback can strand its task.
Changes
Add app.InvokeAsync(...) extension overloads for Action and Action<IApplication>, with optional SessionToken ownership and CancellationToken. Custom application implementations can opt in through IApplicationAsyncDispatcher.
Complete only after execution; cancel pending work when the caller cancels, the owner ends, the final session ends, or the app shuts down. Do not interrupt callbacks that have already started.
Return callback exceptions on the task without passing them to the main-loop error handler; treat OperationCanceledException carrying the canceled caller token as cancellation.
Publish final-session and disposal state before completing canceled tasks, so UI-thread await continuations fall back to the thread pool instead of being stranded on a stopped loop.
Preserve outer-session dispatch through inner-session transitions, and preserve still-running inner dispatch if a non-top session ends.
Keep synchronization-context continuations on the UI thread during nested session transitions by checking every running session, including the outer session beneath a temporarily stopped cached top.
Close owned dispatches and cancel pending work before session state-change handlers run, while preserving their access to SessionToken.Runnable. Coordinate teardown with admission and the queued callback's start decision, check shutdown before returning an already-canceled task, and link the matching Action<IApplication> overload from the IApplication.Invoke documentation.
Queue async dispatches separately from public timers and drain that queue only on the application's UI thread. Direct RunTimers calls on worker threads cannot execute dispatch actions, and TimedEvents.Remove or StopAll cannot strand their tasks. Remove canceled operations from the queue immediately so their callbacks are released without waiting for another loop iteration.
Restore the nearest running session when nested sessions unwind after a non-top session ended. End now discards ended tokens beneath the popped session, so the outer session regains TopRunnable and modality, and its token no longer leaks in SessionStack. Inline UI-thread dispatch uses the same HasRunningSession check as admission and draining.
Finish End teardown when an IsModalChanged or IsRunningChanged handler throws. Because End marks the token dispatch-closed before raising those events, a retried End is a no-op; token finalization (Result, clearing Runnable, restoring the caller's synchronization context, and SessionEnded) now runs in a finally block. The synchronization context is restored before SessionEnded so a throwing or awaiting subscriber cannot observe the stopped loop's context.
Keep this app's synchronization context ambient while any session runs. The caller's context is saved once per app when the first session begins and restored only when the last running session ends, so ending an outer session first does not move the inner session's continuations off the UI thread. Run no longer reinstalls the app context after the last session has ended. If the final End ran on a worker thread, Dispose restores the saved caller context on the UI thread instead of clearing it to null.
Start owned dispatches only after the owner session is running. A dispatch made from SessionBegun during a nested Begin waits for the next drain instead of running inline against the pending session. Work that cannot start yet stays queued until teardown releases it.
Skip sessions ended beneath the top when drawing and hit-testing. Their tokens stay on the stack without a runnable until the sessions above them end. LayoutAndDraw and GetViewsUnderLocation previously threw NullReferenceException on them, and the ended session's cells stayed on screen. The next draw now clears the screen.
Make End tear a session down only once, including when it is called concurrently or reentrantly. End takes a per-session claim before raising the cancellable IsRunningChanging, so another End for the same session returns without raising the event again or bypassing a later veto. The claim is released if stopping is canceled or a handler throws. Owned dispatch closes under the session lock only after the veto passes. End reads IsModal under that lock and pops the stack only when its token is the top. Before this, two callers could both pass the unlocked check: the second popped the outer session and both raised the lifecycle events.
Once stopping proceeds, End raises every remaining notification (IsModalChanged, IsRunningChanged(false), and SessionEnded) even if a handler throws, then rethrows the first exception, so SessionEnded never fires without IsRunningChanged(false). End rechecks for running sessions before restoring the caller's synchronization context, so a session begun by an End lifecycle handler keeps the app context ambient.
Release MainLoopSyncContext work that was queued before the UI loop stopped. Posts are tracked until they run; when the final session ends or dispatch stops for disposal, any that have not run move to the thread pool, matching the IApplication.RunAsync deadlocks after await on ambient SynchronizationContext #5636 fallback for later posts. This keeps an await from being stranded when a worker cancels the caller token just before teardown, and keeps a worker blocked in Send from hanging. Each callback runs exactly once.
Drain MainLoopSyncContext posts only from the UI thread's DrainDispatches during a running session, instead of the public TimedEvents queue, so a worker calling RunTimers cannot run await continuations or Send callbacks off the UI thread.
Use one start predicate for all queued UI work: an InvokeAsync operation or MainLoopSyncContext post starts only on the UI thread while a session is running and dispatch is not stopping, checked under the dispatch lock. Teardown publishes first (owner dispatch closed, session stopped, or disposal begun) and then releases queued work under the same lock, so each item either starts on the UI thread or is released by teardown, never both and never neither.
Keep cancellation-registration publication synchronized with cancellation and callback completion.
Document lifecycle behavior and update the UICatalog Threading example to await a session-owned UI update. IRunnable.IsRunning, IsRunningChanged, and the conceptual docs now say that a token ended beneath the top stays on SessionStack until the sessions above it end, so IsRunning, not stack membership, identifies running sessions.
Add 65 focused tests, including an outer session ended before its inner session, a final End on a worker thread (including a later Dispose), a final session or nested owner stopping mid-drain, worker RunTimers pumps that must not run posted callbacks, UI awaiters and Send callers queued just before the loop stops, teardown after a throwing state-change handler, nested A/B/C session unwinding after a non-top End, a real app.Run dispatch, worker-thread timer pumping, timer removal and StopAll, a throwing TimedEvents.Added subscriber, lifecycle handlers that pump timers, dispatch from SessionBegun (including nested sessions not yet running), reentrant dispatch, disposal inside a callback, caller-token exception handling, bounded UI-await shutdown regressions, concurrent cancellation and teardown, nested/non-top sessions, a stopped-cached-top UI-thread regression, shutdown/cancellation precedence, custom application support, non-View runnables, drawing, hit-testing, and screen clearing after a non-top End, concurrent or reentrant End of the same session, a later End after a veto or a throwing IsRunningChanging handler, throwing End notification handlers, and a session begun by an End lifecycle handler.
Testing
dotnet build: passed with existing warnings only.
Focused dispatch, synchronization-context, session-token, runnable integration, application lifecycle, RunAsync, and TimedEvents classes: 187 passed; with Begin/End, layout, screen, and hit-test classes: 530 passed.
Full parallelizable suite on this revision: 17,778 total, 17,761 passed, 17 skipped, 0 failed.
Nonparallel unit tests: 32 passed.
Integration tests: 437 passed.
Local adversarial stress harness (not committed): owner-end, dispose, caller-cancel, and nested-churn races against worker dispatch; 0 stranded tasks, 0 off-thread callbacks, 0 callbacks after owner end or disposal, 0 spurious cancellations, and no leaked queue entries.
If a public TimedEvents.Added subscriber throws, Add has already inserted the timeout but never returns its token. This catch faults the task while leaving a queued no-op timeout behind—potentially indefinitely before the first session. Construct the timeout token first and remove it in the catch so all partial-add paths are cleaned up.
Finalize session cleanup when lifecycle notification handlers throw
Terminal.Gui/App/ApplicationImpl.Run.cs:447
IsDispatchClosed is set before the lifecycle notifications but only cleared by creating a new token. If RaiseIsModalChangedEvent or RaiseIsRunningChangedEvent throws, End exits before clearing token.Runnable, raising SessionEnded, or finishing result propagation; every later End/Dispose attempt then returns here as though cleanup completed. Track “ending” separately from “ended,” or finalize lifecycle state in a finally block while still propagating the handler exception.
Bound synchronization-context joins and assert completion
This join is unbounded, so a synchronization-context regression can stall the test runner indefinitely instead of failing. Use the same timed-and-asserted join pattern used earlier in this file.
This issue also appears in the following locations of the same file:
Ending a non-top session leaves its token in SessionStack. Ending the
session above it then peeked the ended token, leaving the still-running
outer session with no TopRunnable and IsModal false, and its token was
never popped. Discard ended tokens while unwinding so the nearest running
session becomes top again.
Also align inline InvokeAsync with HasRunningSession, the predicate used by
admission and draining, and simplify StopDispatching, whose flag was
always true.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
EndSessionDispatches marks the token dispatch-closed, which makes a retried
End a no-op. Run token finalization (Result, Runnable = null, sync context
restore, SessionEnded) in a finally block so a throwing IsModalChanged or
IsRunningChanged handler cannot leave the session half-ended.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A continuation posted to MainLoopSyncContext while the loop could pump was
queued as an anonymous Invoke timer. If the final session ended or the app
was disposed before that timer ran, the awaiter never resumed; a worker
blocked in Send never returned. This was reachable from InvokeAsync when a
worker canceled the caller token just before teardown.
Track posted callbacks under the dispatch lock. When the loop stops pumping
(final session End or StopDispatching), move any that have not run to the
thread pool, matching the tui-cs#5636 fallback for later posts. Removal from the
list claims a callback, so it runs exactly once. Send reuses Post.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Dispatch admission still has cancellation-publication and nested-session ownership races that can execute callbacks after cancellation or before their owner is active.
Cancellation can race before callback registration and start execution
Terminal.Gui/App/ApplicationImpl.Dispatch.cs:55
The operation becomes visible in _queuedDispatches before its cancellation callback is registered. The UI thread can therefore enter Execute, observe the token as not canceled, then have the token canceled before TryStartDispatch while the producer has not yet registered the callback; no state transition prevents TryStart, so user code runs even though cancellation preceded the start decision. Publish the cancellation registration before exposing the operation to the drain (and revalidate lifecycle state when it is finally enqueued).
A nested Begin raises SessionBegun before the new session is running, while the
outer session already allows UI work. An owned InvokeAsync from that handler ran
inline against the pending owner and could even end it before Begin finished.
Start owned work only when its owner is active (running and not closed). Work
that cannot start yet stays queued; teardown still releases it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ending a session beneath the top leaves its token on the stack without a
runnable until the sessions above it end. LayoutAndDraw and
GetViewsUnderLocation threw on that token, the root-runnable check counted it,
and the ended session's cells stayed on screen.
Skip tokens without a runnable and clear the screen on the next draw after a
non-top session ends.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Preserve app context when a replacement session remains
Terminal.Gui/App/ApplicationImpl.Run.cs:310
If an End lifecycle handler starts a replacement session during this Run, HasRunningSession is true but previousContext is the caller's pre-run context. This branch therefore removes the app context from the still-running replacement session. Preserve SynchronizationContext whenever a session remains, and restore previousContext only when none does.
This issue also appears on line 564 of the same file.
Claim teardown by setting IsDispatchClosed under the session lock, so a
concurrent or reentrant End that passed the unlocked check returns
instead of popping another session and raising lifecycle events twice.
Read IsModal under the lock and pop only when this token is the top.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Recomputing HasRunningSession here loses the fact that this End stopped the final session. Another thread can Begin a new session after End publishes the old stop but before this lock is acquired; queued application dispatches and sync-context posts are then preserved and can run in the new session instead of being canceled/released as promised for final-session teardown. Capture and release final-session work atomically with the stop/start decision so a new Begin cannot reopen this window.
Recheck running session before restoring caller context
Terminal.Gui/App/ApplicationImpl.Run.cs:578
endsLastRunningSession is only a snapshot taken before lifecycle handlers run. An IsModalChanged/IsRunningChanged handler can call Begin for a new session; when it returns, this branch still restores the caller context even though that new session is running, so its subsequent awaits no longer capture MainLoopSyncContext. Recheck the running-session state while synchronized with Begin before restoring the context.
Take a per-session end claim before IsRunningChanging, so a concurrent or
reentrant End returns instead of raising the event again or tearing down
ahead of a later veto. Release the claim when stopping is canceled or a
handler throws, so a later End can still stop the session. Owned
dispatch closes only after the veto passes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ensure IsRunningChanged(false) fires when modal handler throws
Terminal.Gui/App/ApplicationImpl.Run.cs:560
A throwing IsModalChanged handler jumps directly to the outer finally, so IsRunningChanged(false) is never raised even though SessionEnded is raised afterward. This contradicts the documented SessionEnded contract in IApplication.cs:466-477 and leaves stop observers uninformed. Invoke the remaining lifecycle notifications through nested finally blocks (with defined exception precedence) before raising SessionEnded.
This issue also appears on line 585 of the same file.
Document retained ended tokens in SessionStack
Terminal.Gui/App/IApplication.cs:460
The updated End behavior intentionally leaves a non-top ended token in SessionStack with Runnable == null until the sessions above it unwind, but these remarks still say every call removes the runnable/token immediately (also conflicting with the stack remarks at lines 113-116). Document the retained-token case so consumers of this public stack do not assume every entry is active.
Handle shutdown during sample background updates
docfx/docs/multitasking.md:39
This sample handles owner cancellation but not shutdown between LoadDataAsync and InvokeAsync. The newly documented API throws NotInitializedException once disposal has begun (line 44), so the recommended background-update pattern can still fault during normal application closure. Catch that exception here as the UICatalog example does.
…ntext
- End raises IsModalChanged, IsRunningChanged and SessionEnded even if an
earlier handler throws, then rethrows the first exception, so SessionEnded
never fires without IsRunningChanged(false).
- Recheck HasRunningSession when restoring the caller's synchronization
context, so a session begun by an End lifecycle handler keeps the app
context ambient.
- Document that tokens ended beneath the top stay on SessionStack until the
sessions above them end.
- Catch NotInitializedException in the owned InvokeAsync docs sample.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A claim that runs after End publishes a stop but before it releases queued
work either read pre-stop state (ordered before teardown) or sees the stop
and leaves the item for the release. End publishes under _sessionStackLock
alone so IRunnable state changes never block dispatch callers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
If the final End runs on a worker, it cannot restore the UI thread's ambient context, so that thread still has ownContext while _callerSynchronizationContext retains its original marker. Dispose currently replaces it with null here and then discards the saved marker. Restore the saved caller context instead; otherwise disposing after a worker-thread End silently loses the caller's synchronization context.
This new tombstone behavior conflicts with the public IRunnable.IsRunning contract in Terminal.Gui/App/Runnable/IRunnable.cs:63-78 and its IsRunningChanged remarks at lines 140-148, which state that false means the runnable/token has been removed from SessionStack. Update those public docs to describe the retained null-runnable token so implementers and stack consumers do not receive contradictory lifecycle guarantees.
If the final End ran on a worker, it could not restore the UI thread's
ambient context, so Dispose found this app's context still ambient and
replaced it with null, losing the caller's context saved by Begin. Restore
the saved caller context instead, matching End.
Update IRunnable.IsRunning/IsRunningChanged and the conceptual docs: a
token ended beneath the top stays on SessionStack until the sessions above
it end, so IsRunning, not stack membership, identifies running sessions.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
InvokeAPI and requiredIApplicationsurface unchanged.Investigation
At this PR's
developbase, worker-threadInvokecallbacks queue through zero-duration timeouts. Callers cannot await execution, observe callback exceptions through a task, or prevent queued work from running after its owning session ends. The synchronization context handles async continuations but does not provide this dispatch contract. The timer queue is cleared during disposal, so a wrapper that only waits for the queued callback can strand its task.Changes
app.InvokeAsync(...)extension overloads forActionandAction<IApplication>, with optionalSessionTokenownership andCancellationToken. Custom application implementations can opt in throughIApplicationAsyncDispatcher.OperationCanceledExceptioncarrying the canceled caller token as cancellation.awaitcontinuations fall back to the thread pool instead of being stranded on a stopped loop.SessionToken.Runnable. Coordinate teardown with admission and the queued callback's start decision, check shutdown before returning an already-canceled task, and link the matchingAction<IApplication>overload from theIApplication.Invokedocumentation.RunTimerscalls on worker threads cannot execute dispatch actions, andTimedEvents.RemoveorStopAllcannot strand their tasks. Remove canceled operations from the queue immediately so their callbacks are released without waiting for another loop iteration.Endnow discards ended tokens beneath the popped session, so the outer session regainsTopRunnableand modality, and its token no longer leaks inSessionStack. Inline UI-thread dispatch uses the sameHasRunningSessioncheck as admission and draining.Endteardown when anIsModalChangedorIsRunningChangedhandler throws. BecauseEndmarks the token dispatch-closed before raising those events, a retriedEndis a no-op; token finalization (Result, clearingRunnable, restoring the caller's synchronization context, andSessionEnded) now runs in afinallyblock. The synchronization context is restored beforeSessionEndedso a throwing or awaiting subscriber cannot observe the stopped loop's context.Runno longer reinstalls the app context after the last session has ended. If the finalEndran on a worker thread,Disposerestores the saved caller context on the UI thread instead of clearing it tonull.SessionBegunduring a nestedBeginwaits for the next drain instead of running inline against the pending session. Work that cannot start yet stays queued until teardown releases it.LayoutAndDrawandGetViewsUnderLocationpreviously threwNullReferenceExceptionon them, and the ended session's cells stayed on screen. The next draw now clears the screen.Endtear a session down only once, including when it is called concurrently or reentrantly.Endtakes a per-session claim before raising the cancellableIsRunningChanging, so anotherEndfor the same session returns without raising the event again or bypassing a later veto. The claim is released if stopping is canceled or a handler throws. Owned dispatch closes under the session lock only after the veto passes.EndreadsIsModalunder that lock and pops the stack only when its token is the top. Before this, two callers could both pass the unlocked check: the second popped the outer session and both raised the lifecycle events.Endraises every remaining notification (IsModalChanged,IsRunningChanged(false), andSessionEnded) even if a handler throws, then rethrows the first exception, soSessionEndednever fires withoutIsRunningChanged(false).Endrechecks for running sessions before restoring the caller's synchronization context, so a session begun by anEndlifecycle handler keeps the app context ambient.MainLoopSyncContextwork that was queued before the UI loop stopped. Posts are tracked until they run; when the final session ends or dispatch stops for disposal, any that have not run move to the thread pool, matching the IApplication.RunAsync deadlocks after await on ambient SynchronizationContext #5636 fallback for later posts. This keeps anawaitfrom being stranded when a worker cancels the caller token just before teardown, and keeps a worker blocked inSendfrom hanging. Each callback runs exactly once.MainLoopSyncContextposts only from the UI thread'sDrainDispatchesduring a running session, instead of the publicTimedEventsqueue, so a worker callingRunTimerscannot runawaitcontinuations orSendcallbacks off the UI thread.InvokeAsyncoperation orMainLoopSyncContextpost starts only on the UI thread while a session is running and dispatch is not stopping, checked under the dispatch lock. Teardown publishes first (owner dispatch closed, session stopped, or disposal begun) and then releases queued work under the same lock, so each item either starts on the UI thread or is released by teardown, never both and never neither.IRunnable.IsRunning,IsRunningChanged, and the conceptual docs now say that a token ended beneath the top stays onSessionStackuntil the sessions above it end, soIsRunning, not stack membership, identifies running sessions.Endon a worker thread (including a laterDispose), a final session or nested owner stopping mid-drain, workerRunTimerspumps that must not run posted callbacks, UI awaiters andSendcallers queued just before the loop stops, teardown after a throwing state-change handler, nested A/B/C session unwinding after a non-topEnd, a realapp.Rundispatch, worker-thread timer pumping, timer removal andStopAll, a throwingTimedEvents.Addedsubscriber, lifecycle handlers that pump timers, dispatch fromSessionBegun(including nested sessions not yet running), reentrant dispatch, disposal inside a callback, caller-token exception handling, bounded UI-await shutdown regressions, concurrent cancellation and teardown, nested/non-top sessions, a stopped-cached-top UI-thread regression, shutdown/cancellation precedence, custom application support, non-View runnables, drawing, hit-testing, and screen clearing after a non-topEnd, concurrent or reentrantEndof the same session, a laterEndafter a veto or a throwingIsRunningChanginghandler, throwingEndnotification handlers, and a session begun by anEndlifecycle handler.Testing
dotnet build: passed with existing warnings only.To pull down this PR locally