Skip to content

fix(781): prove ItemViewer UI-boundary ownership by owner thread, not SynchronizationContext identity - #785

Merged
drmoisan merged 2 commits into
mainfrom
bug/breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers-781
Sep 6, 2026
Merged

fix(781): prove ItemViewer UI-boundary ownership by owner thread, not SynchronizationContext identity#785
drmoisan merged 2 commits into
mainfrom
bug/breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers-781

Conversation

@drmoisan

@drmoisan drmoisan commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Suggested title

fix(781): prove ItemViewer UI-boundary ownership by owner thread, not SynchronizationContext identity

Summary

  • ItemViewer.ThrowIfOffUiBoundary (issue Bug: itemviewer-breadcrumb-pipeline-lifecycle #488 defect D4) now proves UI-thread ownership with Dispatcher.CheckAccess() on the Dispatcher captured in the viewer constructor, replacing a SynchronizationContext reference comparison that rejected calls made on the owning UI thread.
  • Root cause: every production ItemViewer is constructed inside a WPF Dispatcher.Invoke/InvokeAsync operation (ItemViewerQueue via UiThread.Dispatcher), and WPF installs a DispatcherSynchronizationContext for the duration of that operation. The captured context is never the UI thread's ambient WindowsFormsSynchronizationContext again, so the old guard threw InvalidOperationException from InitializeBreadcrumbPipeline on every QuickFiler launch that produced folder suggestions.
  • Seven deterministic regression tests are added in QuickFiler.Test/Viewers/ItemViewerBreadcrumbThreadAffinityTests.cs, including the production-shape reproduction (viewer built inside Dispatcher.Invoke, pipeline initialized under a different ambient context) and two cross-thread cases that still expect the boundary exception. Two obsolete tests that encoded the old reference comparison are removed.
  • The guard's XML documentation and the EnsureBreadcrumbResourceOwnership statement-order comment are rewritten to describe the thread-identity contract and why context reference equality was unsuitable.
  • Full C# toolchain passed in one consecutive pass; repository-wide test run 6997/6997; feature review PASS with zero blocking findings and all eight acceptance criteria checked.
  • Housekeeping riding along: artifacts/orchestration/orchestrator-state.json, artifacts/pr_context.summary.txt, and artifacts/pr_context.appendix.txt are untracked. All three are generated session artifacts under the git-ignored artifacts/ tree that had been committed earlier; the tracked checkpoint content was stale state from an unrelated feature.

Why

QuickFiler high-confidence launch failed with an unhandled InvalidOperationException ("InitializeBreadcrumbPipeline must be called on the thread that owns this ItemViewer") from QfcCollectionController.LoadSecondaryAsync -> QfcItemController.AssignFolderComboBox -> EnsureBreadcrumbPipeline -> ItemViewer.InitializeBreadcrumbPipeline. The failure was deterministic for dispatcher-built viewers and was not a race or an InvokeRequired false negative; the thread-pool frames at the bottom of the reported trace are the async void rethrow path, not the throw site. A runtime probe on .NET Framework 4.8 (STA) recorded in evidence/other/dispatcher-synccontext-probe.2026-09-05T10-40.md shows Invoke ctx == outer ambient : False, which is the mechanism. The standard launch path reaches the same sequence and was expected to fail identically.

Constraints honored: the fix is confined to QuickFiler/Viewers/ItemViewer.Breadcrumb.cs; QfcCollectionController.LoadSecondaryAsync, QfcItemController.AssignFolderComboBox, and QfcItemController.EnsureBreadcrumbPipeline are unchanged (AC7, verified by evidence/qa-gates/scope-boundary.2026-09-05T10-49.md). The #488 D3 (second-provider fail-fast) and D5 (disposal) behaviors and their tests are unchanged and passing.

What Changed

Core fix

  • QuickFiler/Viewers/ItemViewer.Breadcrumb.cs: ThrowIfOffUiBoundary reads UiDispatcher, returns without effect when it is null (keeps FormatterServices.GetUninitializedObject-built test viewers inert), and throws InvalidOperationException when CheckAccess() is false. using System.Threading; removed; using System.Windows.Threading; added. Exception message second sentence now states that the calling thread is not the constructing thread. UiSyncContext and every other member are unchanged.

Tests

  • QuickFiler.Test/Viewers/ItemViewerBreadcrumbThreadAffinityTests.cs (new, 7 tests, MSTest + FluentAssertions, Arrange-Act-Assert; no sleeps, timers, temporary files, or message pump): same-thread success under a null ambient context, a different plain context, and a DispatcherSynchronizationContext; the dispatcher-constructed production shape; Task.Run worker rejection for InitializeBreadcrumbPipeline and the three-argument ConfigureBreadcrumbDropDown; and the null-owning-dispatcher escape.
  • QuickFiler.Test/Viewers/ItemViewerBreadcrumbLifecycleRegressionTests.cs: removed InitializeBreadcrumbPipeline_AmbientContextNull_ThrowsBoundaryDiagnostic and InitializeBreadcrumbPipeline_DifferentNonNullContext_ThrowsBoundaryDiagnostic; corrected one helper comment that described the old guard.
  • QuickFiler.Test/QuickFiler.Test.csproj: one <Compile Include> for the new test file.

Docs and evidence

  • docs/features/active/2026-09-05-breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers-781/: issue.md (AC1 through AC8 checked), atomic plan version 1.3, baseline / regression-testing / qa-gates / issue-updates / other evidence, and the feature-review artifacts policy-audit, code-review, and feature-audit (timestamp 2026-09-05T17-29).
  • docs/features/potential/promoted/: promoted bug entries for this issue and for the follow-up issue Bug: uithread-synccontext-awaiter-always-posts-for-dispatcher-built-viewers #784.
  • .claude/agent-memory/: subagent memory notes recorded during planning, execution, and review.

Architecture / How It Fits Together

ItemViewer() captures _uiDispatcher = Dispatcher.CurrentDispatcher (and still captures _context = SynchronizationContext.Current, which UiSyncContext continues to expose). The four guarded members (InitializeBreadcrumbPipeline, both ConfigureBreadcrumbDropDown overloads, EnsureBreadcrumbResourceOwnership) call ThrowIfOffUiBoundary as their first statement. The guard now asks the captured Dispatcher whether the calling thread is its owner, which is true for every call made on the UI thread regardless of which SynchronizationContext instance is ambient at the call site, and false for a thread-pool worker. BreadcrumbUiDispatcher and the rest of the breadcrumb pipeline are unchanged.

Verification

Completed (from evidence/qa-gates/ and evidence/regression-testing/, all commands run from the repository root in PowerShell):

  • dotnet tool run csharpier format . then dotnet tool run csharpier check .: exit 0, 0 files needing formatting.
  • msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true: exit 0, 0 errors, 3 warnings (all MSB3061 file locks from a running Outlook; equal to baseline).
  • msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true: exit 0, 0 errors, no CS86xx.
  • vstest.console.exe QuickFiler.Test.dll /EnableCodeCoverage /InIsolation: 1339/1339 passed.
  • Repository-wide dotnet-coverage collect around vstest.console.exe ... /InIsolation: 9 assemblies, 6997/6997 passed; line-rate 0.848347 -> 0.848316 on an identical 64740-line denominator (delta -0.000031 against a 0.005 tolerance); branch-rate 0.791542 -> 0.791421. Four UtilitiesCS.Test shell-icon classes were excluded locally because of a workstation-level SHGetFileInfo hang; CI covers them.
  • Regression: the new test class against the unfixed guard ran 5 Failed / 2 Passed of 7 (regression-fail-before); after the fix, 33/33 across the four affected classes (regression-pass-after). The feature reviewer independently reproduced both results by swapping the production file to the merge-base version and back.
  • Changed-code coverage: CHANGED-CODE COVERAGE: NOT MEASURABLE. QuickFiler/Viewers/ItemViewer.cs line 20 carries [ExcludeFromCodeCoverage] on the partial class (the ratified WinForms UserControl exemption), so the changed lines are outside the coverage denominator. Substitute evidence: every outcome of both conditionals in the rewritten guard has a named passing test (evidence/qa-gates/changed-code-coverage.2026-09-05T10-49.md).

Recommended:

  • gh pr checks --required on this PR (the S9 CI-green gate is applied before merge).
  • Manual: launch QuickFiler in standard and high-confidence mode against a mailbox with folder suggestions and confirm the breadcrumb selector populates without the boundary exception. Not verified in this PR.

Backward Compatibility / Migration Notes

  • No public API change. UiSyncContext, UiDispatcher, and all guarded member signatures are unchanged.
  • Behavior change: a call to a guarded member on the owning thread now succeeds under any ambient SynchronizationContext; a call from another thread still throws InvalidOperationException. The exception message's second sentence changed wording; no code asserted on it outside the two removed tests.
  • Three previously tracked, git-ignored artifacts/ files are removed from the index. No workflow reads them from git.

Risks and Mitigations

  • Risk: a caller that previously relied on installing another thread's context object to pass the guard. Mitigation: no such shape exists in the test suite (plan fact 8 and the reviewer's independent search); the whole-repository run is green. Rollback: revert the single production-file change; the new tests would then fail as documented in regression-fail-before.
  • Risk: viewers built without running the constructor (GetUninitializedObject) have a null UiDispatcher. Mitigation: the null-owner escape is retained and pinned by InitializeBreadcrumbPipeline_NullOwningDispatcher_DoesNotThrow.
  • Risk: repository-wide C# line coverage is 84.83 percent, below the 85 percent figure in .claude/rules/quality-tiers.md while above the 80 percent floor in CLAUDE.md. Mitigation: the figure is pre-existing and this change adds no line to the denominator; recorded as a non-blocking finding in the policy audit.

Review Guide

  1. QuickFiler/Viewers/ItemViewer.Breadcrumb.cs: the 41-line diff is the entire production change; read ThrowIfOffUiBoundary, its <remarks>, and the statement-order comment in EnsureBreadcrumbResourceOwnership.
  2. QuickFiler.Test/Viewers/ItemViewerBreadcrumbThreadAffinityTests.cs: the seven tests and their private helpers.
  3. QuickFiler.Test/Viewers/ItemViewerBreadcrumbLifecycleRegressionTests.cs: deletions plus one comment correction.
  4. code-review.2026-09-05T17-29.md and policy-audit.2026-09-05T17-29.md for the reviewer's independent RED/GREEN reproduction and coverage analysis.
  5. Everything else is evidence and planning documentation; the PR-context collector's "Core logic changes: 0 files" line is a classifier artifact and undercounts the four C# and project files above.

Follow-ups

  • Issue Bug: uithread-synccontext-awaiter-always-posts-for-dispatcher-built-viewers #784: UtilitiesCS.UiThread.SynchronizationContextAwaiter.IsCompleted uses the same reference comparison, so await viewer.UiSyncContext always posts for a dispatcher-built viewer (extra hop, not a failure). The same issue lists two review nits to ride along: the guard's <remarks> cites managed-thread-id uniqueness while CheckAccess() compares Thread references, and DrainableSynchronizationContext.Drain() in the regression test file is now unused.
  • Issue Bug: tryaddvaluesasync-wall-clock-timeout-flaky #780 (pre-existing): DictionaryExtensions_Tests.TryAddValuesAsync_UpdatesExistingValue is intermittently flaky under parallel coverage runs; it passed in both runs here.
  • Housekeeping: three stale artifacts/pr_body_* file pairs remain tracked under the ignored artifacts/ tree and can be removed in a separate chore.

GitHub Auto-close

Other numbers in the collector's author-asserted list (#475, #488, #780, #784) are references, not closures.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BVVYdzxky9mmdcUVaYnH6k

drmoisan and others added 2 commits September 5, 2026 20:16
- Replace ItemViewer.ThrowIfOffUiBoundary's SynchronizationContext reference
  comparison with an owner-thread identity check via the captured
  Dispatcher.CheckAccess(), since a WPF dispatcher operation installs a
  DispatcherSynchronizationContext that never matches the UI thread's ambient
  context again, causing the guard to reject calls made on the owning thread
- Update the guard's XML docs and the fixed-statement-order comment to
  describe the new thread-identity check
- Add ItemViewerBreadcrumbThreadAffinityTests with seven regression tests and
  remove the two obsolete reference-comparison tests from
  ItemViewerBreadcrumbLifecycleRegressionTests
- Add baseline, regression, and QA evidence plus the finalized atomic plan and
  AC check-offs under the active bug feature folder, and record related notes
  in subagent memory
- Untrack artifacts/orchestration/orchestrator-state.json, a gitignored
  session checkpoint that should not have been committed

Refs: #781

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVVYdzxky9mmdcUVaYnH6k
- Add policy-audit, code-review, and feature-audit artifacts (2026-09-05T17-29) for the #781 feature review
- Append an orchestrator correction to coverage-delta.2026-09-05T10-49.md recording the reviewer's class-by-class Cobertura finding
- Add promoted potential bug entry for the UiThread SynchronizationContext awaiter reference-comparison defect (issue #784)
- Update feature-review subagent memory notes under .claude/agent-memory/feature-review/
- Untrack artifacts/pr_context.summary.txt and artifacts/pr_context.appendix.txt, regenerated collector outputs already covered by .gitignore

Refs: #781

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVVYdzxky9mmdcUVaYnH6k
@drmoisan
drmoisan force-pushed the bug/breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers-781 branch from b808815 to c1653ec Compare September 6, 2026 00:16
@drmoisan
drmoisan enabled auto-merge September 6, 2026 00:19
@drmoisan
drmoisan merged commit 64dc3c2 into main Sep 6, 2026
5 checks passed
@drmoisan
drmoisan deleted the bug/breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers-781 branch September 6, 2026 00:24
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: breadcrumb-ui-boundary-guard-rejects-dispatcher-built-viewers

1 participant