fix(lock): never rename over a lock that was just released or is still being written (#760) - #761
Conversation
…l being written (Tencent#760) A stale verdict lets the reclaimer rename over the lock, and two live states read as stale: a file that vanished (released, and possibly re-created by a third process before the rename) and an empty file (its owner opened it but has not written it yet). lockState now tells live, stale and missing apart: a missing lock gets one more exclusive create, and an empty one reads as held until it has stayed empty for 5s (its owner died before writing it). 16 processes x 2000 attempts on one lock: 4-18 overlapping holders per run before, 0 after, with acquisitions in the same range.
…k users (Tencent#760) - process.kill(pid, 0) throwing EPERM means the owner is alive under another user (e.g. `sudo teamai`); it read as stale and was renamed over. - Test the missing branch under the reclaim sentinel, where the rename lives. - Comment and design doc state exactly what reads as stale (an unreadable lock still does); CHANGELOG lists every lock user.
|
Findings
Test Plan
|
An empty lock expired after 5s, so a creator stalled between open and write (SIGSTOP, sleep, slow I/O) could be taken over while it still held the lock. exclusiveCreate now writes the payload to a private temp file and hard-links it to the lock name (link fails with EEXIST like O_EXCL), so the lock never exists empty. Without hard links it falls back to O_EXCL, where the 5s grace still applies. The same create backs the reclaim sentinel.
|
Findings
Test Plan
|
… held (Tencent#760) - Without hard links the lock is created with O_EXCL and sits empty until written. A creator stalled past the 5s grace could be replaced and still report success; it now reads the lock back and holds it only if its own payload is there. - A lock that exists but cannot be read (EACCES: another user's 0600 lock) read as stale and was renamed over; it now reads as held. - Use fse.link like every other lock operation, and mock it in update.test.
…Tencent#760) - Without hard links, a reclaimer that read the lock empty could rename over it after the stalled creator had written and checked it. The creator now keeps the lock only if it wrote it within half the grace, so no reclaimer can have judged it stale; otherwise it gives it up. - An unreadable lock logs a warning naming the file. - Migration skips lock artifacts (<lock>.*.tmp, .sentinel, .new-*), which a contending pull creates and removes during the copy. - Stale comments; the stall tests restore their spies in afterEach.
|
Findings
Earlier Findings
Test Plan
|
…t#760) Each timing rule for locks that name no owner (empty, partly written) left an ordering where two processes held the lock: a partial payload read as stale at once, an older teamai stalled past the grace, a slow fallback creator yielded but kept blocking. Only ESRCH now makes a lock stale; a lock that names no owner, cannot be read, or has an EPERM owner is held, and a warning names it so a crash leftover can be removed by hand. Drops the 5s grace, the 2.5s creator limit and the read-back. - parseLockContent: a bare legacy PID is valid JSON (a number) and was returned as unparseable, which only worked while unparseable meant stale. - Migration skips only the real lock artifact formats, not every <lock>.*.
…encent#760) - "returns false when a live process holds the lock" failed on the temp write before any verdict ran; link now rejects with EEXIST on the lock path. - Remove Date.now/utimes staging the removed grace no longer reads, merge the duplicate empty-lock tests, restore the stall spies in finally. - Docstrings and design doc: only a lock that names no owner or cannot be read is warned about; the sentinel-steal residual is stated as it is.
|
Findings
Resolved
Test Plan
|
|
On the mixed-version P1 (no hard links, an older teamai reclaims this version's empty The ordering is real, but no code on this side closes mixed versions, with or without hard links. The older processes race among themselves (that is #760), and their
A directory lock stops the exact rename you describe (a file cannot replace a directory), but the older |
Summary
acquireLockrenamed over any lock it judged stale, and it judged live locks stale. Now only a lock whose owner is provably dead is renamed over.Type of Change
Evidence
16 processes × 2000 attempts on one lock, 3 runs:
main): 4 / 12 / 13 double holders · 13 / 16 / 20 renames over a live locklinkforced to fail,wxfallback): 0 / 0 / 0 · 0 / 0 / 0 (2123–2146 acquisitions)Tests, red on
main→ green:Green on both, kept as guards: a lock released between create and read is taken (not "busy"); without hard links the lock still works (O_EXCL fallback, no temp file left).
Stress harness (per worker; 16 run in parallel on one lock path)
Run with
vite-node worker.ts <dir> 2000. "Renames over a live lock" counts, atfse.renameonto the lock, targets whose pid is alive and not the caller's.Test Plan
All run on
4d9ebaa;6740c9conly changes tests, comments and docs (unit suite re-run: 4313 passed).npx tsc --noEmitpassesnpx vitest runpasses (4313 passed, 1 skipped)npm run test:e2e(225 passed, 26 skipped)gitprovider: 8 concurrentteamai pullon one scope → 1 syncs, 7 skip, usage reported once (the race does not show at this scale onmaineither; this checks nothing regressed)Related Issues
Fixes #760
Merge Danger
Door: two-way
Blast Radius: locking
Every
pull,push, reports/learnings worktree write, learnings publish, migration, self-mode bootstrap and update check goes through this lock. A contender now sees busy where it used to take over a live lock. The trade-off: a lock that names no owner (empty, partly written, garbage, unreadable) is never reclaimed, so one left by a crash blockspull/pushuntil removed by hand; a warning names the file. With hard links a live teamai never exposes such a lock; one appears only after a crash or power loss mid-create (the temp file is not fsynced beforelink), or a crash betweenopenandwritewithout hard links. Mixed versions (an older teamai still running, e.g. mid auto-update) keep a residual race that no code on this side closes: the older processes race among themselves (#760 itself), and theirreleaseLockdeletes a lock it cannot read without checking the owner. 8main+ 8 new processes on one lock, overlaps involving a new process in 3 runs: 1 / 0 / 0 on thelinkpath, 2 / 5 / 3 on thewxfallback. A directory-lock fallback (an older teamai cannot rename a file over a directory) measured the same, 4 / 4 / 2, because the olderreleaseLockdeletes the directory; not adopted. It disappears once every process runs this version.Notes for Reviewers
linkfailing for any reason other thanEEXISTfalls back to the previouswxcreate, so filesystems without hard links keep working as before.update.test.tspinned the old syscall (wxwrite on the lock,removenever called); they now assert the same contract throughlink(created on the lock path,EEXIST→ reclaim by rename, the lock itself not removed,wxonly whenlinkfails).mainflips on purpose: a garbage lock is no longer reclaimed.parseLockContentreturned a bare legacy PID (999999, valid JSON) as unparseable; that only worked while unparseable meant stale. Fixed..teamai/(the.gitignorelists lock names exactly); the reclaim's.sentinel/.new-*files already could. Not changed here.