Skip to content

fix(lock): cover native Windows teardown denials - #92

Merged
steipete merged 7 commits into
mainfrom
fix/sidecar-lock-native
Aug 2, 2026
Merged

fix(lock): cover native Windows teardown denials#92
steipete merged 7 commits into
mainfrom
fix/sidecar-lock-native

Conversation

@steipete

@steipete steipete commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Builds directly on @Yigtwxx's #87 — their five commits are preserved verbatim in this branch's history, and the changelog credits them for the fix. This PR adds the native-path coverage that #87 was missing, so please land this one and close #87 as superseded rather than asking for a revision.

What #87 got right

On Windows, a lock file denied while its directory entry is still being torn down surfaces as EPERM (errno -4048) rather than EEXIST, so acquire() treated transient contention as a hard failure. @Yigtwxx classified that denial as contention at both touch points — the exclusive create and readSidecarLockSnapshot() — and re-entered the existing bounded retry loop. Their evidence was 8 failures in 85 runs before, 0 in 110 after.

The gap this closes

The fix only covered the JavaScript fallback. createNativeExclusiveFile() runs first whenever the binding is loaded, and the Windows binding mapped ERROR_ACCESS_DENIED to EACCES with no target pathname attached — so the EPERM-plus-exact-path predicate could never match, and packaged Windows installs still hit the original bug. Every test in #87 forces native mode off, which is why this was invisible.

Two changes fix it. The Rust mapper now reports ERROR_ACCESS_DENIED as EPERM, matching Node/libuv and the JavaScript fallback — until now the two paths reported different codes for the identical underlying condition, which was a latent inconsistency in its own right. And the TypeScript side attaches the target path only when binding.openBeneath() fails during exclusive creation; parent setup, payload, write, chmod/stat and unrelated native failures stay untagged, so the retry predicate stays exactly as narrow as it was.

This cannot fail open. A bare access-denied with no path provenance is still unclassifiable and still propagates, persistent denials stay bounded to eight retries, and the ninth surfaces the original EPERM rather than decaying into file_lock_timeout and losing the diagnosis.

Compatibility

win_error() is the global Windows error mapper, so this changes access-denied reporting for every native Windows operation, not just locks. That has its own changelog entry with migration guidance. Both in-package EACCES branches — directory-durability.ts and secure-temp-dir.ts — already accept EPERM alongside it, so nothing internal changes behavior.

Structure

@Yigtwxx flagged that src/sidecar-lock.ts sat at 498 lines against the 500-line budget and offered to split the module rather than raise the budget. Taking that offer: the acquisition state machine moved to sidecar-lock-acquire.ts, manager lifecycle stayed behind, and both files now sit comfortably inside the budget. The Windows denial tests split the same way. Both LINE_BUDGETS exceptions are removed rather than added to.

Proof

Windows CI is the live proof surface, since this behavior is Windows-only and no Windows host was available locally: run 30766942217 — all 15 jobs green, including Node 22/24 Windows, the Windows native crate/build/integration jobs, and the Windows bundled-package smoke. The hosted Windows tests inject the binding's real bare-error shape and exercise the actual binding on the succeeding attempt, covering both transient recovery and persistent-denial preservation.

Repeated local runs of the real suites on macOS: 50 passed / 0 failed before and after. pnpm check 645 passed / 32 platform-skipped, pnpm test:security 62 passed, git diff --check clean, Codex autoreview clean.

Not captured: an organic, uninstrumented occurrence of the kernel teardown race. The injected shape is the binding's real error shape, but the timing is deterministic rather than natural.

Yigtwxx and others added 7 commits August 2, 2026 21:54
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
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
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
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
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
Co-authored-by: Yigtwxx <yigiterdogan023@gmail.com>
@steipete
steipete requested a review from a team as a code owner August 2, 2026 21:11
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal priority bug or improvement with limited blast radius. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. labels Aug 2, 2026
@clawsweeper

clawsweeper Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codex review: needs maintainer review before merge. Reviewed August 2, 2026, 5:23 PM ET / 21:23 UTC.

ClawSweeper review

What this changes

This PR makes native Windows exclusive lock creation report EPERM with lock-file path provenance so sidecar-lock acquisition can retry the documented transient teardown denial without retrying unrelated permission failures.

Merge readiness

⚠️ Ready for maintainer review - 2 items remain

Keep this PR open: current main still lacks the native-binding path provenance needed to classify the Windows teardown denial as bounded lock contention. The patch preserves the fail-closed boundary by retrying only a Windows EPERM that names the lock file, and its Windows-native tests cover recovery and exhaustion behavior.

Priority: P2
Reviewed head: 9312d5d0252274b472a4f2cb16bf13d03ed587ee

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) This is a well-scoped, proof-backed Windows lock fix; the remaining review point is the intentional public native error-code compatibility change.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR provides targeted native-mode Windows runtime coverage and the supplied check data confirms successful Windows Node, native integration, and bundled-package smoke jobs; the deterministic injection represents the binding’s actual bare-error shape.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR provides targeted native-mode Windows runtime coverage and the supplied check data confirms successful Windows Node, native integration, and bundled-package smoke jobs; the deterministic injection represents the binding’s actual bare-error shape.
Evidence reviewed 6 items Current-main gap: Current main invokes the native exclusive open without a catch that can attach the full target path; therefore the existing exact-path contention predicate cannot recognize a bare native Windows denial.
Narrow retry boundary: The PR attaches targetPath only when native openBeneath() fails with Windows EPERM, and the acquisition loop retries only when that error names the exact lock path; other permission and setup errors continue to propagate.
Native-path regression coverage: The Windows-only native integration tests require the binding, inject its real bare-error shape on the first exclusive open, then exercise the actual binding on retry; a second case proves the original EPERM is retained after eight bounded denials.
Findings None None.
Security None None.

How this fits together

acquireFileLock() uses a sidecar lock file to serialize writes around a target path. It first tries the native filesystem binding when available, then applies lock contention and stale-lock policy before returning a held lock handle to the caller.

flowchart LR
  A[Caller requests file lock] --> B[Sidecar lock acquisition]
  B --> C[Native or JavaScript exclusive create]
  C --> D{Windows EPERM names lock file?}
  D -->|Yes| E[Bounded contention retry]
  D -->|No| F[Propagate original error]
  E --> G[Held lock handle]
  G --> H[Protected file operation]
Loading

Before merge

  • Resolve merge risk (P1) - The global native Windows mapper changes ERROR_ACCESS_DENIED from the previously native-only EACCES to EPERM; callers that compare only EACCES need the documented dual-code compatibility handling when supporting both package versions.
  • Complete next step (P2) - No discrete repair is indicated: the patch is coherent and has sufficient targeted Windows proof, leaving normal compatibility-aware maintainer review.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch surface 9 files affected; 624 added, 262 removed Most of the patch is a focused extraction of the acquisition state machine plus native-path regression coverage.
Windows native scenarios 2 added integration cases They cover successful retry after a bare binding error and preservation of the original denial after the bounded retry budget.

Root-cause cluster

Relationship: canonical
Canonical: #92
Summary: This PR preserves the retry work from the related PR and adds the missing native-binding path, making it the viable complete candidate for the same Windows lock-teardown defect.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge-risk options

Maintainer options:

  1. Accept the documented error-code alignment (recommended)
    Merge with the existing changelog and error-guide migration note, accepting that native Windows consumers must treat EACCES and EPERM equivalently across package versions.
  2. Keep the old native-only code
    Do not merge the global mapper change and instead redesign the lock-specific native failure classification, at the cost of preserving divergent Windows error semantics.

Technical review

Best possible solution:

Land the native-path retry with the documented EACCES/EPERM migration note, then close the narrower JavaScript-only predecessor at #87 as superseded once this PR merges.

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

Yes, at source level: the PR’s Windows-only native integration test injects the binding’s bare EPERM shape on exclusive create, requires native mode, and verifies both retry recovery and bounded-denial propagation. The natural kernel timing race was not independently run in this read-only review.

Is this the best way to solve the issue?

Yes. Attaching provenance at the native exclusive-open boundary and requiring an exact lock-file path is narrower and safer than broadening the retry loop to all Windows permission failures.

AGENTS.md: found and applied where relevant.

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

Labels

Label changes:

  • add P2: This fixes intermittent Windows lock-acquisition failures while preserving bounded retries and a limited platform-specific blast radius.
  • add merge-risk: 🚨 compatibility: The native Windows public error code for access-denied operations changes from EACCES to EPERM, requiring documented consumer compatibility handling.
  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR provides targeted native-mode Windows runtime coverage and the supplied check data confirms successful Windows Node, native integration, and bundled-package smoke jobs; the deterministic injection represents the binding’s actual bare-error shape.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR provides targeted native-mode Windows runtime coverage and the supplied check data confirms successful Windows Node, native integration, and bundled-package smoke jobs; the deterministic injection represents the binding’s actual bare-error shape.

Label justifications:

  • P2: This fixes intermittent Windows lock-acquisition failures while preserving bounded retries and a limited platform-specific blast radius.
  • merge-risk: 🚨 compatibility: The native Windows public error code for access-denied operations changes from EACCES to EPERM, requiring documented consumer compatibility handling.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR provides targeted native-mode Windows runtime coverage and the supplied check data confirms successful Windows Node, native integration, and bundled-package smoke jobs; the deterministic injection represents the binding’s actual bare-error shape.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR provides targeted native-mode Windows runtime coverage and the supplied check data confirms successful Windows Node, native integration, and bundled-package smoke jobs; the deterministic injection represents the binding’s actual bare-error shape.

Evidence

What I checked:

  • Current-main gap: Current main invokes the native exclusive open without a catch that can attach the full target path; therefore the existing exact-path contention predicate cannot recognize a bare native Windows denial. (src/native-operations.ts:104, 2477f5681f68)
  • Narrow retry boundary: The PR attaches targetPath only when native openBeneath() fails with Windows EPERM, and the acquisition loop retries only when that error names the exact lock path; other permission and setup errors continue to propagate. (src/native-operations.ts:112, 9312d5d02522)
  • Native-path regression coverage: The Windows-only native integration tests require the binding, inject its real bare-error shape on the first exclusive open, then exercise the actual binding on retry; a second case proves the original EPERM is retained after eight bounded denials. (test/native-integration.test.ts:156, 9312d5d02522)
  • Feature provenance: The central JavaScript retry logic originates in the preserved lock-retry commits, while the native coverage and package-facing error documentation were added in the two follow-up commits on this PR. (src/sidecar-lock-acquire.ts:221, 4600240cf98e)
  • Release and main status: The PR head is contained in neither a local release tag nor the checked-out main branch, so the central native-path fix is not yet shipped in v0.5.1 or present on current main. (CHANGELOG.md:4, 9312d5d02522)
  • Recorded Windows proof: The supplied PR context reports successful Windows Node 22/24 checks, native build/integration coverage, and bundled-package smoke on the PR head; the associated Windows jobs are all successful in the provided check data. (test/native-integration.test.ts:156, 9312d5d02522)

Likely related people:

  • Peter Steinberger: Current main attributes the surrounding sidecar-lock implementation to the v0.5.1 release commit, and this PR’s native binding and compatibility documentation follow-up commits are authored by Peter Steinberger. (role: recent area contributor; confidence: high; commits: 16e1bd489ae8, 4600240cf98e, 9312d5d02522; files: src/sidecar-lock.ts, src/native-operations.ts, native/src/windows.rs)
  • Yigtwxx: The preserved commit sequence introduces and tightens the lock-file-only Windows teardown retry, including fail-closed and retry-budget regressions that this PR extends to the native path. (role: introduced retry behavior; confidence: high; commits: 714dc37d8640, f02f9705230a, 3d0ffe4bd7fc; files: src/sidecar-lock-acquire.ts, src/sidecar-lock-policy.ts, test/sidecar-lock-windows-denial.test.ts)

Rank-up moves

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

  • Confirm that the documented EACCES to EPERM native Windows migration is acceptable for the next package release.

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.

@steipete
steipete merged commit b1132f9 into main Aug 2, 2026
26 of 27 checks passed
@steipete
steipete deleted the fix/sidecar-lock-native branch August 2, 2026 21:41
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. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants