Repository navigation
Bug: uithread-init-contract-residuals-784-787-788 #809
Description
Activity
- added a commit that references this issue
on 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.- A bare-id predicate breaks
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.- added a commit that references this issue
on 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.
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 beforeInitialize()runs, so a failed first attempt can never be retried, and the naive re-arm was measured to regress in #782 (#788). (3)SynchronizationContextAwaiter.IsCompletedcompares 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
mainat04a54e68vstest.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)QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs:329(MTA caller ofUiThread.Init(false))Steps to Reproduce
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.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.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.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.Dispatcherthrowing an exception that namesInit()as the remedy whileInit()is a no-op; #784 adds one queued hop per await and changes ordering relative to already-queued UI work.Logs / Screenshots
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
Medium, carried from #787: in production
ThisAddIn.cs:35-40is the onlyInit()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