Skip to content

fix(quickfiler): observe InitializeWebViewAsync faults at a boundary (#670) - #723

Merged
drmoisan merged 14 commits into
mainfrom
bug/qfc-initializewebviewasync-fault-is-unobserved-670
Sep 2, 2026
Merged

drmoisan merged 14 commits into
mainfrom
bug/qfc-initializewebviewasync-fault-is-unobserved-670

Conversation

@drmoisan

@drmoisan drmoisan commented Sep 2, 2026

Copy link
Copy Markdown
Owner

fix(quickfiler): observe InitializeWebViewAsync faults at a boundary (#670)

Summary

  • Adds a fault boundary over QfcItemController.InitializeWebViewAsync, whose returned task was discarded at three of its four production call sites, so any exception it raised became an unobserved task exception with no diagnostic.
  • Introduces InitializeWebViewGuardedAsync, an async Task member that contains the fault rather than returning it: the task it returns never transitions to Faulted.
  • Introduces WebViewInitializationErrorSink, an injectable seam over the static log4net logger, so the fault path is assertable in a unit test without a live WebView2 runtime.
  • Substitutes the guard at the three discarding call sites in QfcItemController.Initialization.cs (lines 192, 288 and 324). Line 256 is deliberately left calling the unguarded member, because it is already awaited and routing it through the guard would swallow a fault an existing test asserts.
  • Adds four tests, including a mutation-discriminated pair that proves the new test observes the fix rather than passing regardless of it.
  • New file coverage is 92.31% against the 90% new-module floor; repository-wide covered lines rise by 5 with no regression.

Why

On .NET Framework 4.5 and later, an unobserved task exception no longer terminates the process by default, so a discarded faulted task is finalized away with no observable effect at all. InitializeWebViewAsync is the sole entry point for WebView2 environment creation and core initialization, and at ViewerSetup.cs:112 it calls EnsureBreadcrumbPipeline(). Issue #488's D5 change made that path newly capable of throwing ObjectDisposedException when the pipeline is built against a viewer whose teardown has begun, which converted a previously silent leak into a fault that was itself silently swallowed.

The practical consequence was a diagnostic gap rather than a crash: a WebView2 initialization failure — a missing runtime, a locked cache directory, a disposed viewer — produced no log entry on three of four paths, so the breadcrumb surface simply never appeared and the cause was unavailable to anyone triaging it.

The fire-and-forget intent at those three sites is sound; not blocking initialization on a WebView2 round trip is deliberate. Discarding the fault is the part that was not.

What Changed

Core fix

  • QuickFiler/Controllers/QfcItemController.WebViewFaultBoundary.cs (new, 41 lines) — a new partial of QfcItemController carrying the sink and the guard. A new file rather than an edit to QfcItemController.ViewerSetup.cs because that file measures 499 lines against the repository's 500-line ceiling.
    • WebViewInitializationErrorSink defaults to (message, exception) => logger.Error(message, exception), matching the log4net message-first, exception-second signature.
    • InitializeWebViewGuardedAsync awaits InitializeWebViewAsync, swallows OperationCanceledException as expected cooperative cancellation during teardown, and routes any other Exception to the sink with a subsystem-identifying message.
  • QuickFiler/Controllers/QfcItemController.Initialization.cs — three call-site substitutions, net zero lines added or removed. The file remains 489 lines.
  • QuickFiler/QuickFiler.csproj — one added <Compile Include> entry. That project enumerates the QfcItemController partials explicitly with no wildcard.

Tests

  • QuickFiler.Test/Controllers/QfcItemController.InitializationTests.Part3.cs — three tests: the seam-fault-reaches-the-sink test, the default-delegate smoke test, and a pump-hosted test that drives Initialize(async: false) through WinFormsPumpHost and observes the fault through a signalling sink.
  • QuickFiler.Test/Controllers/QfcItemController.InitializationTests.cs — the shared arrange helper and a fourth test covering the guard's cancellation arm, placed here because Part3.cs had no room under the 500-line ceiling.

Docs and evidence

The feature folder carries the issue, spec, research note, atomic plan, the three feature-review audit artifacts, and the per-task evidence artifacts for every baseline, regression and QA gate.

Architecture / How It Fits Together

The boundary sits between the three fire-and-forget dispatch sites and the existing initialization member. Control flow is unchanged on the success path: the guard awaits the same member the sites previously called, so initialization is neither serialized nor delayed. On the failure path the exception is caught inside the guard instead of escaping into a discarded task, and is handed to the sink.

The sink is the testability seam. In production it is the default lambda over the static log4net logger; in a test it is replaced with a delegate that captures the exception or completes a TaskCompletionSource. This mirrors the ratified EfcFormController.BoundaryErrorSink shape already in the codebase, and is named distinctly so no shared contract between the two types is implied.

The broad catch (Exception) is deliberate and qualifies under .claude/rules/csharp.md as a defined boundary with added context: the file and member are purpose-named, the contract is documented, and the handler routes to the sink with a subsystem-identifying message plus the exception instance rather than swallowing it.

Verification

Completed

Full four-stage C# toolchain, one clean pass with no restart:

Stage Command Result
Format dotnet tool run csharpier format . then check . exit 0, 1567 files, no file under QuickFiler/ or QuickFiler.Test/ rewritten
Lint msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true exit 0, 0 errors
Type-check msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true exit 0, 0 errors
Test + coverage Invoke-MSTestWithCoverage.ps1 -SearchRoot . -Configuration Debug 6934 tests, baseline failure set NONE, post-change failure set NONE

Coverage:

Figure Value Gate Result
Baseline repository-wide line 85.3866% (54983/64393) — —
Post-change repository-wide line 85.3771% (54988/64406) no regression PASS
Covered-line delta +5 >= 0 PASS
New file QfcItemController.WebViewFaultBoundary.cs 92.3077% (12/13) >= 90% PASS

The post-change figure clears both repository floors: the 80% floor in CLAUDE.md and the 85% floor in .claude/rules/general-unit-test.md. The sole uncovered line in the new file is the try block's closing brace.

Mutation discrimination for the primary regression test — the same command, filter and assembly, differing only by the presence of the sink invocation:

  • sink invocation present: exit 0
  • sink invocation replaced with a discard: exit 1, failing on but found <null>
  • sink invocation restored: exit 0

A test that passed in both states would not observe the fix.

Acceptance criteria: 14 of 14 in spec.md delivered and verified, 0 remaining. Feature review returned 0 blocking findings across policy-audit, code-review and feature-audit.

Recommended

  • Re-run the four toolchain stages above against the merge result.
  • Exercise a real WebView2 initialization failure (rename the runtime, or lock the cache directory) and confirm a log entry now appears where previously none did.

Backward Compatibility / Migration Notes

No breaking changes. No public API is altered, no member is removed or renamed, and no signature changes. InitializeWebViewAsync itself is untouched, so any existing caller keeps its current behavior; only the three previously-discarding sites now route through the guard. QfcItemController.ViewerSetup.cs receives zero changed lines.

Risks and Mitigations

  • Risk: the guard converts a previously-propagating fault into a logged one at the three substituted sites, so an automated caller that relied on observing the task would no longer see it. Mitigation: none of the three sites observed the task — that is the defect being addressed — and line 256, the one site that does observe it, is deliberately unchanged and covered by an existing test.
  • Risk: the broad catch (Exception) could mask an unrelated programming error thrown from deep inside initialization. Mitigation: the handler logs rather than swallows, with the exception instance preserved, so the diagnostic is strictly better than the discarded-task behavior it replaces.
  • Rollback: revert the single implementation commit. The three call-site substitutions are net-zero-line replacements and the new file is additive, so the revert is mechanical.

Review Guide

Suggested order:

  1. QuickFiler/Controllers/QfcItemController.WebViewFaultBoundary.cs — 41 lines, the whole fix.
  2. QuickFiler/Controllers/QfcItemController.Initialization.cs — three one-line substitutions.
  3. QuickFiler.Test/Controllers/QfcItemController.InitializationTests.Part3.cs and .cs — the four tests.
  4. QuickFiler/QuickFiler.csproj — one added line.

Large mechanical diffs to skip: the two Cobertura coverage documents under the feature folder total roughly 388,000 added lines and are machine-generated evidence. The remaining feature-folder files are documentation and per-task evidence artifacts.

Note for reviewers using the generated PR-context bundle: it reports "Core logic changes: 0 files" for this branch and buckets all 61 changed paths as documentation. That classification is wrong — five of them are C# source. See Follow-ups.

Follow-ups

None of the following is delivered here, and none is filed from this branch; they are recorded for consolidated filing.

  • Boundary error sinks are settable and unguarded against null or throwing delegates. Affects both the WebViewInitializationErrorSink added here and the existing EfcFormController.BoundaryErrorSink precedent. A null or throwing sink would fault the guard's task and reinstate the unobserved-fault behavior. LATENT — no production code assigns either sink today. Should be addressed for both types together rather than under this issue.
  • EfcItemController fire-and-forget sites at EfcItemController.cs:97 and :153 use Task.Run(() => InitializeWebViewAsync()) against that class's own same-named member and discard the result. LIVE, but the class carries a class-level [ExcludeFromCodeCoverage] and has no injectable initializer seam, so extracting that seam is the real prerequisite.
  • TaskScheduler.UnobservedTaskException backstop at the add-in boundary. Optional hardening; finalization-timed, process-global, and not deterministically testable.
  • PR-context bundle disables the C# coverage gate. validate-feature-review-coverage.ps1 derives its changed-language set by parsing churn-annotated lines out of artifacts/pr_context.summary.txt, and returns success when that set is empty. On this branch the bundle classifies every changed path as documentation and truncates the enumeration to the top ten by churn, which this item's two large Cobertura evidence files dominate. No .cs path survives, so the language set is empty and C# coverage enforcement is skipped without any message saying so. Both mechanisms are independently sufficient to disable the gate.
  • Coverage-floor divergence between CLAUDE.md / .claude/rules/csharp.md (80% and 90%) and .claude/rules/general-unit-test.md / .claude/rules/quality-tiers.md (85% and 75%) remains unsettled. Already tracked in issue Coverage threshold contradiction remains: CLAUDE.md/csharp.md say 80%, general-unit-test.md/quality-tiers.md say 85%/75%, and two live gates disagree #563; this change sidesteps it by clearing both floors.

GitHub Auto-close

Issues #488, #563 and #464 appear above as context and precedent only. They remain open and this pull request does not act on them.

drmoisan and others added 14 commits August 31, 2026 20:40
Preparation-only work for issue 670. Adds the active bug feature folder
scaffold and the research artifact that settles the remediation shape
before planning begins.

Research findings that constrain the plan:
- QfcItemController.ViewerSetup.cs is 499 of the 500-line ceiling, so the
  fault boundary needs a new partial plus one Compile Include entry.
- IItemViewer.UiDispatcher is a WPF Dispatcher, so InvokeAsync returns a
  nested DispatcherOperation Task that would need unwrapping under a
  continuation-based shape.
- EfcItemController is class-level ExcludeFromCodeCoverage and has no
  injectable WebView2 seam, so it is out of scope here.

No production source is modified and no acceptance criterion is checked off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Fills the spec scaffold from the research record. Work mode is full-bug,
so spec.md is the sole acceptance-criteria source; no user-story.md.

Acceptance criteria AC1 through AC14, all unchecked. Each is carried by a
named test, a named command, or a mechanical file observation. Two are
scope guards rather than delivery items: AC8 pins that call site 256 keeps
calling the unguarded member, and AC11 pins the 500-line ceiling on every
touched file.

Records the coverage-floor divergence between CLAUDE.md and
.claude/rules/general-unit-test.md without resolving it here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Five phases, 69 tasks, covering baseline capture, the fault boundary, the
three call-site edits, regression tests, and the final QC loop. All 14
acceptance criteria map to at least one implementation task, one gate task,
and one named evidence artifact.

Validated with validate_orchestration_artifacts artifact_type plan: ok true,
no warnings.

Two planner corrections worth recording:
- A fourth test covering the cancellation arm was added. AC3 mandates the
  OperationCanceledException arm and AC13 mandates 90 percent line coverage
  on a file with roughly ten coverable lines, so leaving that arm uncovered
  would make AC13 unreachable.
- The research design for the first test was insufficient. Supplying a plain
  SynchronizationContext does not satisfy the awaiter, whose IsCompleted
  compares against SynchronizationContext.Current, so the continuation would
  post to the thread pool and the test would capture the wrong exception.
  The plan requires the instance to be installed as Current with a finally
  restore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Preflight round 1 returned revisions required with 11 defects, 3 blocking.
Plan grows from 69 to 71 tasks across the same 5 phases. Re-validated with
validate_orchestration_artifacts artifact_type plan: ok true, no warnings.

Blocking defects closed:
- The two test partials import both System and the Outlook interop
  assembly, so bare Action and Exception are CS0104. Three task texts
  instructed the bare spelling and the plan could not have compiled. The
  EfcFormControllerTests precedent does not transfer because that file
  does not import Outlook.
- Nothing sanitised the committed evidence. TRX embeds the run user and
  machine name and a per-test absolute path, and the Cobertura filename
  attribute stays absolute on exactly the path where the coverage floor
  assertion throws. Two sanitisation tasks now run before either commit.
- Both commit tasks required a clean feature folder after writing a
  commit-SHA artifact into that same folder. The porcelain span is now
  scoped to the two source directories.

Also corrected: the coverage runner prints no percentage on a successful
run, so two tasks now read the rate from the artifact; Phase 4 gained the
toolchain restart rule; stage 4 is now classified rather than ungated; and
three citation or artifact-reference errors were repaired.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Drove the issue-670 atomic plan to PREFLIGHT: ALL CLEAR across four further
preflight rounds (rounds 2 through 5), closing 16 defects on top of the 11 that
round 1 had already closed. The plan held at 5 phases and 71 tasks throughout and
was re-validated with the MCP plan validator after every revision, returning ok
with no warnings each time.

Blocking defects closed:

- P1-T4 asserted a zero-match Select-String for 'throw', but Select-String is
  case-insensitive by default and the guard body the same task dictates contains
  Token.ThrowIfCancellationRequested(), so the condition could never pass.
- P4-T5 carried a two-outcome stage-4 rule that contradicted P0-T14 and P4-T9.
  The coverage runner throws on any non-zero vstest exit with a message that does
  not carry the floor-assertion literal, so one pre-existing test failure routed
  the task into a restart branch that could not clear it.
- The baseline and post-change Cobertura documents could be captured in different
  post-processing states, which changes the denominator and made the P4-T8
  comparison fail spuriously. Both sides now record a POSTPROCESSED flag.
- P0-T2, P0-T3 and P0-T5 wrote resolved absolute host paths into evidence that
  the Phase 3 commit carries, while the only sweep reaching them ran in Phase 4.
  All three now sanitise at capture time.

Also corrected an executor instruction to rewrite spec criterion text, which the
acceptance-criteria-tracking protocol prohibits, an ungated P0-T9 precondition,
an incomplete vswhere placeholder binding, and several unrecorded deviations.

Preparation only: no production file was modified, no acceptance criterion was
checked off, and the single canonical plan path was revised in place with no
timestamped sibling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…ssons

Two lessons from the issue-670 preparation resume:

- Preflight rounds cascade when a delta changes a count or taxonomy that other
  tasks reference. Bundling the consequential sibling fix into the same delta is
  what let round 5 clear with zero defects. A planner's flagged-but-declined
  residual proved to be a real defect on both occasions it arose.
- The tracked orchestrator checkpoint cannot simply be left alone on a resume:
  the model-routing hook reads that exact path and denies every delegation until
  a matching receipt exists, and the inherited Codex-spelling receipts do not
  satisfy it. Setting skip-worktree before overwriting keeps the branch footprint
  clean while still satisfying the hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…initializewebviewasync-fault-is-unobserved-670

# Conflicts:
#	.claude/agent-memory/atomic-executor/MEMORY.md
#	.claude/agent-memory/atomic-planner/MEMORY.md
…670)

Three of the four production call sites of QfcItemController.InitializeWebViewAsync
discarded the returned Task, so any fault there became an unobserved task exception
with no diagnostic. On .NET Framework 4.5 and later an unobserved task exception no
longer terminates the process, so the failure was finalized away with no observable
effect at all.

Add a fault boundary in a new partial file, QfcItemController.WebViewFaultBoundary.cs,
carrying two members: an injectable Action<string, Exception> sink defaulting to the
existing static log4net logger, and an async Task guard that awaits
InitializeWebViewAsync inside a try and contains any fault by routing it to the sink.
Substitute the guarded member at the three discarding sites (lines 192, 288, 324).

A new file is mandatory rather than stylistic: QfcItemController.ViewerSetup.cs sits at
499 lines against the repository's 500-line ceiling, so the members cannot land there.

Line 256 is deliberately unchanged. It is `await InitializeWebViewAsync()` inside
`public async Task InitializeAsync()`, so its fault is already observed; routing it
through the guard would swallow the fault that
InitializeAsync_ThroughThePumpHost_RunsToTheMockedWebViewSeamAndFaults asserts.
ViewerSetup.cs receives zero changed lines.

OperationCanceledException is caught and swallowed without reaching the sink, because
InitializeWebViewAsync opens with Token.ThrowIfCancellationRequested() and the token is
cancelled during normal QuickFiler teardown. The broad catch is a defined boundary with
added context, which .claude/rules/csharp.md permits.

Four regression tests are added. The literal bugfix RED step does not apply: a test
written before the fix would reference members that do not exist and would fail to
compile, which reports nothing about the defect. The substantive red step is a mutation
demonstration recorded in the evidence tree: the same command, filter and assembly pass
with the sink invocation present (exit 0) and fail without it (exit 1, "but found
<null>"), then pass again once restored.

No public API changes. No call site gains an await, so fire-and-forget latency is
preserved. QuickFiler.Test.csproj is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…ance criteria

Carries the final toolchain-pass evidence and the spec check-offs for the #670
fault-boundary fix.

The four-stage C# toolchain completed in one pass with no restart: csharpier format
rewrote no file under QuickFiler/ or QuickFiler.Test/, csharpier check exited 0 over
1567 files, both msbuild gates exited 0 with 5 pre-existing System.Reactive warnings and
zero coded diagnostics, and the coverage run reported 6938 of 6938 passing against a
baseline of 6934 -- an increase of exactly the four added tests.

Coverage: the new partial reaches 92.3077% line coverage (12 of 13 lines), above the
90% new-module rule. The single uncovered line is the try block's normal-completion
path, which requires a live CoreWebView2 runtime and is therefore unreachable in a unit
test; that limitation is stated rather than excluded from measurement. Repository-wide
covered lines rose from 54983 to 54988 and the ratio moved -0.0095 points, inside the
0.10-point band. All three changed call-site lines are covered before and after.

All 14 acceptance criteria in spec.md are checked off with evidence.

Two recorded deviations from the plan as written:

Base ref. The plan pinned every diff gate to 2b85134, which was origin/main at
authoring time and has since been superseded. Against that SHA the P4-T11 changed-file
gate already reported 22 paths instead of five, and QuickFiler.Test.csproj showed an
added line from a sibling delivery, so both clauses would have failed for reasons
unrelated to this work. Every git command was re-anchored to 988d35a, the current merge
base with origin/main. The plan text is unedited; the substitution is recorded in each
affected artifact.

AC14 evidence path. AC14 names evidence/coverage/, which is not a canonical evidence
kind. The location is superseded by evidence/baseline/ and evidence/qa-gates/ per the
non-overridable evidence-path clause. The criterion text is left unedited, as the
acceptance-criteria protocol requires.

Three follow-up items are recorded for promotion: the EfcItemController fire-and-forget
sites, the optional UnobservedTaskException backstop, and the unresolved coverage-floor
divergence between CLAUDE.md and .claude/rules/general-unit-test.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…list

Carries the P4-T29 final-commit record and the last plan check-offs. All 71 plan tasks
are now marked complete.

The P4-T29 artifact names the preceding commit's SHA. This commit's own SHA is reported
in the executor's completion message rather than written into a file: a checkbox cannot
gate a tree state that the act of ticking it invalidates, so recording it here would
leave the file dirty again the moment it was written and the fixpoint would never close.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Policy audit, code review and feature audit for
bug/qfc-initializewebviewasync-fault-is-unobserved-670, anchored to
merge-base 988d35a.

Verdict: PASS, 0 blocking findings. All 14 acceptance criteria in
spec.md verified as earned; none unchecked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Adds the Cobertura .//line double-count trap and the "verify the
caller's factual correction" lesson; updates the session-cwd mirror
memory and the review-residuals index with #670.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…act the index

Adds three orchestrator memories from the #670 delivery:
- the exact orchestrator-state schema the PR-authoring PreToolUse hook
  demands before pull-request creation, discovered one blocked attempt
  at a time
- the top-N-by-churn truncation in the PR-context bundle that silently
  disables the C# coverage gate on evidence-heavy items
- a correction to my own practice after I asserted a negative from a grep
  scoped to the wrong file and the reviewer rightly overturned it

Compacts MEMORY.md from 20.4KB to 14.1KB by grouping entries into topical
sections with shared hooks. All 141 prior pointers are retained; a
set-difference check reports zero dropped links.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
@drmoisan
drmoisan merged commit 807fb0b into main Sep 2, 2026
5 checks passed
@drmoisan drmoisan mentioned this pull request Sep 2, 2026
1 of 5 tasks
@drmoisan
drmoisan deleted the bug/qfc-initializewebviewasync-fault-is-unobserved-670 branch September 2, 2026 13:31
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: qfc-initializewebviewasync-fault-is-unobserved

1 participant