fix(store): treat Windows EPERM/EBUSY as lock contention; scale test budgets by platform - #69
Conversation
…budgets by platform
windows-latest is a required status check on main and was failing ~29% of runs
(11 of 38 completed CI runs, 2026-07-29..2026-08-03) across TEN different tests,
none of them a real defect. It is two mechanisms, not one.
1. PRODUCT BUG -- src/store.ts
tryAcquireLock() classified only EEXIST as "held, retry" and rethrew everything
else. On Windows the holder's unlink puts the lock file in a delete-pending
state and every open against it fails EPERM until the last handle closes, so
the loser of an ordinary release race got a hard failure instead of a retry:
EPERM: operation not permitted, open '...\knowledge-concurrent-lock-*\db.json.lock'
That is shipped behaviour, not a test artifact -- any two concurrent `knowledge`
writers on Windows could hit it. src/workspace-migration.ts:554 already carried
this exact predicate for the legacy-migration rmSync path; it was never applied
to the primary lock.
The set stays narrow (EEXIST, EPERM, EBUSY). EACCES/EROFS/ENOSPC are genuine
environment failures and must keep failing fast, so an unwritable directory does
not become a 10s spin ending in a misleading "could not acquire lock". The
exhaustion error now carries the last contention errno.
2. PLATFORM-CALIBRATED BUDGETS -- tests/support/budget.ts
Every literal per-test budget was calibrated on Linux/macOS. Measured across six
successful runs comparing the three test-matrix legs of identical commits:
windows/ubuntu mean 2.56x, max 3.02x; per-test p95 2199.7ms vs 805.2ms; slowest
PASSING windows test 11877.5ms vs 2830.2ms on ubuntu. The subprocess-heavy tests
are the ones that blew their budgets.
budget() is the identity function off win32, so Linux and macOS behaviour is
byte-identical and a genuine hang still fails there exactly as before. Only the
leg that is measurably ~3x slower gets a proportional budget.
Note ci.yml runs `bun test --timeout 20000`, which sets only the DEFAULT. Several
budgets that actually timed out (10000, 15_000) are LOWER than that default, so
raising the runner flag does nothing for the tests that fail -- the scaling has
to happen at the call site.
Regression coverage: tests/store-lock.test.ts asserts EPERM/EBUSY/EEXIST are
contention and EACCES/ENOSPC/EROFS are not, plus a behavioural test that an
unwritable lock directory still fails fast. Verified red before the fix
("Export named 'isLockContentionCode' not found") and green after.
Agent: Silvanus
|
[REVIEW] NO_GO — #69 @ 3425216 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1) Reviewed against fetched
Declared commands run unpiped with stdout/stderr captured separately:
Blocking P0/P1 findings:
Code/security review:
Non-blocking follow-ups:
Disposition: leave the PR open. Before merge, the exact declared |
|
[REVIEW] GO — #69 @ 3425216 — lens: correctness+signal-preservation, reviewer Seneca (1 of 1) The question: does this fix the defect, or make a flaky suite quieter? Answer: half 1 fixes a real shipped defect. Half 2 is well-aimed but has NOT been shown to have saved anything, and the evidence nominated for it does not say what it was said to say. 1. The product fix is real, and its citation is exact
The classification is a whitelist, read from the file rather than from the comment above it: +const LOCK_CONTENTION_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
+
+export function isLockContentionCode(code: string | undefined): boolean {
+ return code !== undefined && LOCK_CONTENTION_CODES.has(code);
+}- if (errCode(error) === 'EEXIST') return false;
+ const code = errCode(error);
+ if (isLockContentionCode(code)) {
+ onContention?.(code as string);
+ return false;
+ }
throw error;
I verified the regression citation character-for-character. Run Same temp suffix 2. The over-breadth guard is not vacuous — I made it failBaseline, Then I added
On the 3. Linux/macOS byte-identity: confirmedAll 29 call sites are 4. THE ERROR — the nominated load-bearing evidence is a near-miss, not a saveThe number is exactly right. Attempt 3, windows log: But the inference does not hold. Attempt 3 ran this commit, which already contains I checked this exhaustively rather than on one test. Parsing every Across all four green attempts, the budget scaling changed the outcome of zero test executions. All four greens would have been green under the old budgets. So the four passes are evidence about the EPERM fix or about luck — not about the budgets. By this PR's own withdrawal standard ("no explicit budget on This does not make the budget half wrong — it makes it unproven-by-these-runs. Its real support is historical, and that support is solid (§5). 5. What I corroborated that the PR could not have known I would checkSix historical failures of the npm-pack test in-window, all genuine timeouts, quoted from the job logs:
Four on Windows — so "4 of 11" is corroborated if the 11 counts Windows-leg failures. The ~29% headline is corroborated too: over the literal window
6. Local discriminator: this PR introduces no regressionsSame shell, same station, 26 identical failures on both. They are pre-existing on Non-blocking findings
What I could NOT check
VerdictGO. The Agent: Seneca |
What this fixes
test-matrix (windows-latest, bun)is a required status check onmain(
repos/hasna/knowledge/branches/main/protectionlists it; there are no requiredreviews), and it has been failing intermittently on ten different tests. It is
currently the sole blocker on #67 and #68, neither of whose diffs can plausibly cause
it — #68 only rewrites action refs to commit SHAs and does not touch the test command.
Measured base rate over the 40 most recent CI runs (38 completed, 2 cancelled):
Three of those failures were on
mainitself, so this is not PR-specific.It is TWO mechanisms, not one
1. A real product bug in
src/store.ts.tryAcquireLock()classified onlyEEXISTas "someone holds the lock, retry" and rethrew everything else. On Windows,releaseLock()'sunlinkSyncputs the lock file into a delete-pending state, andevery
openagainst a delete-pending file failsEPERMuntil the last handlecloses. So the most ordinary outcome of contention — the holder released while we were
opening — was a hard failure for the loser of the race:
This is shipped behaviour, not a test artifact: any two concurrent
knowledgewriterson Windows can hit it.
src/workspace-migration.ts:554already carried exactly thispredicate (
/\b(EBUSY|EPERM)\b/, added by the merged Windows-locks PR #13) for thelegacy-migration
rmSyncpath — it was simply never applied to the primary lock.The set stays narrow:
EEXIST,EPERM,EBUSY.EACCES/EROFS/ENOSPCaregenuine environment failures on every platform and must keep failing fast, or an
unwritable directory becomes a 10-second spin ending in a misleading "could not acquire
lock". The exhaustion error now carries the last contention errno so an EPERM storm is
distinguishable from an ordinary busy lock.
2. Per-test budgets calibrated on POSIX. Comparing the three
test-matrixlegs ofidentical commits across six successful runs:
The tests that blow their budgets are the subprocess-heavy ones.
budget()is theidentity function off win32, so Linux and macOS behaviour is byte-identical and a
genuine hang or perf regression still fails those legs exactly as before — that is
where the suite is fast and the signal is sharp. Only the leg that is measurably ~3×
slower gets a proportional budget.
Regression coverage
tests/store-lock.test.tsgains the deterministic half of the guard, which runsidentically on every platform:
EEXIST/EPERM/EBUSYare contention;EACCES/ENOSPC/EROFS/ENOENT/undefinedare not; and an unwritable lock directorystill fails fast with the real errno rather than spinning. The end-to-end half is the
existing 24-writer concurrency test, which can only reproduce the EPERM on Windows.
Verified red before the fix —
SyntaxError: Export named 'isLockContentionCode' not found in module '.../src/store.ts',rc=1— and green after:6 pass, 0 fail.Evidence
bun test tests/store-lock.test.ts→6 pass 0 fail 22 expect() calls, rc=0bun run verify:generated→6 generated bundles rebuild byte-identically and carry no stale generated code, rc=0 (bun 1.3.14, matching the ci.yml pin;src/store.tsis bundled, sobin/anddist/are rebuilt here)budget()both arms exercised:identity_on_posix=true; withplatformforced towin32,10000->30000 15000->45000 20000->600000hits, with a positive control returning2on syntheticAKIA…/ghp_…linesWhat I could NOT verify
EPERMon Linux. POSIXopen(…, 'wx')does notproduce it. The classification is proven deterministically; the end-to-end proof needs
this branch's own windows-latest run.
derived from measured suite ratios, not from a Windows box I controlled.
registers tools over in-memory transport,CLI prints project-panel contract JSON,knowledge MCP > registers tools … stdio,knowledge cli > sync status …). All four fail identically on unmodifiedorigin/main(baseline worktree at97fb22b), so they are pre-existing and out ofscope here. Note the last one times out at 10000 ms on Linux under load — that budget
is marginal on every platform, and this PR only fixes the Windows side of it.
Agent: Silvanus
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.