Skip to content

Fixes #5653. Add awaitable session-aware UI dispatch - #5694

Merged
harder merged 20 commits into
tui-cs:developfrom
harder:fix/5653-awaitable-session-dispatch
Oct 5, 2026
Merged

harder merged 20 commits into
tui-cs:developfrom
harder:fix/5653-awaitable-session-dispatch

Conversation

@harder

@harder harder commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

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.

To pull down this PR locally

git remote add harder https://github.com/harder/Terminal.Gui.git
git fetch harder fix/5653-awaitable-session-dispatch
git checkout harder/fix/5653-awaitable-session-dispatch

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.

Copilot review overview

🟡 Changes recommended

Session teardown can race owner validation, allowing work associated with an ended session to execute.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity · 1 Low severity

Open (3)
What changed in this PR

Adds awaitable, cancellable, session-aware UI dispatch while preserving the existing IApplication.Invoke API.

Changes:

  • Adds InvokeAsync extensions and optional dispatcher interface.
  • Integrates dispatch cancellation with session/application lifecycles.
  • Adds documentation, UICatalog usage, and focused tests.
File Description
ApplicationDispatchTests.cs Tests dispatch lifecycle and concurrency behavior.
UiDispatchOperation.cs Manages individual dispatch state.
TimedEvents.cs Adds bulk timeout removal.
IApplicationAsyncDispatcher.cs Defines optional async dispatch capability.
IApplication.cs Adds async API references.
ApplicationImpl.Run.cs Integrates session cancellation and running-state checks.
ApplicationImpl.Lifecycle.cs Cancels dispatches during shutdown.
ApplicationImpl.Dispatch.cs Implements dispatch scheduling and cancellation.
ApplicationImpl.cs Implements the dispatcher interface.
ApplicationDispatchExtensions.cs Exposes public InvokeAsync overloads.
Threading.cs Demonstrates session-owned dispatch.
multitasking.md Documents async dispatch semantics.

💡 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.Dispatch.cs Outdated
Comment thread Terminal.Gui/App/ApplicationImpl.Dispatch.cs Outdated
Comment thread Terminal.Gui/App/IApplication.cs Outdated

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.

Copilot review overview

🟡 Changes recommended

A throwing timeout-added subscriber can leave an orphaned queued timeout, and a documentation formatting violation also remains.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Resolved since last review (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Clean up timeout when Added subscriber throws

Terminal.Gui/​App/​ApplicationImpl.Dispatch.cs:78

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.

Comment thread docfx/docs/multitasking.md Outdated

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.

Copilot review overview

🟡 Changes recommended

Timer-backed dispatch can execute off the UI thread or leave returned tasks permanently pending when timers are cleared.

Review effort: Balanced
Findings: 2 High severity

Open (2)
Resolved since last review (1)

Comment thread Terminal.Gui/App/ApplicationImpl.Dispatch.cs Outdated
Comment thread Terminal.Gui/App/ApplicationImpl.Dispatch.cs Outdated

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.

Copilot review overview

🟡 Changes recommended

Nested session unwinding and lifecycle-handler exceptions can leave application state inconsistent.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (2)

In code that hasn't changed since last review

Medium severity 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.

Medium severity Bound synchronization-context joins and assert completion

Tests/​UnitTestsParallelizable/​Application/​ApplicationDispatchTests.cs:654

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:

  • line 765
  • line 799
  • line 857
  • line 1168

Comment thread Terminal.Gui/App/ApplicationImpl.Run.cs Outdated
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>

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.

Copilot review overview

🟡 Changes recommended

An exception from session state-change handlers can leave a token permanently half-ended and prevent cleanup retries.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread Terminal.Gui/App/ApplicationImpl.Run.cs Outdated
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>

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.

Copilot review overview

🟡 Changes recommended

Caller cancellation can strand UI awaiters, and early lifecycle-handler exceptions can skip subsequent teardown work.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Comment thread Terminal.Gui/App/UiDispatchOperation.cs
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>
@harder
harder requested a balanced review from Copilot October 1, 2026 15:19

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.

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.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

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

Comment thread Terminal.Gui/App/ApplicationImpl.Dispatch.cs
harder and others added 2 commits October 1, 2026 19:03
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>

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.

Copilot review overview

🟡 Changes recommended

Concurrent and reentrant session transitions can corrupt the session stack or leave a running session with the wrong synchronization context.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity 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.

Comment thread Terminal.Gui/App/ApplicationImpl.Run.cs Outdated
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>

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.

Copilot review overview

🟡 Changes recommended

Session teardown still has race and reentrancy windows affecting dispatch cancellation and synchronization-context restoration.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Atomically release final-session work during teardown

Terminal.Gui/​App/​ApplicationImpl.Dispatch.cs:175

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.

Medium severity 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.

Comment thread Terminal.Gui/App/ApplicationImpl.Run.cs Outdated
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>

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.

Copilot review overview

🔵 Needs a closer look

Reentrant session startup can restore the wrong synchronization context, and throwing modal handlers can skip required lifecycle notifications.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity 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.

Low severity 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.

Low severity 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>

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.

Copilot review overview

🟡 Changes recommended

Session teardown and dispatch startup remain synchronized by different locks, allowing queued UI work to start after its session stops.

Review effort: Balanced
Findings: 1 High severity

Open (1)

Comment thread Terminal.Gui/App/ApplicationImpl.Dispatch.cs
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>

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.

Copilot review overview

🔵 Needs a closer look

Disposal can discard the caller’s saved synchronization context after a worker-thread final End.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Restore caller synchronization context after worker-thread End

Terminal.Gui/​App/​ApplicationImpl.Lifecycle.cs:330

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.

Low severity Document retained null-runnable token lifecycle semantics

Terminal.Gui/​App/​IApplication.cs:456

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>
@harder
harder marked this pull request as ready for review October 2, 2026 21:12
@harder
harder requested a review from tig as a code owner October 2, 2026 21:12
@harder

harder commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

I've done multiple rounds of code reviews and updates using GPT-6 Sol, Opus 5.5, and the GitHub CoPilot code review bot. Ready for final review.

@harder
harder merged commit 06b2607 into tui-cs:develop Oct 5, 2026
14 checks passed
@harder
harder deleted the fix/5653-awaitable-session-dispatch branch October 5, 2026 16:59
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.

Add awaitable, cancellable, session-aware UI dispatch

3 participants