Skip to content

Wait instead of crashing when Windows refuses a lock file that's being deleted - #682

Open
shhdwi wants to merge 1 commit into
mainfrom
fix/windows-lock-eperm
Open

shhdwi wants to merge 1 commit into
mainfrom
fix/windows-lock-eperm

Conversation

@shhdwi

@shhdwi shhdwi commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

On Windows, Trail's file lock could crash instead of waiting.

The lock works by creating a file like state/skills.json.lock. If the file already exists, someone else holds the lock, so we wait and try again. When the holder is done, it deletes the file.

On Windows, a file that is in the middle of being deleted can't be opened yet. Windows answers with EPERM (sometimes EBUSY), not "file exists". The lock code only knew how to wait on "file exists". Anything else was thrown, so a hook that happened to try at that moment crashed.

That is the Windows CI flake in "real processes writing skills.json and the ledger at once". It failed the first run of #680 and of #674 on 2026-10-23, and both passed when re-run. Real users on Windows can hit it too, whenever two sessions update skills at the same time.

The fix

In withLock (src/util/private-fs.ts), EPERM and EBUSY on the lock's open now count as "the lock is being let go":

  • It tries again, waiting 1, 2, 4, 8, then 10 ms between tries, until the usual deadline.
  • If the lock was seen held at some point and still isn't free by the deadline, it gives up as busy ({ ok: false }), the same as today when a lock is held too long.
  • If nothing ever held the lock and every try was refused, the folder itself is refusing us. That is a real permission problem, so the original error is still thrown, just after the wait instead of right away.

It doesn't try to take over a lock that is being deleted, since that lock isn't stale.

Tests

New tests in test/skills-state.test.ts fake the Windows error on the lock's open:

  • EPERM and EBUSY for the first few tries: the update waits, then counts the use once and removes the lock.
  • A lock that is held and keeps being refused: gives up as busy by the deadline, and the update never runs.
  • EPERM on every try with nothing holding the lock: the error is still thrown, after retrying.

All four fail on main and pass with this change. I ran the full suite on macOS with HOME and USERPROFILE pointed at a scratch folder.

Not in this PR

Two other Windows tests failed the same day: "cached 10 minutes by checkout and probe" (on #680) and "can't ask: dontAsk, bypassPermissions ... (D17)" (on a main build). Their logs show no lock error. Both are hooks running out of their 400 ms time budget on a slow runner (tests near them took about 20 s that run), so they need a separate fix.

🤖 Generated with Claude Code

On Windows, opening a lock file another process is deleting fails with
EPERM (or EBUSY) instead of EEXIST, so withLock threw and the hook
crashed. Treat those as a busy lock: retry with backoff until the
deadline, then give up as busy. If nothing ever held the lock, the
error is a real permission problem and is still thrown.

Fixes the Windows flake in "real processes writing skills.json and the
ledger at once".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trailhq-graft

trailhq-graft Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 4 areas can be affected. 59 dependent symbols, depth 2.
Tests: 1 area updated its tests.

flowchart TB
  A0(("Skill State Persistence<br/>36 symbols"))
  A1(("Initialization Handover<br/>9 symbols"))
  A2(("Trace Housekeeping<br/>7 symbols"))
  A3(("Claude Integration<br/>7 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Skill State Persistence 36 src/skills/lesson-gate.ts:L165-L172 markLessonReceiptUsed — calls, depth 1 File Lock Utility
Initialization Handover 9 src/init/init-record.ts:L354-L366 updateInitRecord — calls, depth 1 File Lock Utility
Trace Housekeeping 7 src/trace/housekeeping.ts:L70-L85 housekeeping — calls, depth 1 File Lock Utility
Claude Integration 7 src/claude/post-bash.ts:L168-L185 postToolOffer — calls, depth 2 File Lock Utility
All 59 dependent symbols, grouped by area

Skill State Persistence — 36 symbols in 13 files

  • src/skills/lesson-gate.ts:L165-L172 — markLessonReceiptUsed (calls, depth 1)
    167: withLock(path, () => {
  • src/skills/receipts.ts:L102-L109 — markReceiptUsed (calls, depth 1)
    104: withLock(path, () => {
  • src/skills/settings.ts:L69-L81 — update (calls, depth 1)
    71: const done = withLock(path, () => {
  • src/skills/state.ts:L95-L112 — updateSkillState (calls, depth 1)
    98: const done = withLock(path, () => {
  • src/skills/store-fs.ts:L84-L89 — tryStoreLock (calls, depth 1)
    88: return withLock(join(skillsDir(env), '.store'), fn, waitMs);
  • src/skills/update-gate.ts:L179-L186 — markUpdateReceiptUsed (calls, depth 1)
    181: withLock(path, () => {
  • src/skills/value-line.ts:L131-L146 — updateValue (calls, depth 1)
    135: const done = withLock(path, () => {
  • src/skills/lesson-gate.ts:L254-L317 — keepLessonCli (calls, depth 2)
  • src/skills/lesson-gate.ts:L186-L203 — lessonGatePre (calls, depth 2)
  • src/skills/lesson-gate.ts:L240-L251 — startWorked (calls, depth 2)
  • src/skills/offer-retire.ts:L47-L57 — noteFiring (calls, depth 2)
  • src/skills/save-gate.ts:L288-L362 — cliAdd (calls, depth 2)
  • src/skills/save-gate.ts:L71-L76 — saveGateFor (calls, depth 2)
  • src/skills/settings.ts:L158-L163 — noteGovernor (calls, depth 2)
  • src/skills/settings.ts:L138-L148 — noteSkillsStarted (calls, depth 2)
  • src/skills/settings.ts:L152-L156 — pauseRepo (calls, depth 2)
  • src/skills/settings.ts:L111-L122 — setOffers (calls, depth 2)
  • src/skills/settings.ts:L124-L129 — setSkillOff (calls, depth 2)
  • src/skills/settings.ts:L103-L108 — setSkills (calls, depth 2)
  • src/skills/staleness.ts:L92-L123 — cueFlush (calls, depth 2)
  • src/skills/staleness.ts:L150-L160 — markMissingSaid (calls, depth 2)
  • src/skills/staleness.ts:L135-L147 — markUnusedSaid (calls, depth 2)
  • src/skills/state.ts:L117-L124 — change (calls, depth 2)
  • src/skills/store-fs.ts:L167-L184 — startSkill (calls, depth 2)
  • src/skills/store-fs.ts:L75-L81 — underStoreLock (calls, depth 2)
  • src/skills/store-held.ts:L112-L118 — dropHeld (calls, depth 2)
  • src/skills/store-held.ts:L103-L110 — markHeldShown (calls, depth 2)
  • src/skills/store.ts:L252-L277 — moveStatus (calls, depth 2)
  • src/skills/update-gate.ts:L274-L344 — cliUpdate (calls, depth 2)
  • src/skills/update-gate.ts:L214-L228 — updateGate (calls, depth 2)
  • src/skills/value-line.ts:L177-L186 — markValueSaid (calls, depth 2)
  • src/skills/value-line.ts:L152-L164 — noteValue (calls, depth 2)
  • src/skills/versions.ts:L261-L308 — adoptIfCopied (calls, depth 2)
  • src/skills/versions.ts:L104-L106 — clearPending (calls, depth 2)
  • src/skills/versions.ts:L119-L199 — commitLocked (calls, depth 2)
  • src/skills/versions.ts:L228-L254 — writeProposal (calls, depth 2)

Initialization Handover — 9 symbols in 7 files

  • src/init/init-record.ts:L354-L366 — updateInitRecord (calls, depth 1)
    358: const done = withLock(path, () => {
  • src/init/keep.ts:L372-L385 — claimKeep (calls, depth 1)
    374: const done = withLock(path, () => {
  • src/init/candidates.ts:L207-L224 — seedNeverShown (calls, depth 2)
  • src/init/handover.ts:L55-L90 — initHandover (calls, depth 2)
  • src/init/keep.ts:L183-L251 — keepItems (calls, depth 2)
  • src/init/keep.ts:L326-L369 — keepCommand (calls, depth 2)
  • src/init/look-back-mine.ts:L118-L213 — lookBack (calls, depth 2)
  • src/init/miners.ts:L129-L129 — WallsAdd (references, depth 2)
  • src/init/run.ts:L183-L370 — finishLookBack (calls, depth 2)

Trace Housekeeping — 7 symbols in 5 files

  • src/trace/housekeeping.ts:L70-L85 — housekeeping (calls, depth 1)
    76: withLock(mark, () => {
  • src/trace/pool.ts:L76-L101 — notePoolDay (calls, depth 1)
    80: const done = withLock(path, () => {
  • src/trace/run-sheets.ts:L52-L57 — updateIndex (calls, depth 1)
    53: withLock(indexPath(env), () => {
  • src/trace/skill-session.ts:L215-L246 — withSkillSession (calls, depth 1)
    218: const done = withLock(path, () => {
  • src/trace/wall-state.ts:L217-L238 — noteWalls (calls, depth 1)
    225: const r = withLock(path, () => {
  • src/trace/run-sheets.ts:L110-L137 — pruneRunSheets (calls, depth 2)
  • src/trace/run-sheets.ts:L60-L95 — writeRunSheet (calls, depth 2)

Claude Integration — 7 symbols in 6 files

  • src/claude/post-bash.ts:L168-L185 — postToolOffer (calls, depth 2)
  • src/claude/prompt-skills.ts:L73-L109 — promptSkills (calls, depth 2)
  • src/claude/start-init.ts:L49-L65 — keptNotice (calls, depth 2)
  • src/claude/start-skills.ts:L76-L92 — sessionStartSkillsRun (calls, depth 2)
  • src/claude/stop-skills.ts:L220-L226 — notePool (calls, depth 2)
  • src/claude/stop-skills.ts:L52-L68 — stopSkillsRun (calls, depth 2)
  • src/claude/walls-hook.ts:L139-L194 — wallsAtStop (calls, depth 2)
Test signal per changed area — 1 ✓

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ File Lock Utility — 1 of 1 reached · 1 test file changed here: test/skills-state.test.ts
57 test suites also reference this code

76 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/claude-hooks-1b-review-trust.test.ts
  • test/claude-hooks-1b.test.ts
  • test/claude-journeys-2.test.ts
  • test/claude-journeys-3.test.ts
  • test/claude-journeys-update.test.ts
  • test/claude-post-bash-walls.test.ts
  • test/claude-start-skills-1b.test.ts
  • test/claude-stop-moments.test.ts
  • test/cli-1b.test.ts
  • test/cli-skills.test.ts
  • test/init-b6-review.test.ts
  • test/init-keep-cli-review.test.ts
  • test/init-keep-cli.test.ts
  • test/init-keep-gate-1b.test.ts
  • test/init-keep-shell-noise.test.ts
  • test/init-keep.test.ts
  • test/init-miners.test.ts
  • test/init-record.test.ts
  • test/init-relay-review.test.ts
  • test/skills-consent-fixtures.ts
  • …37 more

graft blast · origin/main...HEAD · depth 2 · 2 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Oct 23, 2026

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant