Summary
UtilitiesCS.UiThread.Init() accepts a call from any thread. It performs no apartment-state check, and neither does the Initialize() it guards. A worker-thread call therefore succeeds silently and installs that worker's non-pumping Dispatcher, SynchronizationContext, and managed thread id into set-once process-global state, after which every consumer of UiThread.Dispatcher, UiThread.UiSyncContext, UiThread.AutoScaleFactor, and UiThread.UiThreadId marshals onto a thread that never runs a message loop.
Raised as the behavioral half of finding C09 in the three-phase post-merge review of PR #778 (issue #584). The message-text half of C09 is delivered in issue #782; this entry is the behavior change that #782 explicitly placed out of scope.
Environment
- OS/version: Windows 11 Pro 10.0.26200
- Runtime: .NET Framework 4.8, VSTO add-in hosted by Outlook desktop
- Command/flags used:
vstest.console.exe <nine test assemblies> /InIsolation
- Data source or fixture:
QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs
Steps to Reproduce
- Call
UtilitiesCS.UiThread.Init(false) from a thread whose apartment state is MTA. The in-repo instance is QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329, inside Worker_RunWorkerCompleted_HandlesCompletionCorrectly at :326, which is a plain [TestMethod] on a class carrying [TestClass] only.
- Observe that the call returns normally rather than rejecting the caller.
- Read
UiThread.Dispatcher, UiThread.UiSyncContext, or UiThread.UiThreadId from any later code in the same process.
Expected Behavior
Init() rejects a non-STA caller with a named InvalidOperationException before it captures anything, so the process-global UI context can only ever be populated from a thread that runs a message loop.
Actual Behavior
The call succeeds. Initialize() constructs and shows a WinForms SyncContextForm, and CaptureUiVariables() reads SynchronizationContext.Current, this.AutoScaleFactor, Dispatcher.CurrentDispatcher, and Thread.CurrentThread.ManagedThreadId from the calling thread unconditionally. Because the latch at UiThread.cs:36 is single-shot, the first caller wins permanently, so a worker-thread Init() that happens to run first poisons the globals for the process lifetime. The exception message added by issue #782 names Init() as the remedy, which offers nothing in this state because Init() has already run.
Logs / Screenshots
Impact / Severity
Medium rather than High because the hazard is presently reachable only from test code. In production TaskMaster/ThisAddIn.cs:35-40 is the only direct caller and runs on the Outlook STA during ThisAddIn_Startup. The severity would rise if any worker-thread code path began reading the lazy accessors before startup completed.
Source
From: docs/features/potential/2026-09-05-uithread-init-accepts-non-sta-callers.md
Summary
UtilitiesCS.UiThread.Init()accepts a call from any thread. It performs no apartment-state check, and neither does theInitialize()it guards. A worker-thread call therefore succeeds silently and installs that worker's non-pumpingDispatcher,SynchronizationContext, and managed thread id into set-once process-global state, after which every consumer ofUiThread.Dispatcher,UiThread.UiSyncContext,UiThread.AutoScaleFactor, andUiThread.UiThreadIdmarshals onto a thread that never runs a message loop.Raised as the behavioral half of finding C09 in the three-phase post-merge review of PR #778 (issue #584). The message-text half of C09 is delivered in issue #782; this entry is the behavior change that #782 explicitly placed out of scope.
Environment
vstest.console.exe <nine test assemblies> /InIsolationQuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.csSteps to Reproduce
UtilitiesCS.UiThread.Init(false)from a thread whose apartment state is MTA. The in-repo instance isQuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329, insideWorker_RunWorkerCompleted_HandlesCompletionCorrectlyat:326, which is a plain[TestMethod]on a class carrying[TestClass]only.UiThread.Dispatcher,UiThread.UiSyncContext, orUiThread.UiThreadIdfrom any later code in the same process.Expected Behavior
Init()rejects a non-STA caller with a namedInvalidOperationExceptionbefore it captures anything, so the process-global UI context can only ever be populated from a thread that runs a message loop.Actual Behavior
The call succeeds.
Initialize()constructs and shows a WinFormsSyncContextForm, andCaptureUiVariables()readsSynchronizationContext.Current,this.AutoScaleFactor,Dispatcher.CurrentDispatcher, andThread.CurrentThread.ManagedThreadIdfrom the calling thread unconditionally. Because the latch atUiThread.cs:36is single-shot, the first caller wins permanently, so a worker-threadInit()that happens to run first poisons the globals for the process lifetime. The exception message added by issue #782 namesInit()as the remedy, which offers nothing in this state becauseInit()has already run.Logs / Screenshots
Impact / Severity
Medium rather than High because the hazard is presently reachable only from test code. In production
TaskMaster/ThisAddIn.cs:35-40is the only direct caller and runs on the Outlook STA duringThisAddIn_Startup. The severity would rise if any worker-thread code path began reading the lazy accessors before startup completed.Source
From: docs/features/potential/2026-09-05-uithread-init-accepts-non-sta-callers.md