fix(utilities): make WpfDispatcherYield dispatcher lookup arrangeable (#508) - #521
Merged
Merged
Conversation
…#508) `WpfDispatcherYieldTests.YieldAsync_WithoutDispatcher_RemainsStrict` was order-dependent and failed intermittently under class-level parallelization. Both operands of the production `??` were ambient state the test never arranged: `Dispatcher.FromThread(Thread.CurrentThread)` is non-null on any pooled worker where an earlier test touched `Dispatcher.CurrentDispatcher`, and `UiThread.Dispatcher` is process-global set-once static state. Add an injectable-delegate seam so the dispatcher-free precondition is arranged rather than inherited. The resolution order stays inside `YieldAsync`, so the tests still verify the ordering rather than replacing it. The seam constructor is `internal` (reached via the existing `InternalsVisibleTo("UtilitiesCS.Test")`) and the `public` parameterless constructor is retained explicitly, so the public API surface is unchanged and no call site needs modification. Remove `[ExcludeFromCodeCoverage]`: the class is now genuinely unit-testable, and `.claude/rules/general-unit-test.md` does not permit a coverage exemption whose justification has been removed. Tests pin all three resolution branches (thread dispatcher present, thread dispatcher absent with fallback present, both absent) plus the pre-yield cancellation guard, using counting providers to assert resolution order and an owned pumping STA dispatcher thread that the test shuts down. Fail-before evidence: with the pre-change code, marshalling the unchanged call onto a pumping STA thread makes the assertion fail with "Expected a <System.InvalidOperationException> to be thrown, but no exception was thrown". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjbBgWNjMZLxBexMQMwLd
Adds the minor-audit feature folder for issue #508: the populated issue.md (sole acceptance-criteria source, AC1-AC9 all delivered), the 3-phase atomic plan at revision 1.2, and 40 evidence artifacts under evidence/. Notable evidence: - regression-testing/fail-before.*.md - genuine failing run before the fix, with a rebuild-timestamp proof that rules out a stale-assembly false pass. - regression-testing/preexisting-failure-attribution.*.md - controlled four-run experiment showing the two QuickFiler.Test pump-host failures are pre-existing at merge-base 003c571 and not caused by this change. - qa-gates/repeat-run-{1,2,3}.*.md - three full parallel UtilitiesCS.Test runs, 4667/4667 each, demonstrating the flake is gone. - qa-gates/coverage-changed-lines.*.md - aggregated changed-class coverage across the async state machine and lambda display classes. Coverage evidence is committed as compact package-level JaCoCo summaries rather than raw Cobertura, per the convention established by d0955dc for issue #503. The substitution was made before pushing, so the ~20 MB of raw reports never enters history; qa-gates/coverage-artifact-substitution.*.md records the projection and shows the derived counts reproduce the Cobertura root attributes exactly. Also records agent-memory entries for three traps hit during this work: tracked .claude/agent-memory breaking unscoped git gates, an agent worktree root making a "\.claude\" path exclusion unsatisfiable, and async state machines splitting coverage across Cobertura class elements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjbBgWNjMZLxBexMQMwLd
Adds the three reduced-audit artifacts produced by feature-review: - policy-audit.2026-08-08T17-45.md - all policy gates PASS, with an explicit coverage-scoped PASS verdict for CSharp (repo-wide 85.83% line against an 85% floor, 79.23% branch against a 75% floor). - code-review.2026-08-08T17-45.md - Approved. Confirms the seam addresses the root cause for BOTH ambient operands, preserves the public API and runtime behavior exactly, and adds no prohibited construct. - feature-audit.2026-08-08T17-45.md - AC1 through AC9 all PASS; all nine check-offs verified as earned and retained. Blocking findings: 0 (0 FAIL, 0 blocking PARTIAL). Six advisory items are recorded as follow-up candidates; none holds the merge. AC4 carries one merge-time obligation: its "justified in the PR body" clause is unsatisfiable until the PR exists, so it is discharged by the PR body rather than by the branch content. Also mirrors the issue-update posted to existing issue #511 documenting the pre-existing QuickFiler pump-host flake found while delivering this fix. A comment was added to #511 rather than opening a duplicate issue, because #511 already tracks that defect and explicitly asked for the failure capture this work produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjbBgWNjMZLxBexMQMwLd
- JaCoCo package-level summaries are valid coverage evidence (the hook parses JaCoCo counters and cannot read Cobertura), so a jacoco.xml in a feature folder is not a missing-evidence gap. - Corrects the pr-context-misclassification note: the summary can report a stale head SHA and file both changed .cs files under docs with "Core logic changes: 0 files", which would make the coverage hook skip C# enforcement entirely. Regenerate and verify before trusting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjbBgWNjMZLxBexMQMwLd
Found while verifying the #508 fix against a tree merged with current main. `PrintTree_WritesIndentedTreeToConsole` failed once in a full instrumented suite run (6397/6396/1) and passed on an immediate re-run of the same assembly with no code change (4688/4688). Root cause: the method name is defined twice, in two mirrored classes in different namespaces (`UtilitiesCS.Test/OutlookObjects/DASLFilterParser_Tests.cs:95` and `UtilitiesCS.Test/OutlookObjects/Filter DASL/DASLFilterParserTests.cs:95`), and both bodies redirect the process-global `Console.Out`. Neither class is `[DoNotParallelize]`, so under class-level parallelization one class's restore detaches the other's StringWriter and its content assertion sees empty output. The hazard is assembly-wide, not specific to these two classes: 29 files in `UtilitiesCS.Test` call `Console.SetOut` and most are not serialized. Both DASL files predate this work (present at merge-base 003c571) and neither is in the #508 diff, so the defect is pre-existing and out of scope for #508. This is the fourth distinct nondeterminism defect in this test assembly family, after #508, #511, and #516. Refs: #520 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjbBgWNjMZLxBexMQMwLd
- completion-gate-receipt-shapes: the exact field shapes the MCP require_complete gate demands (delegation_receipts as a LIST with eight keys, skill_receipts needing `required: true`, MCP receipts under `mcp_call_receipts` with `ok: true` + `evidence`). Guessing these cost three validate cycles; the authoritative source is orchestrator-state-routing.ts in drm-copilot. - jacoco-not-cobertura-for-evidence: convert raw Cobertura to package-level JaCoCo before pushing, per the maintainer's d0955dc decision, and derive artifacts/csharp/coverage.xml from it only once the figure clears the 85% floor the coverage hook enforces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vjbBgWNjMZLxBexMQMwLd
drmoisan
force-pushed
the
bug/wpf-dispatcher-yield-test-order-dependent-508
branch
from
August 8, 2026 23:05
e0cc646 to
b465646
Compare
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.
Fix order-dependent WpfDispatcherYield test by making the dispatcher lookup arrangeable (#508)
Summary
WpfDispatcherYieldTests.YieldAsync_WithoutDispatcher_RemainsStrictdeterministic by givingWpfDispatcherYieldan injectable-delegate seam, so the "no dispatcher" precondition is arranged by the test rather than inherited from ambient thread and process state.[ExcludeFromCodeCoverage]fromWpfDispatcherYield: the class is now genuinely unit-testable, so the exemption is no longer defensible.Why
The test asserted that
YieldAsyncthrowsInvalidOperationExceptionwhen no WPFDispatcheris available, but never arranged that precondition. The production resolution was:Both operands are ambient state the test did not control:
Dispatcher.FromThread(Thread.CurrentThread)is non-null on any pooled worker where an earlier test touchedDispatcher.CurrentDispatcher, which creates and caches a dispatcher for the calling thread on first access. At least nine classes inUtilitiesCS.Testdo exactly that.UiThread.Dispatcheris process-global, set-once static state that stays non-null for the rest of the process once any test triggersUiThread.Initialize().Under
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)]the thread this test lands on is undetermined, so the precondition silently evaporated and the assertion failed intermittently. This violated.claude/rules/general-unit-test.mdCore Principles 1 (Independence) and 4 (Determinism).An unreliable baseline is corrosive: it prevents anyone from distinguishing "my change broke a test" from "the suite is flaky," and it trains reviewers and agents to re-run until green.
What Changed
Core logic —
UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs(+37/-4)readonly Func<Dispatcher?>fields and aninternalseam constructor.public WpfDispatcherYield() : this(null, null) { }. This is load-bearing: declaring any constructor removes C#'s implicit parameterless one, which would have broken both existing call sites.??stays in the same position with the same operand order insideYieldAsync, so short-circuiting is preserved and the tests still verify the ordering rather than replacing it. The default lambda evaluatesThread.CurrentThreadwhen invoked, not at construction, so it still observes the calling thread.DispatchertoDispatcher?; removed[ExcludeFromCodeCoverage]and the now-unusedusing System.Diagnostics.CodeAnalysis;.The seam constructor is
internal, reached from tests through the pre-existing[assembly: InternalsVisibleTo("UtilitiesCS.Test")]atUtilitiesCS/Properties/AssemblyInfo.cs:19(present at the merge base; that file is not in this diff). The assembly's public API is unchanged in signature terms.Tests —
UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs(+164/-2)YieldAsync_CanceledToken_ThrowsBeforeDispatcherYieldOperationCanceledException; neither lookup consultedYieldAsync_ThreadAffinitizedDispatcherPresent_YieldsWithoutFallbackYieldAsync_ThreadDispatcherAbsent_FallsBackToProcessGlobalDispatcherYieldAsync_WithoutDispatcher_RemainsStrictInvalidOperationExceptionCountingDispatcherProviderrecords invocation counts so the tests pin resolution order, not just outcome.StaDispatcherHostowns a pumping STA thread (Dispatcher.Run()), setsIsBackground = true, and shuts down deterministically viaBeginInvokeShutdown(DispatcherPriority.Send)+Join().Note this is
System.Windows.Threading.Dispatcher.Run(), notApplication.Run— noWindow,Form, orControlis constructed, so it cannot display a window during a headless run.Documentation
The remaining files are the
minor-auditfeature folder for #508:issue.md(the sole acceptance-criteria source), the 3-phase atomic plan, three review artifacts, and 40 evidence artifacts.Architecture / How It Fits Together
WpfDispatcherYieldimplementsIDispatcherYieldand is constructed once in production atTaskMaster/AppGlobals/AppOlObjects.FolderTreeService.cs:365, then injected into the folder-tree service so long traversals can yield to the UI. Resolution policy stays in the class; only the two lookups are externalized. Consumers see no change: they still callnew WpfDispatcherYield()and get identical behavior.Verification
Completed
Fail-before — a genuine failing run, not an assertion. With the pre-change code, marshalling the unchanged call onto a pumping STA thread produced:
The test assembly was rebuilt before the probe (DLL mtime 16:18:36 -> 16:24:18), ruling out a stale-assembly false pass.
Repeated runs — a single green run does not prove an intermittent fix. Three full parallel
UtilitiesCS.Testruns recorded as evidence, plus three further independent runs during merge verification:Identical counts every time; per-test durations varied, so scheduling genuinely differed while outcomes did not.
Full toolchain, single clean pass, no intervening file change:
csharpier format .msbuild TaskMaster.sln … /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=truemsbuild TaskMaster.sln … /p:Nullable=enable /p:TreatWarningsAsErrors=trueInvoke-MSTestWithCoverage.ps1Re-run after merging current
main:csharpier checkon both files exit 0; a fullmsbuild /t:Rebuildwith analyzers exit 0; full suite 6397 / 6397 / 0.CSharpier 1.3.0 requires the
format/checksubcommands — barecsharpier .is not a valid invocation in 1.x, anddotnet tool run csharpieris unavailable in this checkout. That is a CLI-surface difference from the string quoted inCLAUDE.md, not a deviation from the policy.Coverage.
WpfDispatcherYieldThe denominator grew by 38 lines because
[ExcludeFromCodeCoverage]was removed; 45 newly covered lines more than offset it, so the exemption was retired honestly rather than by hiding the lines. One line remains uncovered: the default fallback lambda() => UtilitiesCS.UiThread.Dispatcher, reachable only via the parameterless constructor when the thread lookup returns null — arranging that through the parameterless constructor would reintroduce exactly the process-global ambient dependency this change removes.Review.
feature-reviewreturned ready to merge, 0 blocking findings (0 FAIL, 0 blocking PARTIAL). AC1 through AC9 all PASS, each check-off independently re-verified. Six advisory items recorded; none holds the merge.Recommended
Backward Compatibility / Migration Notes
No breaking changes and no migration required. The public API surface is unchanged, the seam constructor is
internal, and the defaults reproduce the prior expressions exactly. Both existing call sites —TaskMaster/AppGlobals/AppOlObjects.FolderTreeService.cs:365andUtilitiesCS.Test/OutlookObjects/Folder/OutlookFolderTreeServiceConcurrencyTests.cs:55— are absent from this diff and still bind to a public parameterless constructor.Risks and Mitigations
YieldAsyncand is asserted by two separate tests via invocation countsStaDispatcherHosthangs or leaks a threadDispatcher.Run(),IsBackground = true, deterministicBeginInvokeShutdown+JoininDispose. Advisory noted by review: theJoin()is unbounded, so a shutdown failure would hang rather than fail; considered acceptable because shutdown is unconditional and the host is fully owned by the test[ExcludeFromCodeCoverage]drops repo-wide coverageReview Guide
UtilitiesCS/OutlookObjects/Folder/WpfDispatcherYield.cs— the seam, about 35 lines.UtilitiesCS.Test/OutlookObjects/Folder/WpfDispatcherYieldTests.cs— four tests and two helpers.evidence/regression-testing/fail-before.*.md— proof the defect was real.evidence/qa-gates/repeat-run-*.md— proof it is gone.code-review.*.md,policy-audit.*.md,feature-audit.*.md— the audit verdicts.The remaining ~50 files are evidence and planning artifacts containing no executable code.
Two notes so they are not misread as defects:
d0955dc4for Bug: ribbon-engine-readiness-guard #503. The substitution was made before pushing, so roughly 20 MB and 378,000 lines never entered history.evidence/qa-gates/coverage-artifact-substitution.*.mdrecords the projection and shows the derived counts reproduce the Cobertura root attributes exactly.'*.cs' '*.csproj' '*.sln', because.claude/agent-memory/**is tracked, already modified at branch head, and its prose contains tokens such asDoNotParallelizethat would otherwise trip the prohibited-fix grep.This branch merges current
main(44440736). The only conflicts were three shared.claude/agent-memoryindex files, resolved by union with no entry dropped.Follow-ups
Console.SetOutraces under class-level parallelism.PrintTree_WritesIndentedTreeToConsoleis defined twice, in two mirrored classes in different namespaces, and both redirect the process-globalConsole.Outwithout serialization; one class's restore detaches the other'sStringWriter. 29 files inUtilitiesCS.Testshare this hazard. Both DASL files predate this work (present at merge-base003c5715) and neither is in this diff.QuickFiler.Testpump-host tests fail intermittently with "Invoke or BeginInvoke cannot be called on a control until the window handle has been created." A controlled four-run experiment confirms they are pre-existing: they reproduce at merge-base with this change fully reverted (6293 / 6291 / 2, a byte-for-byte match with the "Run 1" baseline recorded in Bug: wpf-dispatcher-yield-test-order-dependent #508) and were introduced by8f98264c(Build a WinForms message-pump test seam (Application.Run() background thread) to unblock 9 QfcItemController orchestration members #230). The full capture, which Bug: winformspumphost-tests-load-flaky-visible-window #511 explicitly requested, was posted there rather than duplicated into a new issue./t:Rebuildwith/p:Nullable=enable /p:TreatWarningsAsErrors=true. None is in either changed file (verified independently by both the executor and the reviewer). The repo-standard/t:Buildnullable gate is incremental and gates identically at baseline; disclosed rather than presented as a clean enumeration.GitHub Auto-close