Skip to content

Bug: uithread-init-accepts-non-sta-callers #787

Description

@drmoisan
  • Work Mode: full-bug

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

  1. 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.
  2. Observe that the call returns normally rather than rejecting the caller.
  3. 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

  • Attached minimal logs or screenshot
  • Snippet: not captured. The defect is a missing precondition rather than a failure, so it produces no diagnostic; it is established by reading the call chain below.

Impact / Severity

  • Blocker
  • High
  • Medium
  • Low

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

Activity

  1. drmoisan commented on Sep 8, 2026

    @drmoisan
    OwnerAuthor

    Consolidated into #809 on 2026-09-07 (maintainer decision: batch review residuals by blast radius). All acceptance criteria from this issue are carried in #809; this issue is closed as superseded, not as fixed.

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions