Repository navigation
fix(UtilitiesCS): guard UiThread dispatcher null-race in ProgressTrackerAsync - #778
Merged
drmoisan merged 11 commits intoSep 4, 2026
Merged
Conversation
… fix Preparation-mode item for parallel run bugs-2026-09-02. Scopes and plans a targeted fix for issue #584 (UiThread.Dispatcher null race causing a non-deterministic NullReferenceException in ProgressTrackerAsync.InitializeAsync). - issue.md authored by hand from the GitHub issue (no promotable potential document existed on disk; the source document could not be committed from its originating epic-child worktree). - spec.md with confirmed root-cause analysis, fix design (throw InvalidOperationException from UiThread.Dispatcher instead of returning null; remove the null! suppression), and AC1-AC7. - research/defect-scoping.md documenting verification against origin/main and the existing WpfDispatcherYield.cs precedent for the fix shape. - plan.2026-09-02T09-02.md: an 8-round-revised atomic plan covering UiThread.cs plus four UtilitiesCS.Test files, cleared by atomic-executor preflight with PREFLIGHT: ALL CLEAR after fixing vacuous git-diff gates, MSYS path-conversion issues in msbuild/vstest invocations, a missing NuGet restore step, and a TRX-derived test-count sourcing defect. Atomic execution, PR authoring, and CI monitoring are out of scope for this preparation run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTjXvNFHVh7Fo7kYGgWsx2
… paths Preparation-mode revision round 9 on issue #584. The downstream parallel scheduler's blast-radius harvester reads any backtick-delimited, whitespace-free, repository-relative path token as "this item writes this path," with no notion of polarity or context. spec.md and plan.md previously quoted scope-EXCLUSION paths (files this item does not write, does not touch, or verified need no change) inside backticks, causing false contention with the .claude/**, .codex/**, .agents/**, and config-file shared surfaces across a 13-item parallel run. - Added a "## Write Set" section to spec.md listing, as backticked paths, exactly the five files this plan's diff creates or modifies. - Converted every backticked mention of an exclusion, a model/precedent reference, a survey site, a context reference, or a "verified needs no change" file (most notably UtilitiesCS/Threading/ProgressTrackerAsync.cs) to plain, un-backticked prose in both spec.md and plan.md, limited to non-task, non-acceptance-clause, non-command, non-evidence-path prose. - Fixed one consistency defect this created: plan.md's P5-T3 quoted the old backticked AC3 bullet text verbatim to locate it for check-off; updated the quotation to match spec.md's new unbackticked wording. - Applied two corrections returned by a full atomic-executor preflight pass (revision round 9): a missed backtick-removal instance at spec.md line 168, and a required self-review re-derivation subsection in plan.md (citations, sibling-region recheck, and a stale citation-18 line-range fix caused by the Write Set insertion shifting the AC1-AC7 block). - No task, acceptance-criterion substance, command, or evidence path was changed. The round-6/round-8 finding about vstest.console.exe's TRX logger hard-coding notExecuted to 0 (and the total-minus-executed derivation substituted for it) is unchanged. Re-validated via the MCP plan validator (ok:true) and cleared a fresh atomic-executor preflight pass: PREFLIGHT: ALL CLEAR, CONVERGENCE: NO FURTHER ROUNDS EXPECTED. Atomic execution, PR authoring, and CI monitoring remain out of scope for this preparation run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 9 (commit 12a1103) fixed the explicitly-declared .claude/**, .codex/**, .agents/**, and config/*.json exclusion bullets but left dozens of other backtick-wrapped non-write-set path mentions in plan.md untouched: every CLAUDE.md and .claude/rules/*.md policy citation, .gitignore/.csharpierignore/global.json/dotnet-tools.json references, .github/workflows/*.yml CI-parity citations, and several production/test survey and precedent files (ProgressTrackerAsync.cs, SyncContextForm.cs, WpfUiDispatcher.cs, WpfDispatcherYield.cs, CurrentStoreContextTests.cs, WpfDispatcherYieldTests.cs, OutlookFolderTreeServiceConcurrencyTests.cs, QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs). Any one of these remaining backtick-wrapped .claude/ paths still causes the harvester's false-write belief the revision was meant to eliminate. Converted every remaining non-write-set backtick-wrapped path mention to plain text, verified by exhaustive grep sweep (zero remaining shared-surface hits) and by diffing the backtick-stripped file against its pre-image (word-for-word identical apart from the P5-T9/T10 self-review paragraph, corrected to accurately describe this fix instead of the stale "left backtick-wrapped, no change needed" narrative it no longer matches). The Write Set section, the five write-set file citations, the TRX notExecuted=0 finding, and all task/AC/command/evidence-path text are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTjXvNFHVh7Fo7kYGgWsx2
Two atomic-executor preflight rounds on the round-9/round-10 backtick-removal revision (commits 12a1103, b79fc8e) found two narrow, non-blocking documentation/provenance defects in plan.2026-09-02T09-02.md, both now fixed: - The Status/Version header line did not acknowledge the round-10 mechanical backtick-removal correction pass. Appended a round-10 note to the Status line and bumped Version from 1.8 to 1.8 (already applied) with the round-10 description. - The "Sibling regions re-checked in the revision round 9 pass" section incorrectly claimed the P5-T9/P5-T10 exclusion-assertion backtick fix happened "in this round" (round 9). git show 12a1103 proves those tasks were still backtick-wrapped at that commit, so the claim was moved into its own correctly-labelled "Sibling regions re-checked in the revision round 10 pass" section, and a residual backtick-wrapped historical quotation inside that same new section was also converted to plain text. A third, confirming preflight round returned PREFLIGHT: ALL CLEAR, CONVERGENCE: NO FURTHER ROUNDS EXPECTED. The MCP plan validator was re-run after every fix (ok: true each time). No task, acceptance criterion, command, exit code, threshold, or evidence path was changed at any point; this is a text-presentation-only fix on top of the already-pushed backtick-removal revision. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LTjXvNFHVh7Fo7kYGgWsx2
…kerasync-584 for reconciliation
…584) The UiThread.Dispatcher getter now throws a named InvalidOperationException when its backing field has not been captured, instead of returning null and leaving the consumer to fail later with an unattributed NullReferenceException. The null-forgiving suppression is removed and the backing field is redeclared as a nullable Dispatcher, so the nullable analyser verifies the guard rather than being suppressed around it. Two deterministic regression tests cover the guarded and the populated paths. DoNotParallelize is added to the three UtilitiesCS.Test classes that write the process-global static, and the reflective setup and teardown snapshot in EmailMoveMonitorTests is retargeted from the public Dispatcher property to the private backing field so it observes the same state without invoking the new guard. No assertion, test method, or mock setup is altered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzGiZSnVySFZcoC1BHN5Vv
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzGiZSnVySFZcoC1BHN5Vv
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzGiZSnVySFZcoC1BHN5Vv
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzGiZSnVySFZcoC1BHN5Vv
Feature review of the UiThread.Dispatcher null-guard change (#584). Zero blocking findings: policy audit COMPLIANT with documented exceptions, code review APPROVE, feature audit ACCEPT with 7 of 7 acceptance criteria PASS. The non-blocking findings are recorded in the artifacts and are carried into the pull-request body as named deferred follow-ups rather than promoted on this branch, because this plan's footprint acceptance criterion asserts the branch diff lists only the six owned source paths plus this feature folder, and a potential-entry document would falsify evidence already committed at P5-T10. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TzGiZSnVySFZcoC1BHN5Vv
This was referenced Sep 4, 2026
drmoisan
added a commit
that referenced
this pull request
Sep 6, 2026
…install scope Phase 3 of issue #782. Replaces five hand-rolled reflective acquisitions of the private static UiThread dispatcher backing field with a single shared install scope, and corrects the documentation that described the pre-#778 mechanism. - C12/C13: UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs now owns the only reflection site in that assembly. IdleAsyncQueue_Tests reimplements its ForceDispatcherNull/RestoreDispatcher helpers over the scope and drops its own DispatcherField() helper. - C10: the populated-branch UiThread_Tests case obtains its sentinel dispatcher from a dedicated STA host that shuts down and joins, instead of calling Dispatcher.CurrentDispatcher on a pooled MTA worker. - C11: the null-branch assertion lambda is expression-bodied. - C18/C25: EmailMoveMonitorTests reads the existing QuickFiler.Test fixture accessor and takes a typed Dispatcher snapshot; the two stale "avoid WindowsBase" clauses are removed. - C19: the three P27-T2 passages now describe the synchronous InvalidOperationException path rather than a NullReferenceException. Repository-wide reflective acquisitions of the backing field fall from six to two, the two being the UtilitiesCS.Test scope and the QuickFiler.Test fixture that a separate assembly must keep. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ucgeqsVLVSVbmJfkDzcBs
drmoisan
added a commit
that referenced
this pull request
Sep 6, 2026
Phase 5 of issue #782. All edits are made in place; no #584 evidence file is renamed and no existing Timestamp: value is altered. - S3-6: #584 spec Status moves from Draft to Merged (PR #778, merge commit 1c3b210, 2026-09-04), and the three disagreeing file lists are reconciled - the in-scope list is extended to the authoritative six-file Write Set and the "Files/modules to change" section now cross-references the Write Set instead of carrying a third enumeration. - S3-7/SD10: the three ~40/~62 call-site estimates are replaced with the verified figure, stated against the pre-782-base tag so it stays true after Phase 1 removes two live reads. - S3-1: four passages asserting an execution order the recorded timestamps do not establish are restated without the ordering claim; the conclusion never depended on it, because the sibling positive test passed in the same run. - S3-2: the two formatter command cells now record the six-path invocation that actually ran, Appendix B is labelled as a reference rather than a transcript, and a new section 8 gap entry records the deviation and its whole-tree read-only mitigation. - S3-3: the evidence-artifact count is corrected from 34 to 38. - S3-4: the issue-update mirror gains a note explaining why its filename timestamp and its Timestamp: field differ and why neither is changed. - S3-5/SD3: fifteen EXIT_CODE fields are normalized to a single integer with the per-command breakdown preserved below; the two gates whose success outcome is non-zero additionally declare ExpectedExitCode. - S3-8: seven evaluative spans prohibited by the tonality rule are replaced with evidence-first wording. - S3-9/SD9: #584 finding F5 is dispositioned in both artifacts as discharged by C12 and C13 rather than C26, with the record that it was never promoted. The P5-T14 gate artifact records one deviation for the caller's attention: the P5-T10 premise that all ten remaining per-command values were 0 does not hold for three files, whose zero-match search commands recorded 1. The instruction was followed literally and every original value is preserved verbatim below the normalized field. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ucgeqsVLVSVbmJfkDzcBs
drmoisan
added a commit
that referenced
this pull request
Sep 6, 2026
Phase 6 of issue #782. - evidence/other/code-review.2026-09-05T23-00.md carries a disposition row for every finding identifier - C01 through C26, S2-1, S3-1 through S3-9, S4-1 and S4-2 - naming the file that changed or the recorded reason none did, and the commit that carried it. It additionally records nine labelled entries: the C03 omission with its measured regression, bisect and mechanism; the SD5 message-tail change; the SD4 retained test-method naming inaccuracy; the SD10 file-count divergence from the PR #778 review body; the SD9 attribution of #584 finding F5; the two SD14 supersessions of spec Constraint 8 clauses; the SD7 justification for the added serialization attribute; and the SD17 coverage-collection deviation. - evidence/other/upstream-followups-drm-copilot.2026-09-05T23-02.md records the two items that belong to drm-copilot: the S4-1 stale agent-memory notes and the S3-1 request to define Timestamp: semantics. Both live under .claude/, which is overwritten by push-down, so neither is edited here. - evidence/qa-gates/p6-t3-dotclaude-untouched.md records the .claude/ gate. Its committed-history condition holds with zero lines. Its worktree condition does not hold: two paths under .claude/agent-memory/atomic-planner/ are dirty, written by the planner at 22:17 before this executor's first commit at 22:32:36. P6-T3 is left unchecked and the residue is reported rather than worked around. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ucgeqsVLVSVbmJfkDzcBs
drmoisan
added a commit
that referenced
this pull request
Sep 6, 2026
…ew-residuals-782 refactor(782): consolidate the PR #778 post-merge review residuals into one delivery
drmoisan
deleted the
bug/uithread-dispatcher-null-race-progresstrackerasync-584
branch
September 12, 2026 13:53
1 of 5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested title
fix(threading): guard UiThread.Dispatcher against a null dispatcher (#584)
Summary
UiThread.Dispatchernow throws a namedInvalidOperationExceptionwhen its backing field has not been captured, instead of silently returningnulland leaving a downstream consumer to fail later with an unattributedNullReferenceException.null!null-forgiving suppression is removed and the backing field is redeclared asDispatcher?, so the nullable analyser verifies the guard rather than being suppressed around it.[DoNotParallelize]is applied to the threeUtilitiesCS.Testclasses that reflectively write the process-global static, removing the class-level concurrency theirfinallyrestore cannot address.QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs, is retargeted from the public property to the private backing field so its setup and teardown observe the same state without invoking the new guard.Why
UtilitiesCS.Threading.UiThread.Dispatcherwas a static property backed by anull!-initialised field with no lazy initialisation and no guard.ProgressTrackerAsync.InitializeAsync()assigns the property's value at line 33 and dereferences it at line 35. When the static was read beforeUiThread.Initialize()completed, the property returnednulland the consumer threw aNullReferenceExceptionthat named neither the missing initialisation nor the responsible component.The failure was non-deterministic and order-dependent: it was observed once during a full-suite MSTest run under
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)], and did not reproduce in isolation or in two subsequent clean full-suite runs. Making the accessor fail fast converts an intermittent, unattributed crash into a self-diagnosing exception raised at the point of misuse. The fix mirrors theInvalidOperationExceptioncontract already established for the same hazard inUtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs.What Changed
Six source files, verified against the merge base with
git diff --name-status 87cb4df3..HEAD.Core logic (1 file)
UtilitiesCS/Threading/UiThread.cs— theDispatchergetter gains a null guard that throwsInvalidOperationExceptionnaming the requiredUiThread.Init()call;private static Dispatcher _dispatcher = null!;becomesprivate static Dispatcher? _dispatcher;. The property's public type remains non-nullableDispatcher, so callers keep receiving a guaranteed non-null value.Tests (5 files)
UtilitiesCS.Test/Threading/UiThread_Tests.cs— two regression tests plus aDispatcherField()reflection helper (+75 lines).UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs,ProgressTrackerAsync_Tests.cs,ProgressTracker_Tests.cs—[DoNotParallelize]added; attribute-only, one line each.QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs— the[TestInitialize]/[TestCleanup]reflective snapshot moves fromGetProperty("Dispatcher", Public|Static)toGetField("_dispatcher", NonPublic|Static). No assertion, test method, or mock setup is added, removed, or altered; the class keeps all 8[TestMethod]members.Docs and evidence
The feature folder
docs/features/active/uithread-dispatcher-null-race-progresstrackerasync-584/carries the issue, spec, research, atomic plan, three audit artifacts, and 30-plus evidence artifacts recording every gate.Architecture / How It Fits Together
UiThreadholds process-global static UI-thread state.UiThread.Init()runsInitialize(), which captures the WPFDispatcherinto_dispatcher. Consumers read the staticUiThread.Dispatcherproperty;ProgressTrackerAsync.InitializeAsync()is the consumer that exposed the defect.The change is confined to the accessor's contract. Control flow is unchanged on the initialised path: a captured dispatcher is returned exactly as before. Only the uninitialised path changes, from a silent
nullreturn to an immediate throw at the read site.Because the getter can now throw, any reflective read through the property surfaces the exception via
PropertyInfo.GetValue. A repository-wide census (plan taskP0-T14) enumerated every such route across all.csfiles: the qualified expressionUiThread.Dispatcher, the reflective property name"Dispatcher", and the reflective field name"_dispatcher". Exactly one reflective property consumer existed, and it is the sixth file changed here. No production file reads the dispatcher reflectively.Verification
Completed (recorded under the feature folder's
evidence/tree)dotnet tool run csharpier format .scoped to the six owned paths, thendotnet tool run csharpier check .—Checked 1576 files, exit 0.msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true— exit 0,0 Warning(s),0 Error(s).msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true— exit 0,0 Error(s).UtilitiesCS.Test: 4787 of 4787 passing, 0 failed, 0 skipped, against a 4785 baseline (+2 for the new regression tests).QuickFiler.Test: 1312 of 1312 passing, 0 failed, 0 skipped. All eightEmailMoveMonitorTestsmethods are named as passing; a recorded first pass had all eight failing before the sixth-file retarget, and that fail-before artifact is preserved.lines-validdelta of +42.Recommended
Backward Compatibility / Migration Notes
This is a behavioural change to a public API surface.
UiThread.Dispatcherpreviously returnednullwhen uninitialised and now throwsInvalidOperationException.The census described above establishes that no production consumer depends on the silent-null outcome; every production read either follows initialisation or is a documentation cross-reference. The one test consumer that did depend on it is updated in this change. No public type signature changes: the property's declared type remains non-nullable
Dispatcher.Callers that previously relied on a null return to detect uninitialised state must now call
UiThread.Init()first, which is the intended contract and is named in the exception message.Risks and Mitigations
.csfile, and the review additionally checked theusing static UtilitiesCS.UiThreadroute, which has zero hits. Rollback is a one-file revert with no data or migration considerations.dotnet-coveragefigures for the wholeUtilitiesCS.Testhost process, which is a different denominator from the first-party testable one the policy governs; they are not comparable to the policy percentage. This change moves the figure up and achieves 100% changed-line coverage.[DoNotParallelize]reduces test parallelism. It applies to four classes that write one process-global static. The measured cost is immaterial against a 4787-test assembly, and the alternative is retaining a known order-dependent flake.Review Guide
UtilitiesCS/Threading/UiThread.cs— the entire behavioural change is here; it is small and self-contained.UtilitiesCS.Test/Threading/UiThread_Tests.cs— confirm the regression tests are deterministic.QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs— the reflection retarget; confirm no assertion changed.[DoNotParallelize]additions are one line each and need little scrutiny.spec.mdandplan.2026-09-02T09-02.mdare large but are planning records, not shipped behaviour.Follow-ups
These are non-blocking findings from the feature review. They are deferred rather than promoted on this branch: the plan's footprint acceptance criterion asserts the branch diff lists only the six owned source paths plus the feature folder, and adding a potential-entry document would falsify evidence already committed. They should be filed as a consolidated issue after this PR is open.
UtilitiesCS.Test/Threading/ProgressTracker_Tests.csis 514 lines, over the 500-line limit. This is pre-existing at the merge base and the branch delta for that file is zero lines;[TestClass, DoNotParallelize]was used specifically to avoid adding one. A partial-class split is the natural remedy.ProgressTrackerAsync_Tests.csstill mutates the reflective static directly; the spec records syncing it to the shared helper idiom as a follow-up.UiThread_Tests.csomits afield.Should().NotBeNull()guard that its sibling test carries.EmailMoveMonitorTests.csusesDispatcherField?.GetValue(null); if the field were ever renamed, both sides would be null and the cleanup assertion would pass vacuously. This null-conditional pattern is retained from the pre-change code.UiThread.csretains a_uiSyncContext!suppression, an untouched instance of the same pattern this change removes for_dispatcher.GitHub Auto-close
This pull request addresses issue #584. The automatic auto-close bullet is withheld because the PR-context bundle reports
GitHub CLI unavailableand lists no verified closing issue, and the skill's reference rules forbid emitting a closing directive from unverified state. The bundle's author-asserted list additionally contained #449, #493 and #508, which appear in this branch's documents only as historical references — #449 as the run during which the defect was first observed — and must not be closed by this pull request.🤖 Generated with Claude Code
https://claude.ai/code/session_01TzGiZSnVySFZcoC1BHN5Vv
Post-merge code review (three-phase, 2026-09-05)
Review target: merge commit
1c3b210c, diffHEAD~1..HEADrestricted to the six.csfiles above, plus the 45 documentation and evidence files under the feature folder.Method. Phase 1 ran ten independent finder angles (line-by-line scan, removed-behavior audit, cross-file tracer, C# pitfall specialist, wrapper/proxy correctness, simplification, reuse, efficiency, altitude, and CLAUDE.md conventions). Phase 2 deduplicated the ten candidate lists into 26 distinct claims and ran one adversarial verifier per claim, each instructed to attempt refutation first and to anchor its verdict to quoted source. Phase 3 ran four gap sweeps (residual code pass, blast-radius enumeration of 96 references across all assemblies, documentation and evidence consistency, and build/nullable/test-configuration), producing 12 further candidates; the two rated should-fix were independently verified and three others were spot-checked directly.
Result. No blocking finding. No functional regression introduced by this PR was confirmed. The accessor change does what it set out to do, and the regression test fails before the fix and passes after it.
Verdict meanings: CONFIRMED = claim factually true and consequence real for this PR; PLAUSIBLE = claim true but consequence latent or uncertain; REFUTED = claim false, pre-existing and unaffected, or immaterial.
Should-fix (7)
C10 — CONFIRMED.
UtilitiesCS.Test/Threading/UiThread_Tests.cs:166.Dispatcher_WhenBackingFieldIsPopulated_ReturnsThatSameInstancecallsDispatcher.CurrentDispatcherinside a plain[TestMethod], on a pooled MTA MSTest worker thread, and never shuts the dispatcher down.UtilitiesCS.Test/test.runsettingsdocuments that STA is opt-in via[STATestMethod], and every otherCurrentDispatcheruse in the test projects runs under[STATestMethod],[STATestClass], or a dedicated STA thread withBeginInvokeShutdown. The leaked, never-pumped dispatcher stays affinitized to a reused thread; a later test on that thread that resolvesDispatcher.FromThread(Thread.CurrentThread)(production default inWpfDispatcherYield.cs:44; test helper inFilterOlFoldersControllerRefreshDisposalTests.cs:257-264awaited without timeout) would hang. Latent today because the class is[DoNotParallelize]and recorded runs were per-assembly with/InIsolation. Fix: obtain the sentinel on a dedicated STA thread (pattern atProgressTrackerAsync_Tests.cs:130or theStaDispatcherHostinWpfUiDispatcherTests.cs:161-207) and shut it down infinally. Keep the test rather than deleting it, so the populated branch stays covered in the regression file.C02 — PLAUSIBLE.
UtilitiesCS/Threading/UiThread.cs:138-146. The getter reads the non-volatile static_dispatchertwice: once for the null check and once for the return. A null write landing between the two loads returns null despite the guard. Null-writers remain in the repository:IdleAsyncQueue_Tests.ForceDispatcherNull,finallyrestores of a null prior in the tracker tests, and QuickFiler.Test's ungatedEnsureScope.Dispose(CompareExchangeto null) under class-level parallelization. Within UtilitiesCS.Test all writers are now serialized, so the window is latent, and the failure mode is identical to pre-PR behavior. Fix:Dispatcher? dispatcher = _dispatcher; if (dispatcher is null) throw ...; return dispatcher;(orVolatile.Read), matchingOutlookFolderTreeService.cs:336andWebView2BreadcrumbHost.cs:159.C18 — CONFIRMED.
QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:39-65.DispatcherField?.GetValue(null)with a null-conditional on astatic readonly FieldInfomeans a rename of_dispatchermakes both snapshots null, and FluentAssertions 8.10BeSameAson a null subject with null expected passes, so the order-independence guard the class exists to enforce degrades to a no-op. The PR increased exposure by retargeting from a public property (rename is a solution-wide compile break) to a private field name with no compile-time coupling. The same assembly'sQfcItemController.UiThreadDispatcherFixture.cs:38-48exposesinternal static Dispatcher Current, a lock-guarded read of the same field whoseResolveDispatcherFieldasserts the field exists. Fix: replace the localFieldInfoand both?.reads withUiThreadDispatcherFixture.Current, and remove the two "avoid WindowsBase" comment fragments at lines 29 and 53 (see C25).C19 — CONFIRMED.
UtilitiesCS.Test/Threading/IdleAsyncQueue_Tests.cs:236-240, 266-267, 272. The P27-T2 docstring, the Act comment, and theNotThrowreason string still say aNullReferenceExceptionfromInvokeAsyncon a null Dispatcher is caught "after the await". After this PR the exception isInvalidOperationException, thrown synchronously by the getter atIdleAsyncQueue.cs:72inside thetryand before the first await, and swallowed only because the catch at line 83 iscatch (Exception). The test still passes; the documented mechanism is wrong. The PR edited this file ([DoNotParallelize]) without updating the text. Fix: rewrite the three passages to describe the synchronousInvalidOperationExceptionpath.C20 — CONFIRMED.
UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs:57-66. The comment "UiThread.Dispatcher ... is null outside a live host, so that null state is surfaced as InvalidOperationException" is now false: the production fallback provider() => UtilitiesCS.UiThread.Dispatcher(line 46) throws itself. The localdispatcher is nullguard (lines 62-66) is unreachable on the production path and is retained only because the providers are typedFunc<Dispatcher?>under#nullable enable. The same precondition now emits two different messages depending on path; production always emits UiThread's message, never "...before yielding folder tree work." The plan (line 1294) acknowledged the divergence and accepted it. Fix: rewrite the comment to state that the fallback provider throws and that the guard covers injected providers; route both throws through one shared message constant; optionally add.WithMessage("*UiThread.Init()*")toYieldAsync_WithoutDispatcher_RemainsStrict.C16 — CONFIRMED, pre-existing.
UtilitiesCS.Test/Threading/ProgressTracker_Tests.csis 514 lines at bothHEAD~1andHEAD..claude/rules/general-code-change.md:49andCLAUDE.md:106set a 500-line limit with no pre-existing or baseline exemption; a grep of.claude/rulesand.claude/skillsfinds no "baseline + 1" file-size clause. That clause exists only inplan.2026-09-02T09-02.md:941. A plan-local acceptance clause cannot waive a CLAUDE.md rule under the Policy Compliance Order. The PR's own policy audit (line 109) discloses this as PARTIAL, so it is disclosed debt rather than a regression. Fix: split the file in a follow-up, and do not describe thep2-t3"baseline + 1" pass as rule compliance in review artifacts.S3-2 — CONFIRMED.
policy-audit.2026-09-04T04-05.md:123, 229, 421andfeature-audit.2026-09-04T04-05.md:149report thatdotnet tool run csharpier format .was executed.evidence/qa-gates/p4-t1-format.md:8records a six-path scoped invocation, prescribed by plan P4-T1 (lines 1068-1084) to avoid repo-wide drift rewrites. The executor followed the plan; the misstatement is in the audits' transcription, and the deviation from the CLAUDE.md approved-command list is absent from the audit's section 8 "Gaps and Exceptions". Substantively equivalent:p4-t2ran whole-treecheck .with exit 0 and an empty reported set. Fix: correct the command cells, amend row 3.1, and add a section 8 entry citing the plan rationale and thecheck .mitigation. Documentation only.Nits (25)
Code and tests:
UiThread.cs:36.Init()sets the one-shotThreadSafeSingleShotGuardbeforeInitialize()runs and has no catch or reset. IfInitialize()throws after the guard flips,_dispatcherstays null permanently and the new message's remedy ("Call UiThread.Init()") is a no-op. In production anInitialize()failure abortsThisAddIn_Startupbefore any consumer runs. Consider setting the latch after line 61 succeeds, and wording the message to not promise a retry re-runs initialization.UiThread.cs:137.UiSyncContextandAutoScaleFactorlazily callInit()on null;Dispatcherthrows. Read order now decides whether a caller self-heals or faults. The divergence predates the PR (Dispatcher was already the only non-lazy accessor) and is logged as CR-7 in the PR's code review, but no in-code comment explains why lazyInit()from an arbitrary reader is deliberately avoided here (Initialize()shows a hidden WinForms window and must run on the UI thread). Add a two-line comment above the throw.UiThread.cs:142andUiThread_Tests.cs:152. The message and the regression test both name the private methodUiThread.Initialize(). The plan mandated the exact string. The sibling message atWpfDispatcherYield.cs:65names only the publicInit(). Follow-up: shorten to "Call UiThread.Init() before reading UiThread.Dispatcher." and assert*UiThread.Init()*.UiThread.cs:135. The public static property gains a throwing precondition and carries no XML doc (CLAUDE.mdC#6.2 and C#3.3). No member ofUiThread.csis documented today, and the policy says "should", so this is a nit. Add<summary>,<remarks>noting the deliberate non-lazy contract, and<exception cref="InvalidOperationException">.UiThread.cs:142. The message omits thatInit()must run on the UI (STA) thread during startup. NeitherInit()norInitialize()checks apartment state;SyncContextForm.CaptureUiVariablescapturesDispatcher.CurrentDispatcheron whatever thread calls it, so a worker-threadInit()succeeds silently and installs a non-pumping dispatcher into set-once globals (QfcHomeControllerRunAsyncTests.cs:329already callsUiThread.Init(false)from a test thread). Append the thread requirement to the message; open a follow-up forInit()to reject non-STA callers.UiThread_Tests.cs:135-167. Test 1 assertsfield.Should().NotBeNull(); test 2 callsfield.GetValue(null)unguarded, so a renamed field fails test 2 with a bare NRE in Arrange. Test 1 uses a block-bodied lambda where 396 of 407 throw-assertion lambdas in UtilitiesCS.Test are expression-bodied. Move the null guard intoDispatcherField(); useAction act = () => _ = UiThread.Dispatcher;.UiThread_Tests.cs:125.DispatcherField()is the sixth reflection site forUiThread._dispatcher(alsoIdleAsyncQueue_Tests.cs:144,ProgressTracker_Tests.cs:421,ProgressTrackerAsync_Tests.cs:138,EmailMoveMonitorTests.cs:40,UiThreadDispatcherFixture.cs:135), each handling a missing field differently.UtilitiesCS/Properties/AssemblyInfo.cs:19grantsInternalsVisibleTo("UtilitiesCS.Test"), so an internal test seam onUiThreadcould replace reflection for the four UtilitiesCS.Test sites. Follow-up; a bugfix PR is the wrong vehicle.UiThread_Tests.cs:139-176. Both new tests hand-roll capture,SetValue,try/finallyrestore.IdleAsyncQueue_TestshasForceDispatcherNull/RestoreDispatcherbut they are private and cannot install a non-null value. Same remedy as C12: oneIDisposableinstall scope underUtilitiesCS.Test/TestHelpers/.UiThread_Tests.cs:167. While test 2 holds a never-pumped MTA dispatcher in the static, background work left alive by parallel-phase tests can read it. Concretely,IdleActionQueue_Testshas no cleanup and leaves no-op entries queued with a liveApplicationIdleTimerheartbeat subscription; a heartbeat in the microsecond window would enqueue aDispatcherOperationthat never runs. No test-visible effect. Optional hygiene: add aTestCleanuptoIdleActionQueue_Teststhat drains entries and unsubscribes.ProgressTracker_Tests.cs:14.[TestClass, DoNotParallelize]is the only comma-combined attribute list among 41DoNotParallelizeusages in the repository; chosen to avoid growing a 514-line file. Split when the file is next touched, paired with the C16 split.ProgressTracker_Tests.cs:14,ProgressTrackerAsync_Tests.cs:14,IdleAsyncQueue_Tests.cs:29. Exactly one method per class touchesUiThread._dispatcher(readers included), so class-level[DoNotParallelize]moves 32 non-touching tests into the serial bucket where method-level placement on the three writer methods would give the same guarantee. Defensible per plan rationale (grep-verifiable per-file invariant) and repo precedent (all 18 pre-existing usages are class-level); runtime cost is negligible.WpfDispatcherYieldTests.cs:118. No test constructsnew WpfDispatcherYield()and reaches the production fallback provider; the concurrency test marshals onto an STA host first. The PR's research (defect-scoping.md:147-162) scoped this out. Follow-up: one[DoNotParallelize]test that nulls_dispatcher, callsYieldAsyncfrom a thread with no dispatcher, and assertsInvalidOperationExceptionwith*UiThread.Init*.EmailMoveMonitorTests.cs:29, 53. The comment justifies reflection by "avoiding a compile-time WindowsBase dependency".QuickFiler.Test.csproj:460references WindowsBase directly and ten sibling files importSystem.Windows.Threading. The PR appended an accurate paragraph (lines 32-37) beneath the false premise without correcting it. Delete the two clauses.ProgressTrackerAsync_Tests.cs. No test drivesInitializeAsync()orProgressTracker.Initialize()with a null dispatcher; AC3's consumer-level conversion is verified by code reading only (p3-t4-progresstrackerasync-unmodified.md:58-71). The accessor-level test was a documented scoping decision and demonstrably fails before and passes after the fix. Optional: addInitializeAsync_WhenDispatcherNotCaptured_ThrowsInvalidOperationException.QuickFiler.Test/Controllers/QfcItemController.InitializationTests.Part2.cs:121-125. The Arrange comment says an unset static "cannot complete an InvokeAsync"; the unset case now throwsInvalidOperationExceptionsynchronously before anyInvokeAsync. Comment-only..claude/agent-memory/task-researcher/project_qfc_collection_defects_468.md:41-42andproject_filerqueue_consumer_unsound_633.md:3state thatUiThread.Dispatcheris "permanently null in tests" and "NREs". Stale after this PR; a future planning session loading these notes would reason from the wrong exception type. Update the notes (push-down surface owned by drm-copilot).evidence/qa-gates/p4-t5-utilitiescs-tests.md:7,p4-t6-quickfiler-tests.md. The local toolchain step 4 ran onlyUtilitiesCS.Test.dllandQuickFiler.Test.dll;TaskMaster.Test(host of the ribbon and startup consumers) and the other five test assemblies were not run locally. CI'smstest-coveragejob discovers every*.Test.dlland concluded success on the PR head, so no regression is indicated. Evidence-scope gap only.Documentation and evidence:
evidence/regression-testing/p1-t4-expect-fail.md:3,48(Timestamp 08-31, "P1-T3 recorded a clean build immediately before this run") vsp1-t3-build-before-fix.md:3(08-33);evidence/qa-gates/p3-t1-analyzer-build.md:3,30-31(08-38, "the first build that compiles ... the production fix") vsp3-t2-regression-green.md:3(08-34) andp3-t3-at-risk-tests.md:34(TRX mtime 08:35:42). Every artifact with a hard marker has a Timestamp matching it to the minute, so the most likely reading is that P3-T1 ran after P3-T2..T5 and an unrecorded build produced the assembly they executed against. The fail-before/pass-after proof does not depend on the ordering prose: P1-T4's recorded output ("no exception was thrown", 1 failed) and P3-T2's output stand on their own, and Phase 4 pass 2 independently confirms the final state. Neither the skill nor the plan defines what instantTimestamp:denotes. Fix: soften the ordering sentences in the two artifacts and infeature-audit.md:38/policy-audit.md:115; defineTimestamp:semantics inevidence-and-timestamp-conventions.policy-audit.2026-09-04T04-05.md:68states "All 34 evidence artifacts";git ls-treeshows 38 at the audit commit and at HEAD.evidence/issue-updates/issue-584.2026-09-02T09-02.md. Filename timestamp is the plan's timestamp; the artifact's ownTimestamp:is2026-09-03T22-24. A second update to Bug: uithread-dispatcher-null-race-progresstrackerasync #584 would collide or mis-order.evidence/baseline/p0-t6-mcp-probe.md:12(EXIT_CODE: non-zero (...)),p1-t5-donotparallelize.md:11-13,p3-t5-no-timing-tokens.md:12-16.EXIT_CODE:is not a single integer as the evidence schema requires.spec.md:7Status remains "Draft" with all seven ACs checked and the PR merged; "In scope" (lines 62-70) lists three files, "Files/modules to change" (160-163) lists two, the Write Set (92-99) lists six.spec.md:50, 172say "~40 other call sites";spec.md:73-74says "~62 remaining direct reads across ~29 files"; a grep at the research base yields about 49 live reads in 26 production files.feature-audit.md:117, 119,code-review.md:22, 191,policy-audit.md:111,p2-t3-file-size.md:42use evaluative wording ("honest and correct", "the right call", "Exemplary", "a model instance", "comfortably inside") that.claude/rules/tonality.mdclassifies as non-neutral.code-review.md:85andpolicy-audit.md:329-330recommend promoting the ProgressTrackerAsync_Tests synchronization follow-up to a GitHub issue before merge. The PR merged and the feature folder holds no record that this happened. Not verified against GitHub from the review environment.Refuted (6)
TaskMaster/Ribbon/RibbonViewer.EngineCommands.cs:72, 115. Thedispatcher != nullchecks are now dead code (true), but the claimed regression (loss of a degrade-to-direct-call fallback) does not hold: every production caller ofInvalidateEngineCommands/InvalidateEngineToggleruns afterApplication_Startup, which requiresThisAddIn_Startupto have already runUiThread.Init()synchronously (ThisAddIn.cs:35-42), and the IdleAsyncQueue refresh path dereferenced the accessor without a guard before this PR. Any hypothetical exception is caught byIdleAsyncQueue's catch,HandleToggleClickAsync's click boundary, orCompletePrime's continuation. Optional cleanup: remove the redundant null comparisons.UiThread.cs:36.CheckAndSetFirstCallis non-blocking, so a concurrent secondInit()caller returns beforeInitialize()completes (true), but the mechanism is pre-existing, untouched by this PR, and unreachable under the production startup ordering; the PR's getter neither widens nor narrows the window.UiThread.cs:137. Collapsing the getter toget => _dispatcher ?? throw ...is legal but the premise is false: the adjacentUiSyncContextandAutoScaleFactorgetters use the same block form, the.editorconfigpreference issilent, and CSharpier would wrap the long message literal anyway.UtilitiesCS/Threading/ProgressTrackerPane.cs:13, 16. The double read exists, but pre-PR a null static already failed at line 13 (NRE on.Invoke); the setter is private and set-once, so no production path can swap the static between reads; no test reaches the constructor. Exception type change only.ProgressTrackerAsync.cs:39,ProgressTracker.cs:39. The inner re-read inside theInvokeAsynclambda exists, but issue Bug: uithread-dispatcher-null-race-progresstrackerasync #584's recorded NRE was at line 35, from the outer read at line 33; the inner read was never reached. Production never mutates_dispatcherafterInitialize(), and every reflective writer is now serialized with the lambda drained byPushFramebefore any restore. Optional tidy-up: pass the capturedUiDispatcherinto the lambda.UtilitiesCS/Threading/WpfUiDispatcher.cs:25. Pre-PR the same members threw NRE at the same call sites beforeInit(); the PR changes only the exception type. TheStoreLockupResponderpath cannot execute beforeInit()becauseThreadMonitoris constructed insideInitialize()after the dispatcher is assigned.IUiDispatcher.cswas not touched.Verification notes
1c3b210c, the full touched files, callers and callees, the feature folder's issue, spec, research, plan, and evidence artifacts,.claude/rules,.claude/skills,CLAUDE.md,.editorconfig, the test runsettings andAssemblyInfoparallelization attributes, and the CI workflow definitions.msbuild,vstest.console.exe, ordotnet; all conclusions are from static reading of source, configuration, and recorded evidence. Toolchain results were taken from the committed evidence artifacts and the CI check runs on the PR head, all of which concluded success.