fix(quickfiler): await a counted queue drain before metrics and cleanup (#633) - #717
Merged
drmoisan merged 9 commits intoSep 1, 2026
Merged
Conversation
Preparation for issue 633 (unsynchronized undo handoff after batch move) completed through the route_id:preparation contract: issue.md, spec.md with 25 acceptance criteria, the research artifact, and the nine-phase atomic plan that cleared atomic-executor preflight with PREFLIGHT: ALL CLEAR. The preparation child terminated on a session rate limit after preflight cleared but before it committed, so this commit completes that terminal step only. No plan or specification content was authored or modified here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…d-undo-handoff-after-batch-move-633
… regression for issue 633 Phase 1 adds a behaviour-preserving internal ItemProcessor seam to FilerQueue so the per-item call can be driven deterministically from a test. Phase 2 adds two barrier tests in a new QfcFormControllerUndoHandoffTests file, plus its project compile item. Both tests fail against this tree, which is the intended fail-before witness: with one item still parked behind a closed gate, BackGroundMoveAsync has already dispatched the metrics operation to the UI dispatcher by the time an equal-priority probe completes. Also includes the Phase 0 baseline evidence: green full-suite run (6912 passed), filtered coverage denominator at 85.32 percent, and clean analyzer and nullable rebuilds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…up (issue 633) BackGroundMoveAsync proceeded to the WriteMetrics and CleanupBackground dispatches as soon as the batch had been *enqueued*, not filed. The undo pushes happen later on the queue worker, so the handoff was unsynchronized and nothing in the code expressed the ordering it relied on. FilerQueue gains a counted, per-batch, awaitable quiesce, WhenDrainedAsync(). The one-shot ThreadSafeSingleShotGuard start gate is replaced by a start/stop decision taken under a single monitor, which closes the orphaned-item window: the running flag is now cleared in the same critical section in which TryTake fails. The outstanding-work counter is decremented in a finally, so a throwing item still decrements and the drain cannot hang. Consumer keeps its type, accessibility and completed-task default. QfcFormController.EventHandlers awaits the barrier between the batch move and the metrics dispatch, adds _parent to the early-return guard, and drops the two now-subsumed Consumer awaits. Metrics-before-cleanup order is unchanged. SeamFactoryTests is reconciled: it no longer reflects into the removed private guard field and instead observes the item the queue handed to the ItemProcessor seam. Toolchain green in one uninterrupted pass: csharpier check 0 unformatted; both msbuild /t:Rebuild gates exit 0 with zero CoreCompile skips; 6924 tests, 6924 passed, 0 failed. Coverage 85.32 -> 85.39 percent; FilerQueue.cs per-file rate 1.00; zero uncovered changed lines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…he plan Marks AC1 through AC20 complete in spec.md, each against named evidence under the feature folder's evidence tree. The spec diff is confined to the Last Updated metadata line, the twenty checkbox flips, and one added paragraph under the deviation section recording the fail-before split as delivered; no criterion text was reworded, renumbered, or reordered. Adds the P8-T3 diff-scope record, the P8-T1 sanitisation record, and the issue-633 update mirror. Sets the plan to Complete with every task checked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
… criterion The close-out commit sweep picked up two orchestrator agent-memory files that were written while the executor was running. They are unrelated to this fix and put the branch diff outside the path set the spec's footprint acceptance criterion allows, which is the two named production files plus QuickFiler.Test/ and docs/ only. Restore both paths to their base state so the branch diff carries no path outside that set. The memory content is reported to the maintainer for landing on a separate branch.
The P8-T1 sweep substituted only the three spellings of this worktree's own absolute path. Its Output Summary generalised that scoped result to "no absolute host path in any file's content", which was false: every other absolute host path and every bare host identifier in the evidence set survived the pass. Corrective sweep over the branch's changed set only, derived from `git diff --name-only origin/main...HEAD` (74 paths): - 8 `.msbuild.txt` logs carrying an analyzer-configuration path into the main checkout, 36 occurrences each. - 8 `.trx` files carrying account and machine identifiers in their `runUser`, `computerName`, test-run `name`, and `runDeploymentRoot` attributes. - 1 plan-file note that defined the `WORKTREE` constant by writing its literal absolute value, reinstating the identifier while documenting its removal. 366 identifier-token occurrences and 289 drive-rooted user-profile paths removed across 17 files; post-sweep counts are 0 for every measure. Substitution is case-insensitive over decoded file content, because vstest writes the `storage=` attribute in lower case while writing the run-identity attributes in mixed case, so a case-sensitive pass clears the visible header and leaves the lower-case copy intact. TRX files use the bracket-free tokens REDACTED_USER, REDACTED_HOST and REDACTED_PATH: an angle-bracket placeholder in an XML attribute value would make the document malformed. All 8 TRX files were confirmed to parse as XML both before and after. Neither artifact quotes a pre-substitution value; each substituted token is described by class only, since quoting a removed identifier writes it back into a committed file. `p8-t1-sanitisation.2026-09-01T11-15.md` retains its original record and gains a dated correction stating the true scope of that pass. The corrective sweep is recorded in `p8-t1-sanitisation-correction.2026-09-01T11-47.md`; that artifact claims no plan task ID, since `[P8-T2]` and `[P8-T3]` are the plan's commit and diff-scope tasks. No production or test code changed, so no toolchain re-run was required. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Commit the policy audit, code review, and feature audit produced by the feature-review pass. All twenty acceptance criteria pass and the pass returned zero blocking findings. The reviewer re-derived coverage, the formatter result, and the CoreCompile skip counts from primary sources rather than reading the executor's recorded values. Non-blocking findings are recorded in the artifacts and are not promoted from this branch, because promotion would add paths the footprint acceptance criterion does not permit.
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(quickfiler): await a counted queue drain before metrics and cleanup
Summary
FilerQueuegainspublic Task WhenDrainedAsync(), a counted, awaitable quiesce backed by a monitor-guarded outstanding-work counter and a lazily createdTaskCompletionSource<bool>drain signal.BackGroundMoveAsyncnow awaits that drain after the batch move and before both theWriteMetricsdispatch and theCleanupBackground()dispatch, so the ordering the code previously only assumed is now enforced by control flow.Enqueue/ConsumeAsyncstart-stop handshake is repaired. The one-shot guard start gate is replaced by a running flag set and cleared inside the same critical section that performs the queue add and theTryTakefailure, which closes the orphaned-item window.internal Func<FilerQueueItem, Task> ItemProcessorseam replaces the hard-coded per-item call, making the concurrency assertions testable without a live Outlook COM path.await _parent.FilerQueue.Consumer;statements are removed, andBackGroundMoveAsync's early-return guard gains a_parentnull clause for the post-Cleanup()state.Why
The batch-move path treated the undo stack as populated by the time the move completed, but the push happens later on a queue worker.
MoveMailAsynconly enqueues the filer and then returnsawait Task.CompletedTask, so awaiting it conveyed nothing about the filing work.BackGroundMoveAsyncthen proceeded straight toWriteMetricsandCleanupBackground()with no statement between them that observed the queue at all.The defect was latent rather than active: the entries land eventually and are serialized, so undo worked in the observed configuration. The cost was the absent ordering constraint. A future caller reading the stack immediately after a batch move would have seen an incomplete stack with no diagnostic.
The handshake repair is a precondition rather than an opportunistic refactor.
Consumeris not a lifetime task — it completes whenever a worker observes a momentarily empty queue — and bothEnqueueoverloads performedQueue.Addbefore reading the guard while the worker exited itsTryTakeloop before reinstalling one. A barrier layered over that handshake would have reported "drained" while an item was stranded. Adding a barrier that reads as a guarantee but is not one is worse than the present state, in which the ordering constraint is at least honestly unexpressed.What Changed
Core fix (2 production files)
QuickFiler/Controllers/FilerQueue.cs— outstanding-work counter, drain signal,WhenDrainedAsync(),ItemProcessorseam, monitor-protected running flag replacing the one-shot guard. The counter decrement is in afinally, so a throwing item still decrements.Consumeris retained with its type, accessibility, and completed-task default.QuickFiler/Controllers/QfcFormController.EventHandlers.cs— awaits the drain before the two dispatches, adds the_parentguard clause, deletes the two subsumedConsumerawaits.Tests (4 files)
QuickFiler.Test/Controllers/FilerQueueTests.cs— extended with the queue-level drain cases; the class comment recording the deliberateEnqueue/ConsumeAsyncexclusion is corrected, since that exclusion no longer holds.QuickFiler.Test/Controllers/QfcFormControllerUndoHandoffTests.cs— new; the ordering tests.QuickFiler.Test/Controllers/QfcItemController.SeamFactoryTests.cs— reconciled off the privateguardfield the repair removes, which it previously reached by reflection.QuickFiler.Test/QuickFiler.Test.csproj— oneCompile Includeentry, since the project uses explicit compile items.Architecture / How It Fits Together
Enqueuetakes the monitor, increments the counter, adds to theBlockingCollection, and decides whether to start a worker — all in one critical section, so the decision is atomic with respect to the worker's loop exit.ConsumeAsyncdrains viaTryTake, invokesItemProcessorinside the existingtry/catch, and decrements in afinally; when the counter reaches zero it completes and clears the drain signal.WhenDrainedAsync()returns an already-completed task when nothing is outstanding, and otherwise the signal's task.BackGroundMoveAsyncawaits_groups.MoveEmailsAsync(...), then_parent.FilerQueue.WhenDrainedAsync(), then dispatches metrics and cleanup in that unchanged order. BecauseMoveEmailsAsyncawaits each group'sMoveMailAsyncsequentially and each enqueues synchronously, the outstanding count observed at that point is an exact upper bound on the batch — which is what makes the counted barrier correct rather than heuristic.The barrier is awaited off the UI thread, before both
UiThread.Dispatcher.InvokeAsynccalls, and the monitor is never held across anawait.Verification
Completed
Full C# toolchain, one uninterrupted pass, in order:
dotnet tool run csharpier check .msbuild /t:Rebuild ... /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=trueSkipping target "CoreCompile"msbuild /t:Rebuild ... /p:TreatWarningsAsErrors=trueInvoke-MSTestWithCoverage.ps1Both MSBuild gates were run with
/t:Rebuildand file logging, and the zeroSkipping target "CoreCompile"count is recorded so the gates are demonstrably non-vacuous rather than warm no-ops.Coverage, first-party filtered denominator, identical nine-package set before and after: line 85.32 percent to 85.39 percent, branch 79.32 percent to 79.40 percent.
FilerQueue.csper-file coverage 36.73 percent to 100.00 percent. 138 changed lines, 0 uncovered.Fail-before evidence is a genuine failing run for the barrier defect, recorded at
evidence/regression-testing/fail-before-run.2026-09-01T10-46.mdwith the passing counterpart atpass-after-run.2026-09-01T10-55.md. The queue-level drain suite and the orphan-window regression cannot compile against the pre-fix tree, so they are covered by the dossier atevidence/regression-testing/fail-before-exception.2026-09-01T10-48.mdrather than by a failing run.All twenty acceptance criteria in
spec.mdpass. Feature review returned zero blocking findings and re-derived the coverage figures, the formatter result, and the CoreCompile counts from primary sources rather than reading the recorded values.Recommended
Backward Compatibility / Migration Notes
Additive on the public surface.
WhenDrainedAsync()is new;ItemProcessorisinternaland reachable from tests through the existingInternalsVisibleTo.Consumerkeeps its declaration, accessibility, andTask.CompletedTaskdefault, and bothEnqueueoverloads keep their signatures and exception behaviour — including the synchronousArgumentNullExceptionin the caller's frame thatQfcItemController.MoveMailAsyncwraps.The only user-observable difference is timing: the batch-move task now completes after the batch has been filed rather than after it has been enqueued. That wait occurs after the next group is loaded and displayed, and
ButtonOK_Clickisasync voidand has already yielded, so the message loop is not blocked.No configuration key, no persisted state, no schema change. Rollback is a revert of the commits.
Risks and Mitigations
finallyand by the regressionItemProcessor_ThatThrows_StillDecrementsAndDrainCompletes. See the first follow-up below for a residual case.Enqueue_AfterPreviousBatchDrained_ProcessesSecondBatchpins it.Task.Run, and the barrier is awaited before any dispatcher operation is in flight.Review Guide
QuickFiler/Controllers/FilerQueue.cs— the critical sections are the substance of the change. Check that the counter, the queue add, and the start/stop decision are all under one monitor and that the monitor is never held across anawait.QuickFiler/Controllers/QfcFormController.EventHandlers.cs— small, and the placement of the awaited drain relative to the two dispatches is the whole point.QuickFiler.Test/Controllers/QfcFormControllerUndoHandoffTests.cs— the determinism argument is worth reading closely. Ordering is established by a probe operation posted at equal priority to a pinned dispatcher, not by elapsed time; there is no sleep, delay, or polling anywhere in the added tests.docs/paths are evidence and audit artifacts, mechanical and safe to skim.Follow-ups
These were found during review and are deliberately not addressed here, because the change is bounded by a footprint acceptance criterion that permits only the two production files,
QuickFiler.Test/, anddocs/. They will be filed separately.FilerQueuecan leak the consumer-running flag (Major). The flag is cleared only on the normal loop-exit path. Thecatchhandler's own body sits outside thetry/catch/finally, so an exception raised inside the diagnostic — for exampleHelpers.First()on an empty list, which theFilerQueueItemconstructor permits — escapes the worker loop and leaves the flag set.Enqueue((FilerQueueItem)null)is a second route. Neither is reachable from the single production call site today, but the consequence post-fix is a hang rather than a delay. Suggested fix: atry/finallyaround the loop clearing the flag under the monitor, a null-and-empty-safe diagnostic, and a null guard on the item overload.ConsumeAsyncispublicand now participates in an invariant it does not establish; an external call could reopen the window this change closes. The sibling queue inTaskVisualizationdeclares the same methodinternal. Sealing it would be a breaking change, so the minimum step is a documented warning.CancellationTokenand has no upper bound.DASLFilterParserTestsneeds[DoNotParallelize]. It usesConsole.SetOutand races other tests; the repository already applies that attribute toPrettyPrint_Testsfor the identical hazard. Out of footprint here.QfcFormController.EventHandlers.csper-file coverage is 49.41 percent, below the modified-file floor. Pre-existing at 45.38 percent, improved by 4 points, with zero uncovered changed lines; the remainder is untouched Outlook-interop and WinForms handler code. Belongs in a dedicated coverage-uplift issue.TaskVisualization/FlagChangeTrainingQueue.cs, a different type with a different consumer, recorded as a non-goal in the spec.GitHub Auto-close