Skip to content

fix(lock): retry Windows lock files denied mid-teardown - #87

Closed
Yigtwxx wants to merge 5 commits into
openclaw:mainfrom
Yigtwxx:fix/lock-windows-transient-denial
Closed

fix(lock): retry Windows lock files denied mid-teardown#87
Yigtwxx wants to merge 5 commits into
openclaw:mainfrom
Yigtwxx:fix/lock-windows-transient-denial

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where consumers taking a contended file lock on Windows would see acquireFileLock() reject with EPERM when the previous holder released the lock at the same moment. The affected surface is the sidecar file lock (acquireFileLock, withFileLock, createFileLockManager).

Windows denies access to a lock file while a just-unlinked directory entry is still being torn down. In that window the name is already gone but a fresh exclusive create is refused with ERROR_ACCESS_DENIED, which libuv reports as EPERM (errno -4048). The next attempt succeeds, so this is contention, not a permission failure.

acquire() treated only EEXIST as contention, so the transient EPERM escaped from two touch points:

  1. the exclusive create, fs.open(lockPath, "wx")
  2. readSidecarLockSnapshot(), which maps only ENOENT to a vanished lock

Instrumenting the failure showed the state directly — at the moment of the EPERM, the lock file is already gone and a zero-delay retry opens it:

EPERM-DIAG {"attempt":2,"lstat":"ENOENT","siblings":["state.json"],"retryAfterMs":0}

Why This Change Was Made

Both touch points now classify a Windows EPERM on the lock file as contention and re-enter the existing retry loop. The retry budget is bounded (8 denials), so a genuine permission denial still surfaces as EPERM instead of degrading into a file_lock_timeout and losing the diagnosis.

The classification is gated to win32, matching the equivalence this repository already applies elsewhere — isPermissionRenameError in src/replace-file.ts, renameJsonFileWithFallback in src/json.ts, and windows-rename-denied in src/move-path.ts. src/sidecar-lock.ts was the outlier. No public API, error shape, or POSIX behaviour changes.

src/sidecar-lock.ts sat at 498 lines against the default 500-line budget, so the predicate lives in src/sidecar-lock-policy.ts and the file gets a LINE_BUDGETS entry. Happy to split the module instead if you would rather not raise the budget.

User Impact

Concurrent lock acquisition on Windows no longer fails intermittently when one holder releases while another is waiting. Consumers on POSIX are unaffected. Nothing is added to or removed from the public API.

Evidence

Two regression tests inject the transient denial at each touch point and assert the acquire still completes. Both fail on main and pass with this change:

FAIL  test/sidecar-lock-regression.test.ts > retries an exclusive create denied mid-teardown
FAIL  test/sidecar-lock-regression.test.ts > retries a contended snapshot read denied mid-teardown
Tests  2 failed | 11 passed (13)

Repeated runs of the real suites on Windows 11 (Node 22), test/file-lock-reentrancy.test.ts plus test/new-primitives.test.ts:

runs failures
before 85 8 (~9%)
after 110 0

The rate before the fix moves with machine load, so a single green run does not demonstrate anything here — the loop does.

Full local verification: vitest run 489 passed / 183 skipped, tsc --noEmit clean, scripts/check-file-size.mjs and scripts/check-fs-boundary-primitives.mjs both exit 0.

Note on the flake reports

This is the defect behind the file-lock-reentrancy and new-primitives > file locks failures that have been rotating across Windows legs. src/sidecar-lock.ts last changed in #65, so it predates the #79 dependency refresh — that bump only shifted timing enough to expose an existing race.

It does not explain move-path-regression.test.ts > publishes a fresh inode when hardlink rejection is enabled, which is a different symptom and is still open.

  • Tests added or updated when behavior changed
  • Security and compatibility impact considered
  • CHANGELOG.md updated when release-relevant
  • No credentials, private paths, private hosts, or sensitive contents included

Windows denies access to a lock file while a just-unlinked directory entry
is still being torn down, so a contended acquire observed EPERM on a name
that was already gone. At the failure lstat reports ENOENT and a zero-delay
retry opens the file, which makes this contention rather than a permission
failure.

acquire() only treated EEXIST as contention, so the transient EPERM escaped
from two touch points: the exclusive create, and readSidecarLockSnapshot,
which maps only ENOENT to a vanished lock. The repository already applies
this Windows equivalence in replace-file.ts, json.ts, and move-path.ts;
sidecar-lock.ts was the outlier.

Retries are bounded so a genuine denial still surfaces as EPERM instead of
degrading into a lock timeout.

Locally this failed 8 times in 85 runs before the change and 0 times in 110
runs after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGksu4AaxVTcTHZntKTyAs
@Yigtwxx
Yigtwxx requested a review from a team as a code owner August 2, 2026 18:55
Both new tests held the raw mkdtemp() result and matched the injected path
by string equality. Windows runners return an 8.3 short name from
os.tmpdir() while acquire() realpaths the target, so the injection never
matched the path the lock code used. The create case then saw zero
injections and the read case fell through to the real snapshot, which read
as a live lock and timed out.

Follow the suite convention and realpath the root. Verified locally by
pointing the same scenario at a directory junction, which reproduces the
CI mismatch: the raw path records zero injections and the resolved path
records one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGksu4AaxVTcTHZntKTyAs
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. P2 Normal priority bug or improvement with limited blast radius. labels Aug 2, 2026
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

CI status on this branch: 22 pass, 1 fail, and the one red leg is not from this diff.

Node 22 check (windows-latest) timed out after 15s in new-primitives.test.ts > secure file reads > reads from a validated Windows ACL and owner. Node 24 windows-latest passed on the identical commit.

That test is not reachable from this change — the diff touches src/sidecar-lock.ts and src/sidecar-lock-policy.ts, and neither readSecureFile, inspectPathPermissions, nor the owner/DACL path takes a file lock. It is also a repeat: the same test failed on #82 and #85.

Worth noting what did change: the two failures this PR is about — file-lock-reentrancy.test.ts and new-primitives.test.ts > file locks — passed on every Windows leg here, including the Node 22 leg that went red for the unrelated ACL timeout.

The remaining ACL failure looks like a different defect (a 15s timeout rather than an EPERM), so I have left it out of this PR rather than widening the scope. Happy to chase it separately if useful.

The first run of this branch did have two genuine failures of my own, both in the new tests — they held the raw mkdtemp() result while acquire() realpaths the target, so on a Windows runner's 8.3 short name the injected path never matched. Fixed in 0dcf0c4, verified locally against a directory junction that reproduces the same mismatch.

@clawsweeper clawsweeper Bot added merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. labels Aug 2, 2026
@clawsweeper

clawsweeper Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codex review: needs real behavior proof before merge. Reviewed August 2, 2026, 5:09 PM ET / 21:09 UTC.

ClawSweeper review

What this changes

This PR retries a transient Windows EPERM during sidecar lock-file creation or holder-snapshot reads, while preserving unrelated permission errors and retry-limit diagnostics.

Merge readiness

Blocked until stronger real behavior proof is added - 5 items remain

This PR is not ready to merge: its narrowed JavaScript fallback handling preserves fail-closed behavior, but the binding-enabled Windows exclusive-create path used by packaged installs cannot satisfy the new lock-file-path predicate and is neither covered by the regressions nor demonstrated by runtime proof. The existing P1 remains unresolved.

Priority: P1
Reviewed head: 46038173f1cccafeec4d46cb37c483d8ecaef7c5

Review scores

Measure Result What it means
Overall readiness 🦪 silver shellfish (2/6) The patch has thoughtful fallback safeguards and green hosted checks, but a P1 native-path gap and insufficient production-path proof prevent merge readiness.
Proof confidence 🦪 silver shellfish (2/6) Needs stronger real behavior proof before merge: The branch provides credible repeated Windows fallback evidence, but its injected regressions explicitly disable native mode and therefore do not show the binding-enabled route used by packaged Windows installs. Add redacted binding-enabled terminal output or a focused regression, without exposing private paths or host data. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment @clawsweeper re-review.
Patch quality 🦐 gold shrimp (3/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Needs proof Needs stronger real behavior proof before merge: The branch provides credible repeated Windows fallback evidence, but its injected regressions explicitly disable native mode and therefore do not show the binding-enabled route used by packaged Windows installs. Add redacted binding-enabled terminal output or a focused regression, without exposing private paths or host data. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment @clawsweeper re-review.
Evidence reviewed 5 items Current main ownership: Current main creates sidecar locks through createNativeExclusiveFile(...) ?? fs.open(..., "wx"); the relevant current implementation is attributed to Peter Steinberger in the v0.5.1 release commit.
Native route lacks the required pathname: The binding-enabled route opens the parent directory and calls native openBeneath; its N-API error conversion throws a status/reason error and does not attach Node's path property. The PR retries only when denial.path === lockPath, so a transient native exclusive-create denial does not enter the new retry path.
Patch discriminator excludes native errors: The proposed create catch records a retryable denial only through isTransientLockFileDenial; that predicate requires a Windows EPERM whose path exactly matches the lock path.
Findings 1 actionable finding [P1] Cover the native Windows exclusive-create path
Security None None.

How this fits together

The sidecar lock manager coordinates concurrent writes by creating a .lock file next to a target and inspecting an existing holder when contention occurs. It receives a target path and retry policy, then either returns an owned lock or propagates a filesystem/timeout error to the caller.

flowchart TD
  A[Caller requests a file lock] --> B[Sidecar lock manager]
  B --> C[Exclusive sidecar lock creation]
  C --> D{Created, contended, or denied?}
  D -->|Created| E[Return owned lock]
  D -->|Contended| F[Read holder snapshot]
  F --> G[Retry policy and deadline]
  G --> B
  D -->|Unexpected denial| H[Propagate fail-closed error]
Loading

Before merge

  • Add real behavior proof - Needs stronger real behavior proof before merge: The branch provides credible repeated Windows fallback evidence, but its injected regressions explicitly disable native mode and therefore do not show the binding-enabled route used by packaged Windows installs. Add redacted binding-enabled terminal output or a focused regression, without exposing private paths or host data. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment @clawsweeper re-review.
  • Cover the native Windows exclusive-create path (P1) - The new create classifier at this line only retries errors with path === lockPath, but createNativeExclusiveFile() delegates to N-API openBeneath, whose error conversion supplies a status/reason rather than a Node path. Consequently binding-enabled Windows installs bypass this repair even though the PR presents it as a general lock-acquisition fix. Preserve fail-closed handling for parent failures, but add a narrow native-operation marker plus a binding-enabled regression or runtime proof.
  • Resolve merge risk (P1) - Packaged Windows installs with the native binding can continue to surface the intermittent exclusive-create EPERM, because native errors lack the lock-path metadata required by the new classifier.
  • Resolve merge risk (P1) - Broadening retry behavior without tagging the native exclusive-create operation could reintroduce retries for parent-directory or other permission failures that must remain fail-closed.
  • Complete next step (P1) - A narrow mechanical repair is available: preserve fail-closed propagation while making native exclusive-create failures distinguishable and proving the actual Windows binding route.

Findings

  • [P1] Cover the native Windows exclusive-create path — src/sidecar-lock.ts:325-330
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch surface 5 files affected; 209 additions, 5 deletions The branch changes lock acquisition, policy, regressions, changelog, and line-budget allowlists.
Native coverage 0 new binding-enabled denial regressions Both injected Windows denial cases force native mode off despite the default packaged route preferring the native binding.

Merge-risk options

Maintainer options:

  1. Prove and repair the native route (recommended)
    Tag only a native exclusive-create failure at the helper boundary, add a binding-enabled Windows regression or redacted live proof, and retain propagation for unclassified errors.
  2. Narrow the PR to the fallback route
    State explicitly that the change only repairs JavaScript fallback lock acquisition and remove the broader packaged-Windows claim if maintainers do not want native-path work here.
  3. Pause pending native-path evidence
    Do not merge until the production native route is either proven unaffected or covered by an equally narrow retry discriminator.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Preserve fail-closed propagation for all unclassified errors; add binding-enabled Windows coverage for the native exclusive-create path and the relevant transient lock-file denial.

Technical review

Best possible solution:

Keep unrelated native errors fail-closed, but make the native exclusive-create operation distinguishable at its boundary and add binding-enabled Windows evidence that the exact teardown denial retries without reclassifying parent-directory or caller errors.

Do we have a high-confidence way to reproduce the issue?

Yes for the gap in this PR: source proves that binding-enabled exclusive creation reaches openBeneath, while the new predicate requires a Node-style path property the native N-API error path does not provide. The reported teardown race itself has real Windows evidence, but no binding-enabled reproduction artifact.

Is this the best way to solve the issue?

No. The scoped fallback classifier is appropriately fail-closed, but it does not solve the production native exclusive-create route; the native operation needs a narrow provenance signal plus a focused Windows regression or live proof.

Full review comments:

  • [P1] Cover the native Windows exclusive-create path — src/sidecar-lock.ts:325-330
    The new create classifier at this line only retries errors with path === lockPath, but createNativeExclusiveFile() delegates to N-API openBeneath, whose error conversion supplies a status/reason rather than a Node path. Consequently binding-enabled Windows installs bypass this repair even though the PR presents it as a general lock-acquisition fix. Preserve fail-closed handling for parent failures, but add a narrow native-operation marker plus a binding-enabled regression or runtime proof.
    Confidence: 0.96

Overall correctness: patch is incorrect
Overall confidence: 0.96

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 2477f5681f68.

Labels

Label justifications:

  • P1: The reported intermittent failure affects a core Windows lock-acquisition workflow, and the branch leaves the packaged native route unproven and likely unfixed.
  • merge-risk: 🚨 security-boundary: The change alters permission-error classification inside a filesystem coordination boundary where overly broad retries would weaken fail-closed behavior.
  • merge-risk: 🚨 compatibility: Lock callers depend on existing retry limits and on permission failures retaining their original error identity.
  • rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦪 silver shellfish and patch quality is 🦐 gold shrimp.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs stronger real behavior proof before merge: The branch provides credible repeated Windows fallback evidence, but its injected regressions explicitly disable native mode and therefore do not show the binding-enabled route used by packaged Windows installs. Add redacted binding-enabled terminal output or a focused regression, without exposing private paths or host data. After adding proof, update the PR body; ClawSweeper should re-review automatically. If it does not, the PR author or someone with repository write access can comment @clawsweeper re-review.

Evidence

Acceptance criteria:

  • [P1] Run the focused sidecar lock regression test with native mode enabled on Windows.
  • [P1] Run the relevant file-lock reentrancy and new-primitives lock suites on Windows.
  • [P1] Run pnpm check and git diff --check before handoff.

What I checked:

  • Current main ownership: Current main creates sidecar locks through createNativeExclusiveFile(...) ?? fs.open(..., "wx"); the relevant current implementation is attributed to Peter Steinberger in the v0.5.1 release commit. (src/sidecar-lock.ts:308, 16e1bd489ae8)
  • Native route lacks the required pathname: The binding-enabled route opens the parent directory and calls native openBeneath; its N-API error conversion throws a status/reason error and does not attach Node's path property. The PR retries only when denial.path === lockPath, so a transient native exclusive-create denial does not enter the new retry path. (src/native-operations.ts:96, 16e1bd489ae8)
  • Patch discriminator excludes native errors: The proposed create catch records a retryable denial only through isTransientLockFileDenial; that predicate requires a Windows EPERM whose path exactly matches the lock path. (src/sidecar-lock.ts:325, 46038173f1cc)
  • Regression scope: The newly added create and snapshot-denial tests explicitly force configureFsSafeNative({ mode: "off" }), so they validate only the JavaScript fallback. The PR discussion also acknowledges that native-enabled behavior is not covered. (test/sidecar-lock-regression.test.ts:124, 46038173f1cc)
  • Current-main and release check: The PR head is not an ancestor of current main, while current main is based on the v0.5.1 release line; this behavior is not implemented or shipped on main. (src/sidecar-lock.ts:308, 2477f5681f68)

Likely related people:

  • Peter Steinberger: Git blame attributes the current sidecar acquisition and native exclusive-create integration to the v0.5.1 release commit; the repository's available history is grafted at that release boundary. (role: current implementation author and recent area contributor; confidence: high; commits: 16e1bd489ae8; files: src/sidecar-lock.ts, src/native-operations.ts)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Add a focused binding-enabled Windows regression or redacted terminal proof for native exclusive creation.
  • Ensure the native discriminator cannot classify a parent-directory, payload, write, or other unrelated EPERM as contention.
  • After updating the PR body, request @clawsweeper re-review if a fresh review does not start automatically.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (5 earlier review cycles)
  • reviewed 2026-08-02T19:03:38.430Z sha 0dcf0c4 :: needs changes before merge. :: [P1] Limit retries to the two lock-file I/O failures
  • reviewed 2026-08-02T19:23:38.850Z sha f02f970 :: needs changes before merge. :: [P1] Do not retry native parent-directory permission failures
  • reviewed 2026-08-02T19:37:00.970Z sha 3d0ffe4 :: needs changes before merge. :: [P1] Preserve EPERM when no retry is permitted
  • reviewed 2026-08-02T19:46:51.576Z sha 4603817 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-02T19:50:42.617Z sha 4603817 :: needs real behavior proof before merge. :: [P1] Cover the native Windows exclusive-create path

The classification sat in the catch that wraps the whole acquisition body,
which also covers options.payload(), lockRoot.create/open, the payload
write, and the stat. A Windows EPERM raised by any of those was reclassified
as contention, so the loop reran the caller's payload callback and, once the
retry budget was spent, replaced the caller's error with file_lock_timeout.
That is the opposite of the fail-closed contract.

Classify at the two operations the evidence actually covers: the direct
exclusive create and the holder snapshot read. Everything else propagates
unchanged. The loop now only owns the retry budget.

Adds a regression asserting that an EPERM thrown by the caller's payload
reaches the caller as EPERM and that the callback runs exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGksu4AaxVTcTHZntKTyAs
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

The P1 is correct. f02f970 narrows it.

The classification sat in the catch wrapping the whole acquisition body, which also covers options.payload(), lockRoot.create/open(), the payload write and the stat. A Windows EPERM from any of those was reclassified as contention — the loop reran the caller's payload callback, and once the retry budget was spent waitForRetry() replaced the caller's error with file_lock_timeout. For a fail-closed boundary that is the wrong direction, and my evidence never covered those paths.

Classification now happens at the two operations the evidence does cover:

  • the direct exclusive create, via a try around createNativeExclusiveFile() ?? fs.open(lockPath, "wx") that records lockFileCreateDenied and rethrows
  • the holder snapshot read, which was already in its own try

The loop only owns the budget now (withinDenialBudget()), so nothing else in the acquisition body can be reclassified. The lockRoot branch is deliberately left alone — it routes through Root and raises FsSafeError, and I have no evidence of the teardown window there.

Added the regression you asked for: an EPERM thrown by the caller's payload with retry: { retries: 0 } now rejects as EPERM rather than file_lock_timeout, and the callback is asserted to run exactly once. It fails on the previous commit and passes on this one.

Re-verified after the narrowing rather than assuming it held: test/file-lock-reentrancy.test.ts + test/new-primitives.test.ts + test/sidecar-lock-regression.test.ts, 0 failures in 60 runs on Windows 11 / Node 22. Full local vitest run is 490 passed / 183 skipped, tsc --noEmit clean, both lint scripts exit 0.

On the P1 merge risk about the hosted Windows check: that failure was new-primitives.test.ts > reads from a validated Windows ACL and owner timing out at 15s, which is not reachable from this diff — no lock is taken anywhere in readSecureFile, inspectPathPermissions, or the owner/DACL path. It is a separate spawn-budget problem and I opened #88 for it with the measurements. The two tests this PR is about passed on every Windows leg here, including the leg that went red for that timeout.

@clawsweeper clawsweeper Bot added P1 Urgent regression or broken agent/channel workflow affecting real users now. and removed P2 Normal priority bug or improvement with limited blast radius. labels Aug 2, 2026
createNativeExclusiveFile() opens dirname(lockPath) before its native
exclusive open, so a denial raised by that setup step was still classified
as the teardown window. The outer loop then retried it and reran
options.payload(), which is the same fail-closed violation as before, one
level further in.

Require the EPERM to name the lock file itself. The observed failures carry
path set to the lock path, from fs.open(lockPath, "wx") and from the
snapshot read, so the evidence maps exactly onto that condition. A parent
directory denial names the directory and now propagates untouched, as does
any native helper error that identifies no path.

Adds a regression asserting that a denial naming the lock parent rejects
with the original EPERM and runs the payload callback exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGksu4AaxVTcTHZntKTyAs
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Also correct. 3d0ffe4.

createNativeExclusiveFile() opens dirname(lockPath) before its native exclusive open, so a denial from that setup step was still landing inside the classified try — the same fail-closed violation as the first finding, one level further in.

The classification now requires the error to name the lock file:

export function isTransientLockFileDenial(error: unknown, lockPath: string): boolean {
  const denial = error as NodeJS.ErrnoException | null;
  return process.platform === "win32" && denial?.code === "EPERM" && denial.path === lockPath;
}

That maps onto the evidence rather than onto a code path. Every observed failure carries path set to the lock file — the CI stack was { errno: -4048, code: 'EPERM', syscall: 'open', path: '...\state.json.lock' }, from fs.open(lockPath, "wx"), and the snapshot read reports the same. A parent-directory denial names the directory and propagates untouched.

On covering the native-enabled case: a native openBeneath failure that identifies no path is not retried. I would rather leave that propagating than widen the rule past what I can demonstrate — I have no evidence the teardown window is reachable through the native create, and failing closed is the right default on this boundary. If you want it covered, the honest way is to tag the error inside createNativeExclusiveFile so the create step is distinguishable from its setup, and I am happy to do that as a follow-up with a native-enabled reproduction behind it.

New regression: a denial naming the lock parent rejects with the original EPERM and runs the payload callback exactly once. It fails on f02f970 and passes here. That is alongside the earlier payload-EPERM regression, so both escape routes out of the classified block are now pinned.

Re-ran the race after the change rather than assuming the discriminator still matched: file-lock-reentrancy + new-primitives + sidecar-lock-regression, 0 failures in 60 runs on Windows 11 / Node 22. Full local vitest run 491 passed / 183 skipped, tsc --noEmit clean, both lint scripts exit 0. The previous head also went fully green on the hosted matrix (21 checks, no failures), including every Windows leg.

withinDenialBudget() only bounded the denial count, not the caller's retry
and timeout limits. A lock-file EPERM still went through waitForRetry(),
which throws file_lock_timeout under retries: 0 or an elapsed deadline, so
the caller lost the permission diagnosis on exactly the settings that ask
to fail fast. The PR claimed bounded denials preserved EPERM; they did not.

Hand the original denial back when waiting cannot schedule another attempt.
Any other waitForRetry() failure still propagates as itself, and both the
create and snapshot-read branches route through the same helper.

Adds a regression pinning EPERM with retries: 0 for the create branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GGksu4AaxVTcTHZntKTyAs
@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Correct again, and this one contradicted a claim in my own PR body. 4603817.

withinDenialBudget() bounded only the denial count. The denial still went through waitForRetry(), which throws file_lock_timeout under retries: 0 or an elapsed deadline — so on exactly the settings that ask to fail fast, the caller lost the permission diagnosis. I wrote that bounded denials keep EPERM surfacing; they did not.

The wait now hands the original denial back when no further attempt can be scheduled:

const retryOrRethrowDenial = async (denial: unknown): Promise<void> => {
  try {
    await waitForRetry();
  } catch (waitError) {
    if ((waitError as NodeJS.ErrnoException).code === "file_lock_timeout") throw denial;
    throw waitError;
  }
};

Both the create and snapshot-read branches route through it, and any other waitForRetry() failure still propagates as itself.

New regression: a lock-file EPERM with retry: { retries: 0 } rejects as EPERM with path set to the lock file. It fails on 3d0ffe4 with code: 'file_lock_timeout' and passes here. That is the fourth case pinned on this loop, alongside the payload denial, the parent denial, and the two teardown retries.

Re-ran the race again rather than assuming: 0 failures in 60 runs on Windows 11 / Node 22 across file-lock-reentrancy, new-primitives and sidecar-lock-regression. Full local vitest run 492 passed / 183 skipped, tsc --noEmit clean, both lint scripts exit 0. 3d0ffe4 was also fully green on the hosted matrix, 21 checks with no failures.

One thing worth your judgement rather than mine: the line-budget entries have crept across these rounds — src/sidecar-lock.ts to 540 and now a new entry for test/sidecar-lock-regression.test.ts at 540. Each round added a guard the review asked for, but the net effect is two raised budgets in scripts/check-file-size.mjs. If you would rather see the acquisition loop or the regression file split than the budgets raised, say which and I will do that instead of carrying the allowlist entries.

@Yigtwxx

Yigtwxx commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

The open P1 (Preserve EPERM when no retry is permitted) was addressed in 4603817; the last review is against 3d0ffe4. The hosted matrix is green on the current head — 21 checks, no failures.

@clawsweeper

clawsweeper Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: when the review finishes, ClawSweeper will create the durable review comment if needed or update the existing comment in place.

Re-review progress:

@clawsweeper clawsweeper Bot added status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed proof: sufficient Contributor real behavior proof is sufficient. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Aug 2, 2026
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Aug 2, 2026
@steipete steipete closed this in #92 Aug 2, 2026
@steipete

steipete commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Landed as b1132f9 via #92 — thank you, this was a good catch and the evidence was excellent.

Your diagnosis was right and your five commits are preserved verbatim in the merged history, with the changelog crediting you. I closed the one remaining gap myself rather than sending it back: createNativeExclusiveFile() runs before the "wx" fallback whenever the binding is loaded, and the Windows binding mapped ERROR_ACCESS_DENIED to EACCES without a pathname, so your EPERM-plus-exact-path predicate could never match on packaged Windows installs. Since every test here forces native mode off, it stayed invisible.

The Rust mapper now reports ERROR_ACCESS_DENIED as EPERM, which also fixes a latent inconsistency you had already half-surfaced — the native path and the JavaScript fallback were reporting different codes for the identical condition. The target path is attached only on the openBeneath() exclusive-create failure, so the predicate stays exactly as narrow as you wrote it and unpathed denials remain unclassifiable.

I also took you up on the module split instead of raising LINE_BUDGETS. Both budget exceptions are now gone rather than added to.

The 8-in-85 versus 0-in-110 measurement was the thing that made this reviewable — a single green run would have proved nothing for a race this shape. Thanks for doing it properly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. P1 Urgent regression or broken agent/channel workflow affecting real users now. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants