Share the shared-config-dir cleanup instead of hardening one class at a time - #468
Conversation
… a time TokenStoreProfileTests' [Before(Test)] hook deleted the shared KCAP_CONFIG_DIR artefacts with bare File.Delete / Directory.Delete. On Windows an open handle makes that a hard sharing violation, and thrown from a Before hook it fails the test before it runs: BeforeTestException: BeforeTest hook failed: The process cannot access the file '...\kcap-repopathstore-tests-952701da\config.json' because it is being used by another process. A retrying cleanup for exactly this already existed — privately, inside CrossProcessRefreshTests, from the previous round of this fix. That round hardened the one class that was failing at the time; the identical exception then surfaced in the next class to touch the same files. So the retry is now shared, and owned by the RESOURCE rather than by whichever class is currently unlucky. Correcting the premise on the way, because it changes what a fix can be: a NotInParallel key cannot help here. CI runs this suite with --maximum-parallel-tests 1, so there is no concurrency to serialise — meaning a lock at Before(Test) time proves a handle OUTLIVED its owning test, either an undisposed stream awaiting finalization or a child process not yet reaped. Adding a shared key would have looked like a fix while the leak persisted, which is worse than the flake. The helper keeps the earlier conclusion intact: retry a transient sharing violation, then THROW with a named cause. Never swallow — these tests assert on token and profile state, and a stale tokens directory already satisfies "a peer already refreshed it", so a false pass would hide a real regression rather than cost a rerun. One addition: a single GC + finalizer pass after the first failed attempt. It can only release an in-process undisposed stream, so it doubles as a discriminator — if the retry budget stops being exhausted, the holder was an unreferenced stream; if failures continue, it is a live holder and the owner is still running. The thrown message says the pass was attempted so the next occurrence carries that fact. NOT verifiable locally: this is Windows-only (Unix unlinks regardless of open handles) and intermittent, so neither a local run nor a single green Windows CI run establishes it. Verified only that behaviour is unchanged where it can be: TokenStoreProfileTests 20/20, CrossProcessRefreshTests 4/4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoShare KCAP shared-config-dir cleanup retry across unit tests
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Two review findings, both correct, and the second is a direct hit on my own
reasoning.
HIGH — File.Exists / Directory.Exists were used as success oracles: the loop
returned early when they reported "absent". They return false for access and some
I/O failures too, not only for absence, so the helper written specifically to
refuse false passes could report success over state that was still present and
merely unreadable. Absence is now established only by the delete operation itself:
a missing file makes File.Delete a no-op, and a missing directory raises
DirectoryNotFoundException. Both are now caught as "definitively absent". The
existence probe is gone from the decision path entirely, which also removes the
Func<bool> parameter.
MEDIUM — the GC.Collect + WaitForPendingFinalizers pass is removed rather than
reworded. I had claimed it discriminated an undisposed stream from a live holder.
It does not:
* a child process can close DURING the pause, so any apparent effect is
confounded by the delay the pass itself adds — the exact confound I would
reject in someone else's measurement;
* GC runs arbitrary finalizers, not uniquely a leaked stream;
* worst, it can CONCEAL a genuine undisposed-handle defect by making it pass.
The thrown message asserted an undisposed stream was "ruled out", which it could
not establish. Removing the pass also restores a claim I had made and broken: that
this change is purely #418's already-reviewed logic moved to the shared resource.
With the GC pass it was not — it silently changed CrossProcessRefreshTests'
behaviour by adding pauses.
Verified: TokenStoreProfileTests 20/20, CrossProcessRefreshTests 4/4. Still not
locally verifiable against the actual failure, which is Windows-only and
intermittent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code Review by Qodo
1.
|
Same note the reviewer gave on the sibling PR, and I had repeated the same excess
here after trimming it there: the method docs had grown "(review fix, HIGH)" /
"(review fix, MEDIUM)" paragraphs narrating what earlier versions of this change
did. That is review history, it is recorded in full in the commits, and it does not
help someone reading the helper.
Cut. What is kept is the set of facts a reader cannot recover from the code, each
of which exists to stop a specific wrong move:
* the shared-dir mechanism (static readonly ConfigDir, one dir per process, 12+
classes deleting the same files) — why the retry belongs to the resource;
* that --maximum-parallel-tests 1 makes a NotInParallel key useless here, and
what a lock at hook time therefore proves — this is the fix someone would
otherwise reach for, twice now;
* why it is Windows-only and intermittent — otherwise it reads as unexplained
flakiness;
* why a persistent lock must throw rather than be swallowed — a stale tokens dir
satisfies the assertion under test, so swallowing yields a false pass;
* why Exists is not the absence oracle.
Verified unchanged: TokenStoreProfileTests 20/20, CrossProcessRefreshTests 4/4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes Linear AI-1743 (GitHub #466). Recurrence of AI-1620's exception, one class over.
Symptom
Build and test (windows-latest)goes red with a single failure, in a hook rather than an assertion:Windows only; ubuntu green on the identical commit.
The premise correction that shapes the fix
The issue proposed either per-class config directories or a shared
NotInParallelkey. Neither can work, and it's worth stating before the fix:.github/workflows/ci.yml:66runs this suite with--maximum-parallel-tests 1across the whole matrix, Windows included, and line 56 says so outright.So there is no concurrency to serialise and no race to isolate. Which means a lock at
[Before(Test)]time proves a handle outlived its owning test — an undisposed stream awaiting finalization, or a child process (watcher/daemon) not yet reaped. Both observed properties follow from that and only from it:A shared key would have been worse than useless: it would look like a fix, the flake would recur at a lower rate, and the leak would still be there.
The fix: own the resource, not the class
A retrying cleanup for exactly this already existed — privately, inside
CrossProcessRefreshTests, from the previous round. That round hardened the one class failing at the time, and the identical exception then appeared in the next class touching the same files. There are 12+ candidates.So the retry now lives in
SharedConfigDirCleanup, and both classes use it.TokenStoreProfileTests' hook went from bare deletes to the shared helper;CrossProcessRefreshTestsdelegates and drops its private copy.Nothing about the mechanism is new — it is the already-reviewed logic from #418, moved to where it covers every user of the shared files.
Retry, then throw — never swallow
Kept from AI-1620 deliberately. These tests assert on token and profile state, and a stale tokens directory already satisfies "a peer already refreshed it". Swallowing a persistent lock would convert a rerun-costing flake into a false pass that hides a real regression.
One addition: a GC pass that doubles as a discriminator
After the first failed attempt only:
This can only release an in-process undisposed stream, so its effect distinguishes the two candidate causes. If the retry budget stops being exhausted, the holder was an unreferenced stream awaiting finalization; if failures continue, it is a live holder and the owner is still running. The thrown message records that the pass was attempted, so the next occurrence carries that fact rather than needing another investigation.
Verification — and what it cannot show
TokenStoreProfileTestsCrossProcessRefreshTestsNeither establishes the fix. The failure is Windows-only and I am on macOS, where the delete never fails; and it is intermittent, so even a green Windows CI run is weak evidence. What is verified is that behaviour is unchanged where it can be observed, and that the retry-and-throw contract is the one the earlier round concluded was correct.
🤖 Generated with Claude Code