Skip to content

fix(809): reject non-STA UiThread.Init callers, allow init retry, and relax the awaiter predicate - #814

Merged
drmoisan merged 20 commits into
mainfrom
bug/uithread-init-contract-residuals-784-787-788-809
Sep 8, 2026
Merged

fix(809): reject non-STA UiThread.Init callers, allow init retry, and relax the awaiter predicate#814
drmoisan merged 20 commits into
mainfrom
bug/uithread-init-contract-residuals-784-787-788-809

Conversation

@drmoisan

@drmoisan drmoisan commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Fix three UiThread init-contract defects: non-STA acceptance, unretryable latch, and over-strict awaiter

Summary

  • UiThread.Init() now rejects any caller whose apartment is not STA, throwing a named InvalidOperationException as the first statement of the method, ahead of the four process-global monitoring assignments and ahead of the initialization latch (Bug: uithread-init-accepts-non-sta-callers #787).
  • The single-shot latch is replaced, inside UiThread only, by a success-recorded flag guarded by a serializing lock. The flag is set after Initialize() returns rather than before it runs, so a failed first attempt no longer permanently consumes the latch and a later call retries (Bug: uithread-init-latch-not-rearmed-after-failed-initialize #788).
  • SynchronizationContextAwaiter.IsCompleted no longer forces a post for a context captured inside a WPF dispatcher operation. It keeps reference equality as its fast path and additionally admits exactly two demonstrably UI-owned cases while the caller stands on the owning UI thread (Bug: uithread-synccontext-awaiter-always-posts-for-dispatcher-built-viewers #784).
  • A narrow IUiCaptureSource seam plus a UiThreadStateScope snapshot/restore helper make initialization drivable, and failable, in tests without a live Outlook host.
  • 17 new tests; full suite 7137 passed / 0 failed. UiThread.cs line coverage rises from 76.83% to 96.03%.
  • One acceptance criterion (AC5) is not met and is left unchecked. See "Known gap" below.

Why

Three findings on a single file were filed separately after the #781 and #782 reviews and are shipped together because they touch the same initialization and awaiter code and share one test suite.

The ordering between them is load-bearing rather than cosmetic. The safety argument for relaxing the latch depends on the apartment precondition already being present and being the first statement of Init(): with the precondition in place, the expensive and potentially-throwing body of Initialize() is unreachable from any non-STA caller, so no retry storm can originate on a thread-pool thread. The two changes must land together.

What changed

Core logic (UtilitiesCS/)

  • Threading/UiThread.cs — apartment precondition and its message constant; ThreadSafeSingleShotGuard usage replaced by lock (InitLock) plus a _initialized flag set after success; IsCompleted rewritten; SyncContextFormFactory and ResetForTesting() test seams added. The field is now typed IUiCaptureSource?.
  • Threading/IUiCaptureSource.cs (new) — the capture-object contract Initialize() reads UI values from.
  • Threading/SyncContextForm.cs — implements the new interface.
  • UtilitiesCS.csproj — one compile item for the new file.

Tests

  • UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs (new) — apartment rejection, latch re-arm after throw, and the anti-retry-storm case.
  • UtilitiesCS.Test/TestHelpers/UiThreadStateScope.cs (new) — snapshot/restore of every process-global static UiThread owns.
  • UtilitiesCS.Test/Threading/UiThread_Tests.cs — awaiter inline-versus-post cases, including the guards that pin the false paths.
  • QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs — the MTA UiThread.Init(false) caller is removed and replaced with a pumping dispatcher scoped to the test, which makes the test order-independent rather than merely making it pass.
  • Three test files gain a single [DoNotParallelize] attribute line each, because they mutate UiThread statics.
  • UtilitiesCS.Test.csproj — two compile items.

Docs and evidence — spec, plan, research, 44 evidence artifacts, and the three review artifacts.

Architecture / how it fits together

Init() is the single entry point that captures UI state into process-global statics. Its four reachable callers are the two direct Init() calls and the two lazy accessors that route through it; UiThread.Dispatcher remains deliberately non-lazy. Placing the apartment check as the first statement means every one of those paths fails fast on a non-STA thread at one enum read and a throw, before any global is written.

IsCompleted is a field-free predicate, deliberately so. GetAwaiter() runs at the await site on the awaiting thread rather than on the thread that captured the context, so a construction-time capture of the owning thread id would be unsound. default(SynchronizationContextAwaiter) behaviour is therefore unchanged.

Thread identity is used as a necessary guard and never as a sufficient one: each new true-branch additionally requires a reference match against an object captured at Init() time. This preserves the rule recorded at QuickFiler/Viewers/BreadcrumbUiDispatcher.cs:263-272, which forbids substituting bare owning-thread identity for a captured-context reference match, because a continuation resumed after ConfigureAwait(false) can be scheduled onto a recycled thread-pool thread whose managed thread id equals the owner's.

A null ambient context continues to return false, which keeps the two TaskScheduler.FromCurrentSynchronizationContext() call sites working.

Verification

Completed (all recorded as evidence artifacts under the feature folder)

Gate Result
dotnet tool run csharpier check . exit 0, Checked 1611 files (baseline 1608 + 3 new)
msbuild /t:Rebuild with analyzers exit 0, 0 Warning(s), 0 Error(s), 18 projects
msbuild /t:Rebuild with TreatWarningsAsErrors exit 0, 0 Warning(s), 0 Error(s)
Full test suite 7137 total, 7137 passed, 0 failed, 0 skipped (baseline 7120 + 17)
UiThread.cs line coverage 76.83% -> 96.03%
Changed-line coverage 95.83% (46/48 executable added lines)
First-party line coverage 84.58% -> 84.62%

Fail-before / pass-after evidence exists for every regression test. Six tests failed against the pre-fix tree with assertion messages mapping to the specific defects, and pass after.

Feature review returned 0 blocking findings across policy audit, code review and feature audit.

Recommended — manual verification on a live Outlook host (QuickFiler launch, item load, breadcrumb open), confirming no change in observable UI behaviour and no keyboard-focus regression. The automated suite cannot close this residual: the awaiter change alters execution ordering at eleven production await sites and no existing test asserts ordering at any of them.

Known gap

AC5 is unchecked and this PR does not claim it. It required a recorded measurement of whether new SyncContextForm(); Show(); throws on an MTA thread. The probe that was run never read Thread.CurrentThread.GetApartmentState(); it inferred the apartment from a research premise that this same delivery then falsified by direct measurement. Two facts verified against the tree — UtilitiesCS.Test/Properties/AssemblyInfo.cs:18 carries the repository's only assembly-level Parallelize attribute, and no runsettings file sets ExecutionThreadApartmentState — indicate the probe most likely ran STA, in which case no MTA measurement was taken.

The claim has been withdrawn in the evidence artifacts rather than left standing, and the status of the #782 mechanism narrative reverts to UNKNOWN.

This does not affect the delivered code. The design argument was written from the outset to stand independently of the measured value, and the review verified it structurally against the head tree: the apartment precondition makes the potentially-throwing path unreachable from any non-STA caller whatever the answer turns out to be.

Backward compatibility

UiThread.Init() gains a precondition, so a non-STA caller that previously succeeded now throws. Exactly one such caller existed in the repository, in test code, and it is corrected here. The production caller runs on the Outlook main STA thread and is unaffected. No public API is removed or renamed, ThreadSafeSingleShotGuard is retained as a type, no InternalsVisibleTo grant is added, and the reflected field names _uiSyncContext and _dispatcher are preserved for four existing test helpers.

Risks and mitigations

  • Ordering change at eleven production await sites, none covered by an ordering assertion. Mitigated by the guards that pin the predicate's false paths and by the two WinFormsPumpHostTests marshal tests; the live-host check above is the residual.
  • Process-global static state shared across a test assembly. Mitigated by ResetForTesting(), UiThreadStateScope, and [DoNotParallelize] on every class that mutates a UiThread static.
  • Two uncovered lines at UiThread.cs:177-178, the body of one predicate branch. The condition is covered and asserted false; only the return true; arm is unreached. The IsCompleted member sits at exactly 90.00%.
  • Rollback is a revert of the changed files. No data migration, feature flag, or configuration change to undo.

Review guide

  1. UtilitiesCS/Threading/UiThread.cs — the whole change is here; read IsCompleted first, then Init().
  2. UtilitiesCS/Threading/IUiCaptureSource.cs and SyncContextForm.cs — the seam.
  3. UtilitiesCS.Test/Threading/UiThreadInitContract_Tests.cs and the additions to UiThread_Tests.cs.
  4. QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs — the largest test diff, mostly the dispatcher fixture.
  5. The three single-line [DoNotParallelize] additions are mechanical.

The 51 documentation and evidence files are generated audit trail and can be read last. Note that the PR-context tool classified this change as documentation-only with zero core-logic files; that classification is wrong, and the twelve source paths above are the real change.

Follow-ups

GitHub auto-close

  • None

GitHub validation was unavailable when the PR context was collected, so no Closes bullet is emitted. The author-asserted list produced by the tool harvested every issue number mentioned anywhere in the feature documents, including several this change does not resolve, so it is not a safe source. #809 should be closed manually on merge.

drmoisan and others added 20 commits September 7, 2026 20:17
…esiduals

Seeds issue.md (work mode full-bug), spec.md template, and the canonical
plan file for issue #809, which consolidates #784, #787 and #788.
Research establishes the AC1/AC2 interaction, a 56-site live-read census, the
testability seams, and the awaiter predicate. It also shows the issue's own
note misattributed a bare thread-id predicate to #781; BreadcrumbUiDispatcher
records the opposite rule and two WinFormsPumpHost tests would fail under it.
Fills the seeded spec template from the research: corrected root cause for
#784, the three concrete changes, the AC2 anti-regression argument, and the
test strategy. Acceptance criteria are AC1-AC4 verbatim from issue.md plus
AC5 (the required MTA construction measurement) and AC6 (coverage uplift).
Evidence paths normalized to the canonical evidence/qa-gates/ location.
Seven phases, 71 tasks, all unchecked. Phase 0 carries the fresh-worktree
bootstrap (repo SDK, NuGet restore, tool restore, analyzer parity) plus the
AC5 MTA construction measurement. Toolchain command strings and success-case
output shapes are taken from recorded #782 runs rather than composed.
Passes the MCP plan validator with no warnings.
… test files

Preflight round 1 established three unprotected parallel-bucket writers of the
UiThread process-global statics. Marking only the classes this delivery adds
does not serialize them, because the serial and parallel buckets overlap under
ClassLevel parallelization. Each of the three gains one attribute line.
All eighteen defects applied. Phase 6 grows to 16 tasks, total 72. Adds three
standing conventions (skipped-count derivation, TRX selection under a re-run,
results-directory creation) and re-anchors the issue.md diff to HEAD.

Also corrects the runsettings citation in the spec Write Set amendment: the
MSTest parallelization uses the element form, not an attribute form.
Five defects and two observations applied. Replaces the unsatisfiable
repository-wide results-file gate with a base-anchored delivery-added count,
closes a producer/consumer gap on the ThreadMonitorField accessor, and
corrects three cross-references. Task count unchanged at 72 across 7 phases.
Separates the pre-existing 500-line overrun in FolderPredictorTests.cs from
delivery-introduced overruns, so the file-size gate measures what this change
causes rather than what it inherits. Corrects the P3-T3 removal attribution
and replaces two unfounded uncovered-line predictions with a deferral to the
recorded baseline artifact. Task count unchanged at 72 across 7 phases.
Pins the physical-line counting idiom and rejects Measure-Object -Line by
name, which counted non-blank lines and could report zero overruns on a file
sixty lines over the 500-line cap. Adds base-inertness verification, stops
appended Phase 5 pass sections duplicating schema fields, and completes the
residual-throw enumeration.

The orchestrator added the porcelain companion span to P0-T3 to clear the
G8b validator warning its own base-inertness request introduced.
Records the pre-809-base anchor, SDK and package bootstrap, analyzer parity, baseline csharpier/analyzer/nullable/vstest/coverage gates, per-file UiThread.cs coverage, the decision-D5 MTA measurement and the five design preconditions.
…et scope

Introduces IUiCaptureSource, UiThread.SyncContextFormFactory, UiThread.ResetForTesting, the NonStaInitMessagePrefix constant and UiThreadStateScope. No observable behaviour of Init, Initialize or IsCompleted changes in this phase.
Seventeen new MSTest cases covering the apartment precondition, retry after a failed Initialize, and the awaiter predicate. Six fail against current behaviour, which is the recorded fail-before evidence. Four classes that write UiThread statics gain DoNotParallelize.
…d awaiter

AC1 adds an apartment precondition as the first statement of Init(). AC2 replaces the single-shot latch with a success-recorded flag under a serializing lock. AC3 replaces the reference-equality IsCompleted predicate. Two regression tests are re-authored to drive a dedicated MTA thread because the shared serial worker was measured to be STA.
…atcher

QfcHomeControllerRunAsyncTests no longer calls UiThread.Init(false). It installs a WinFormsPumpHost dispatcher through the existing UiThreadDispatcherFixture transaction, which removes the order-dependency rather than only satisfying the new apartment precondition.
Format, format-check, analyzer build, nullable build and the full nine-assembly coverage run all pass in a single clean loop. 7137 of 7137 tests pass, the baseline 7120 plus exactly seventeen added tests.
…and closure

UiThread.cs per-file line coverage rises from 76.83 to 96.03 percent. Changed-line coverage is 95.83 percent and every new member with executable lines is at or above 90 percent. Aggregate first-party coverage is comparable and rises on both line and branch. All six acceptance criteria are checked off in spec.md, with AC1 through AC4 mirrored into issue.md.
Records completion of every task through P6-T15. The P6-T16 mark is written after this commit, because no task can commit the mark that records its own completion.
…partment claim

Feature review returned 0 blocking findings across policy-audit, code-review and feature-audit. Two acceptance criteria were graded PARTIAL.

AC5 is unchecked in spec.md. Its measurement clause is not established: the [P0-T15] probe never read the executing thread's apartment state, and this same delivery falsified the research premise the MTA label rested on. A correction section is added to the two evidence artifacts that drew conclusions from that label, recording that the status of the #782 mechanism narrative reverts to UNKNOWN. The delivered code and tests are unaffected, because the AC2 design argument was written to stand independently of the measured value and the review verified it structurally against the head tree.

AC6 remains checked. Every measurable clause is met and was recomputed by the reviewer from the raw Cobertura documents. The two unmet clauses are literal wording: the collector route and the storage location of a gitignored 18 MB report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 6 of the closure summary attributed the observed STA execution to a plain [TestClass] [DoNotParallelize] class sharing the serial bucket with an [STATestClass] [DoNotParallelize] class. That stated cause is not established. Two facts verified directly against the tree support a simpler explanation: UtilitiesCS.Test/Properties/AssemblyInfo.cs:18 carries the only assembly-level Parallelize attribute in the repository, and no runsettings file sets ExecutionThreadApartmentState. An assembly invoked without a settings file therefore does not parallelize, and its tests inherit the vstest main execution thread apartment, which is STA on .NET Framework.

The operational rule the section exists to convey is unchanged and is confirmed: a test that needs a caller of a known apartment must create a dedicated thread and set the apartment explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@drmoisan
drmoisan merged commit f63a2c4 into main Sep 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: uithread-synccontext-awaiter-always-posts-for-dispatcher-built-viewers

1 participant