Skip to content

Bug: uithread-init-contract-residuals-784-787-788 #809

Description

@drmoisan
  • Work Mode: full-bug

Summary

Consolidates three findings on one file, UtilitiesCS/Threading/UiThread.cs, that were filed separately as #784, #787, and #788 after the #781 and #782 reviews. (1) Init() accepts a non-STA caller and installs that worker's non-pumping dispatcher and context into set-once process-global state (#787). (2) Init() consumes its single-shot latch before Initialize() runs, so a failed first attempt can never be retried, and the naive re-arm was measured to regress in #782 (#788). (3) SynchronizationContextAwaiter.IsCompleted compares contexts by reference, so any context captured inside a WPF dispatcher operation always posts instead of continuing inline on the UI thread (#784). All three touch the same initialization and awaiter code and should ship as one change with one test suite.

Environment

  • OS/version: Windows 11 Pro 10.0.26200
  • Runtime: .NET Framework 4.8 VSTO add-in hosted by Outlook desktop; main at 04a54e68
  • Command/flags used: vstest.console.exe <test assemblies> /InIsolation; runtime probe in the Bug: breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers #781 feature folder (evidence/other/dispatcher-synccontext-probe.2026-09-05T10-40.md)
  • Data source or fixture: QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329 (MTA caller of UiThread.Init(false))

Steps to Reproduce

  1. Bug: uithread-init-accepts-non-sta-callers #787: call UiThread.Init(false) from an MTA thread (the in-repo instance is the test at QfcHomeControllerRunAsyncTests.cs:329). It returns normally and every later UiThread.Dispatcher / UiSyncContext / UiThreadId read marshals onto a thread with no message loop.
  2. Bug: uithread-init-latch-not-rearmed-after-failed-initialize #788: arrange for Initialize() to throw (headless or non-STA), call Init(), fix the condition, call Init() again. The second call is a no-op because _loaded.CheckAndSetFirstCall at UiThread.cs:36 was consumed before Initialize() ran.
  3. Bug: uithread-synccontext-awaiter-always-posts-for-dispatcher-built-viewers #784: construct an ItemViewer through ItemViewerQueue.Dequeue (inside UiThread.Dispatcher.Invoke, so UiSyncContext is a DispatcherSynchronizationContext), then on the UI thread evaluate viewer.UiSyncContext.GetAwaiter().IsCompleted. It is false, so the continuation posts instead of running inline.

Expected Behavior

  • Init() rejects a non-STA caller with a named InvalidOperationException before capturing anything.
  • A failed Initialize() leaves the latch re-armed so a later Init() retries, without reintroducing the regression Refactor: pr-778-post-merge-review-residuals #782 measured.
  • IsCompleted is true when the caller already runs on the owning UI thread, regardless of which SynchronizationContext instance is ambient.

Actual Behavior

See the three reproduction steps. #787 succeeds silently and poisons the globals for the process lifetime; #788 leaves UiThread.Dispatcher throwing an exception that names Init() as the remedy while Init() is a no-op; #784 adds one queued hop per await and changes ordering relative to already-queued UI work.

Logs / Screenshots

Impact / Severity

  • Blocker
  • High
  • Medium
  • Low

Medium, carried from #787: in production ThisAddIn.cs:35-40 is the only Init() caller and runs on the Outlook STA, so the hazards are reachable today only from test code, but a worker-thread read of the lazy accessors before startup completes would poison the process. #784 and #788 are Low individually.

Source

From: docs/features/potential/2026-09-07-uithread-init-contract-residuals-784-787-788.md

Activity

  1. drmoisan commented on Sep 8, 2026

    @drmoisan
    OwnerAuthor

    Correction to the #784 part of this consolidation, recorded during preparation on 2026-09-07.

    The promoted record originally stated that the correct SynchronizationContextAwaiter.IsCompleted predicate is bare owning-thread identity (UiThread.UiThreadId == Thread.CurrentThread.ManagedThreadId) and attributed that rule to #781. Both claims are wrong.

    • QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:263-272 records the opposite rule: when a context was captured, the context is the authoritative boundary and bare owner-thread identity must never substitute for it, because a continuation resumed after ConfigureAwait(false) can land on a recycled thread-pool thread whose managed id equals the owner's.
    • A bare-id predicate breaks QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183-199, which awaits a foreign WindowsFormsSynchronizationContext from the MSTest thread and asserts the continuation lands on the pump thread.

    The correct predicate keeps reference equality as the fast path and admits only contexts that are demonstrably UI-owned while the caller stands on the owning UI thread. The active-folder issue.md and research/research.2026-09-07T20-20.md (section R6) on branch bug/uithread-init-contract-residuals-784-787-788-809 carry the corrected note; the promoted record in docs/features/potential/promoted/ is corrected in the same commit that posts this comment.

    Also noted during preparation: the #782 latch-regression narrative that constrains the #788 fix may not be reproducible. No in-tree path was found where Initialize() throws, and the test named in that narrative is the documented #780 flake. The plan measures the regression instead of assuming it.

  2. added a commit that references this issue on Sep 8, 2026
  3. drmoisan commented on Sep 8, 2026

    @drmoisan
    OwnerAuthor

    Delivered by pull request #814, merged into main as f63a2c4. The pull-request body carried no auto-close reference, so this issue is closed manually by the parallel run bugs-2026-09-06. Five of six acceptance criteria are met; AC5 remains unchecked because its MTA measurement clause was never established, and the issue #782 reproduction status is unknown rather than negative. Feature review returned zero blocking findings.

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