Repository navigation
Add opt-in UI-thread affinity diagnostics for View mutations #5654
Description
Problem
Terminal.Gui documents a strict single-threaded UI model:
all UI operations must happen on the main thread
Terminal.Gui/docfx/docs/multitasking.md
Lines 5 to 12 in 48efa0c
| Terminal.Gui applications run on a single main thread with an event loop that processes keyboard, mouse, and system events. This document explains how to properly handle background work, timers, and asynchronous operations while keeping your UI responsive. | |
| ## Threading Model | |
| Terminal.Gui follows the standard UI toolkit pattern where **all UI operations must happen on the main thread**. Attempting to modify views or their properties from background threads will result in undefined behavior and potential crashes. | |
| ### The Golden Rule | |
| > Always use `App?.Invoke()` (from within a View) or `app.Invoke()` (with an IApplication instance) to update the UI from background threads. |
However, view mutations generally do not verify thread affinity. Representative setters such as View.Text and View.Title update shared formatting/layout/draw state and raise events without checking View.App.MainThreadId:
Terminal.Gui/Terminal.Gui/ViewBase/View.Text.cs
Lines 62 to 89 in 48efa0c
public string Text { get => _text; set { if (_text == value) { return; } if (OnTextChanging (value)) { return; } _text = value; UpdateTextFormatterText (); if (TryRedrawWithoutLayout ()) { RaiseTextChanged (); return; } SetNeedsLayout (); RaiseTextChanged (); } Terminal.Gui/Terminal.Gui/ViewBase/View.cs
Lines 527 to 564 in 48efa0c
public string Title { get { return _title; } set { #if DEBUG_IDISPOSABLE if (EnableDebugIDisposableAsserts && WasDisposed) { throw new ObjectDisposedException (ToDebugString ()); } #endif if (value == _title) { return; } if (OnTitleChanging (ref value)) { return; } CancelEventArgs<string> args = new (ref _title, ref value); TitleChanging?.Invoke (this, args); if (args.Cancel) { return; } _title = value; TitleTextFormatter.Text = _title; SetTitleTextFormatterSize (); SetHotKeyFromTitle (); SetNeedsDraw (); OnTitleChanged (); TitleChanged?.Invoke (this, new EventArgs<string> (in _title)); }
Off-thread mutations therefore fail later as intermittent layout corruption, collection races, disposed-view access, or hangs instead of failing at the call site with a useful explanation.
Proposed improvement
Add opt-in UI-thread affinity diagnostics for views attached to an initialized/running application. Possible building blocks:
IApplication.CheckAccess()to report whether the current thread is the application's UI thread.IApplication.VerifyAccess()and/orView.VerifyAccess()to throw a diagnostic exception with the expected/current thread IDs and view/session identity.- A debug/test-only or explicitly enabled guard used by core mutation pathways.
The guard must not reject legitimate setup. Terminal.Gui applications commonly construct and configure views before Begin/Run, when View.App is null, and may inspect or configure detached views. Affinity should be enforced once a view is attached to an initialized/running application session, or according to another clearly documented attachment rule.
Visibility and top-runnable status are not ownership boundaries for this diagnostic. Applications can keep persistent tab views attached to the application while hiding them and showing another tab. Those hidden or non-top views still participate in shared layout, draw, hierarchy, focus, and event state, so off-thread mutation remains invalid. The diagnostic should therefore cover every attached view, including persistent hidden tabs, rather than only Visible views or views beneath the current TopRunnable.
There is precedent for opt-in correctness diagnostics that impose no normal release-build cost: View.EnableDebugIDisposableAsserts, View.ValidatePosDim, and ViewDiagnosticFlags.
Scope: thread affinity, not lifetime ownership
This diagnostic would catch a mutation performed on the wrong thread. It would not catch a stale callback that is correctly executing on MainThreadId after the modal, request, tab, or workspace that owned the referenced views has ended.
Recent SkillView hardening found several examples of that distinct failure mode:
- PR Control: multi-line text editor. #12: queued search/preview callbacks could outlive their request leases — Harden removal, async lifecycles, cache, and logging harder/gh-skillview#12
- PR Ponder: ListView, should it support multiple columns, and if so, what? #16: synchronous install modals needed to retain operation ownership until their queued terminal UI callback ran — Complete portable adversarial hardening backlog harder/gh-skillview#16
- PR Control: Attributed Label #17: the advanced-remove workflow needed the same terminal-dispatch ownership and teardown draining — Harden removal with native filesystem identities harder/gh-skillview#17
Those callbacks may run on the correct UI thread and still be invalid because their originating owner or session is stale. Detecting or preventing that requires the awaitable, cancellable, session-owned dispatch and lifetime contract tracked by #5653, not a thread-affinity check:
The two improvements are complementary: this issue diagnoses wrong-thread access; #5653 establishes whether correctly marshaled work still owns the state it intends to mutate.
Suggested initial scope
Start with high-value mutation funnels and operations rather than attempting to instrument every setter at once:
- hierarchy changes (
Add/Remove); - layout/draw invalidation (
SetNeedsLayout/SetNeedsDraw); - focus/navigation mutations;
- representative core properties such as
Text,Title,Visible,Enabled,X,Y,Width, andHeight.
The helper should be reusable by derived/custom views so application authors can protect their own mutation methods.
Acceptance criteria
- An off-thread mutation of any view attached to an initialized/running application is detected at or near the mutation site when diagnostics are enabled.
- Attached hidden views and attached non-top views receive the same affinity checks as the current visible/top runnable.
- The diagnostic identifies the view, current thread, expected main thread, and owning runnable/session when available.
- Pre-run construction/configuration and detached-view configuration remain valid.
- Mutation on the main UI thread remains valid for visible and hidden views and for both top and non-top runnables in a nested session stack.
- A stale callback running on the correct UI thread is not misrepresented as an affinity violation; documentation points to Add awaitable, cancellable, session-aware UI dispatch #5653 for ownership/session enforcement.
- The disabled path has negligible or zero release-build overhead.
- Tests cover pre-run setup, detached views, attached hidden views, active main-thread mutation, active worker-thread mutation, nested sessions, session end, and application disposal.
- The multitasking guide shows how to enable the diagnostic and how to fix a thread-affinity failure with
App.Invokeor the proposed awaitable dispatch API.
A Roslyn analyzer may later catch obvious patterns such as direct mutation inside Task.Run, but runtime thread identity and session lifetime generally cannot be proven statically. This issue is intentionally scoped to a reliable runtime/debug thread-affinity guard. It does not attempt to diagnose every async event-handler capture or replace #5653's session-ownership contract.
Activity