Repository navigation
Bug: uithread-init-contract-residuals-784-787-788 #809
Description
- 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;
mainat04a54e68 - 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 ofUiThread.Init(false))
Steps to Reproduce
- Bug: uithread-init-accepts-non-sta-callers #787: call
UiThread.Init(false)from an MTA thread (the in-repo instance is the test atQfcHomeControllerRunAsyncTests.cs:329). It returns normally and every laterUiThread.Dispatcher/UiSyncContext/UiThreadIdread marshals onto a thread with no message loop. - Bug: uithread-init-latch-not-rearmed-after-failed-initialize #788: arrange for
Initialize()to throw (headless or non-STA), callInit(), fix the condition, callInit()again. The second call is a no-op because_loaded.CheckAndSetFirstCallatUiThread.cs:36was consumed beforeInitialize()ran. - Bug: uithread-synccontext-awaiter-always-posts-for-dispatcher-built-viewers #784: construct an
ItemViewerthroughItemViewerQueue.Dequeue(insideUiThread.Dispatcher.Invoke, soUiSyncContextis aDispatcherSynchronizationContext), then on the UI thread evaluateviewer.UiSyncContext.GetAwaiter().IsCompleted. It isfalse, so the continuation posts instead of running inline.
Expected Behavior
Init()rejects a non-STA caller with a namedInvalidOperationExceptionbefore capturing anything.- A failed
Initialize()leaves the latch re-armed so a laterInit()retries, without reintroducing the regression Refactor: pr-778-post-merge-review-residuals #782 measured. IsCompletedis true when the caller already runs on the owning UI thread, regardless of whichSynchronizationContextinstance 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
- Attached minimal logs or screenshot
- Snippet:
UtilitiesCS/Threading/UiThread.csline 100 (verified 2026-09-05):public bool IsCompleted => _context == SynchronizationContext.Current;(reference comparison). Probe result:Invoke ctx == outer ambient : Falseon .NET Framework 4.8 STA. Bug: uithread-init-accepts-non-sta-callers #787 and Bug: uithread-init-latch-not-rearmed-after-failed-initialize #788 are missing-precondition and ordering defects with no diagnostic output.
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
docs(potential): consolidate ten residual bugs into four issues #809-#…
drmoisan commentedon Sep 8, 2026
Correction to the #784 part of this consolidation, recorded during preparation on 2026-09-07.
The promoted record originally stated that the correct
SynchronizationContextAwaiter.IsCompletedpredicate 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-272records 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 afterConfigureAwait(false)can land on a recycled thread-pool thread whose managed id equals the owner's.QuickFiler.Test/TestSupport/WinFormsPumpHostTests.cs:183-199, which awaits a foreignWindowsFormsSynchronizationContextfrom 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.mdandresearch/research.2026-09-07T20-20.md(section R6) on branchbug/uithread-init-contract-residuals-784-787-788-809carry the corrected note; the promoted record indocs/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.docs(809): correct the #784 awaiter predicate note in the promoted re…
drmoisan commentedon Sep 8, 2026
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.