fix: publish and recover multiscan locks atomically - #196
Conversation
(cherry picked from commit f6e3033)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8cdc1f6880
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (recovering !== null && processIsRunning(recovering.pid)) { | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } | ||
| await rm(recovery, { force: true }); |
There was a problem hiding this comment.
Reclaim stale recovery markers without unlinking successors
When a crashed .lock.recovery exists and multiple supervisors restart concurrently, two contenders can both read the old owner; after one removes it and publishes its own recovery claim, the delayed contender can execute this unconditional removal against the new claim. Both can then enter recovery and replace .lock, allowing overlapping campaigns. Move the observed marker aside and verify its token before deleting it, as is done for lock release.
Useful? React with 👍 / 👎.
| await releaseLock(path, token); | ||
| throw new Error("A multiscan supervisor is already running."); | ||
| } | ||
| return async () => await releaseLock(path, token); |
There was a problem hiding this comment.
Revalidate ownership before accepting a published lock
During legacy-directory recovery, there is a gap between moving the directory at line 337 and installing the recoverer's lock at line 339. A contender can hard-link its pending file into .lock during that gap, but if the recovery marker is released between its lstat and readFile, readLockOwner(recovery) returns null and this return admits it even though the recoverer has already replaced its lock. Confirm that .lock still contains this contender's token before returning, otherwise both supervisors can run concurrently.
Useful? React with 👍 / 👎.
| try { | ||
| const moved = await readLockOwner(released); | ||
| if (moved?.token !== token) { | ||
| await link(released, path); |
There was a problem hiding this comment.
Restore substituted legacy locks without hard-linking directories
If a legacy directory lock is substituted after the ownership check at line 426 but before the rename at line 431, this code moves that competing lock aside and then attempts to restore it with link. Hard-linking a directory fails on supported POSIX filesystems, and the non-recursive cleanup also cannot remove it, leaving the competitor's ownership stranded under .lock.released-* while .lock is vacant and allowing another supervisor to start.
Useful? React with 👍 / 👎.
|
Security review completed. No security issues were found in this pull request. Reviewed commit: Only the user who started this review can view the report in Codex. ℹ️ About Codex security reviews in GitHubThis is an experimental Codex feature. Security reviews are triggered when:
Once complete, Codex will leave suggestions, or a comment if no findings are found. |
Summary
Closes #102.
Why
The original two-step directory lock could permanently block a bulk scan after an interrupted owner write. PR #98 fixes that publication gap, but adversarial QA found that its stale-recovery path could admit several supervisors at once, overwrite a competing lock, accept unusable PID values, and leak pending files on I/O failure.
This replacement keeps the contributor's atomic hard-link publication and adds a separate, atomically published recovery claim. Stale regular locks are replaced without ever vacating the lock path; legacy directories are migrated only while a recovery claim makes competing acquisitions fail closed. Ownership release moves a candidate aside, verifies its token, and restores a replacement without clobbering it.
Regression coverage
fsyncfailure.Verification
pnpm run typespnpm run formatpnpm run buildpnpm pack --pack-destination <temporary-directory>andpnpm run check:package <tarball>— installed-package import, CLI, and all 95 bundled plugin files verified.bun test --timeout 30000 ./tests-ts/multiscan.test.ts— 19 passed.bun test --timeout 30000 --rerun-each=10 --test-name-pattern='serializes supervisors while recovering stale file and legacy locks' ./tests-ts/multiscan.test.ts— 10 repeated contention runs passed.pnpm run test— 720 passed, 5 expected platform/integration skips, 0 failed.git diff --check.