Skip to content

fix: publish and recover multiscan locks atomically - #196

Draft
mldangelo-oai wants to merge 9 commits into
mainfrom
mdangelo/codex/atomic-multiscan-lock-recovery
Draft

fix: publish and recover multiscan locks atomically#196
mldangelo-oai wants to merge 9 commits into
mainfrom
mdangelo/codex/atomic-multiscan-lock-recovery

Conversation

@mldangelo-oai

Copy link
Copy Markdown
Collaborator

Summary

  • Preserve the original atomic multiscan-lock publication from fix: publish multiscan locks atomically #98 as a standalone commit authored by @GautamSharma99.
  • Serialize stale-lock recovery so concurrent supervisors cannot simultaneously acquire ownership.
  • Atomically replace stale regular locks and protect the temporary publication gap when migrating legacy directory locks.
  • Preserve a competing supervisor's ownership if the lock changes during recovery or release.
  • Reject process IDs outside Node's supported range and recover malformed, incomplete, oversized, or stale legacy lock records.
  • Clean up pending ownership files even when writing, syncing, or closing them fails.

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

  • 24 concurrent contenders recovering both stale regular-file locks and legacy directory locks.
  • A live replacement published while another supervisor checks stale ownership.
  • A replacement substituted between ownership verification and lock release.
  • Missing, truncated, malformed, oversized, zero, negative, out-of-range, and invalid-token owner records.
  • Pending-file cleanup after a simulated fsync failure.
  • Existing live-owner rejection, dead-owner recovery, resumable scan output, and checkout cleanup.

Verification

  • pnpm run types
  • pnpm run format
  • pnpm run build
  • pnpm pack --pack-destination <temporary-directory> and pnpm 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.
  • Independent 24-contender stress harness — 100 stale-file trials and 100 legacy-lock trials with no overlapping supervisors; the original PR overlapped in 28 of 30 trials.
  • git diff --check.

Copy link
Copy Markdown
Collaborator Author

@codex review

Please review exact pushed head 8cdc1f6, including the preserved contributor-authored atomic lock publication, serialized stale-lock recovery, ownership-safe release, bounded PID validation, failure cleanup, and 24-contender regression coverage.

@github-actions github-actions Bot added the bug Something isn't working label Aug 1, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread sdk/typescript/src/multiscan.ts Outdated
Comment thread sdk/typescript/src/multiscan.ts
Comment thread sdk/typescript/src/multiscan.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8cdc1f6880

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@mldangelo-oai

Copy link
Copy Markdown
Collaborator Author

@codex review Please review exact head 04f8a7b, focusing on replacement-safe stale recovery markers, published-lock ownership revalidation, restoring substituted legacy directory locks, concurrent supervisors, malformed owner records, and cross-platform cleanup.

@mldangelo-oai
mldangelo-oai marked this pull request as ready for review August 3, 2026 06:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 04f8a7be59

ℹ️ 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".

Comment thread sdk/typescript/src/multiscan.ts
Comment thread sdk/typescript/src/multiscan.ts Outdated
Comment thread sdk/typescript/src/multiscan.ts
@mldangelo-oai
mldangelo-oai marked this pull request as draft August 3, 2026 06:37
@mldangelo-oai

Copy link
Copy Markdown
Collaborator Author

@codex review Please review exact head 52fc7d9, focusing on three-supervisor recovery-marker swaps without vacancy, device/inode and token revalidation, live legacy directory restoration, lock-owner checks, hard-link filesystem errors, and cross-platform cleanup.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52fc7d944b

ℹ️ 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".

Comment thread sdk/typescript/src/multiscan.ts Outdated
moved?.token !== current?.token ||
(moved !== null && processIsRunning(moved.pid))
) {
await rename(stale, path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retry restoration when the lock path is reoccupied

When a live legacy supervisor replaces the stale directory after current is read and a third supervisor publishes a file lock during the vacancy created by rename(path, stale), this restoration fails because a directory cannot be renamed over the third supervisor's file. The recovery attempt then releases its marker while the live legacy lock remains stranded under .lock.stale-*; once the third supervisor rejects the live recovery owner and removes its file, .lock is vacant and another campaign can overlap the legacy campaign. Fresh evidence in this head is that the new directory-specific rename avoids hard-linking the directory but still does not handle a concurrently reoccupied destination.

Useful? React with 👍 / 👎.

(await readLockOwner(recovery))?.token !== token ||
(await readLockOwner(path))?.token !== token
) {
await releaseLock(path, token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck recovery ownership before replacing the lock

When two contenders both validate the same stale recovery inode before either swap, contender B can publish its marker and read the stale .lock, then contender A can replace the marker, install its lock, pass the final checks, and return. B still proceeds with its earlier observation, overwrites A's lock, and reaches this cleanup because the recovery marker is no longer B's; releaseLock then removes B's replacement and leaves .lock vacant while A's campaign is running, allowing a third supervisor to start. Fresh evidence in this head is that the added final check detects the losing contender but cleans up only after that contender has already overwritten the winner's lock.

Useful? React with 👍 / 👎.

await rename(pending, path);
removed = true;
} finally {
if (removed) await rm(stale, { recursive: true, force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release the published lock when stale cleanup fails

If recursive removal of the moved legacy directory fails after rename(pending, path)—for example, because Windows reports EPERM or EBUSY for an open file—this finally propagates the cleanup error while leaving the new token installed at .lock. No unlock callback is returned, and the recovery marker is subsequently released, so retries in the same long-lived SDK process see that process's PID as live and remain locked out until it exits. Release the newly published lock when stale-directory cleanup fails, or defer nonessential cleanup until ownership has been safely returned.

Useful? React with 👍 / 👎.

for (;;) {
try {
await link(pending, path);
const recovering = await readLockOwner(recovery);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Roll back publication when ownership validation errors

If the initial hard link succeeds but inspecting .lock.recovery fails with a non-ENOENT error—for example, an unreadable preexisting marker produces EACCES—the catch rethrows without releasing the newly published .lock. The outer cleanup removes only the pending pathname, leaving the same token owned by the still-live SDK process; no unlock callback was returned, so subsequent attempts in that process report an active supervisor until the process exits. Track successful publication and release it on validation errors before propagating the failure.

Useful? React with 👍 / 👎.

@mldangelo-oai

Copy link
Copy Markdown
Collaborator Author

@codex review exact head 1f815af

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f815af6b0

ℹ️ 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 (contender === null) continue;
if (
contender.token === undefined ||
!occupied.isFile() ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore reoccupying legacy directories before aborting

When a previous-SDK supervisor creates a directory lock in the vacancy after rename(path, stale)—which it can do because it ignores .lock.recoveryrename(stale, path) fails and this !occupied.isFile() branch immediately throws. This strands the first live legacy supervisor's ownership under .lock.stale-*; after the new directory owner removes .lock, another supervisor can acquire it while the stranded supervisor is still running. Fresh evidence in the exact head is that the newly added restoration loop only evicts regular-file contenders and explicitly rejects directory contenders, so the previously reported reoccupation race remains for legacy peers.

Useful? React with 👍 / 👎.

);
if (occupied === null) continue;
const contender = await readLockOwner(path);
if (contender === null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop retrying forever on an incomplete legacy contender

When an old supervisor completes mkdir(.lock) in this restoration window but crashes before writing owner.json, readLockOwner(path) remains null and this unconditional continue spins forever while retaining the recovery marker. This is precisely the incomplete legacy-lock crash state the migration is intended to recover, so the occupant must be moved and validated or the restoration attempt must terminate rather than retrying until some unrelated process changes the path.

Useful? React with 👍 / 👎.

Comment on lines +302 to +304
recovering !== null &&
recovering.token !== token &&
processIsRunning(recovering.pid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim dead recovery markers before accepting ownership

If a recoverer crashes after rename(path, stale) has vacated .lock, the next invocation can publish its file through this direct-link branch. Because the condition rejects only a recovery owner whose PID is still running, it accepts the dead foreign recovery marker and returns without ever reclaiming it, permanently leaking .lock.recovery, its pending link, and the moved .lock.stale-* directory. A later reuse of the dead PID then makes otherwise valid campaigns report that a supervisor is running, so any foreign recovery marker should be resolved before this branch returns ownership.

Useful? React with 👍 / 👎.

Comment on lines +447 to +448
const replacement = `${path}.pending-${owner.pid}`;
await acquireRecoveryMarker(replacement, pending, token);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound abandoned-marker takeover names

Each time a process crashes after recursively publishing replacement but before renaming it over the abandoned marker, the next recovery appends another .pending-<pid> suffix to the same filename component. After enough interrupted recovery attempts, this component exceeds the filesystem's filename limit and link fails with ENAMETOOLONG; because the existing chain cannot then be collapsed, the output directory remains permanently unusable without manual cleanup. Use constant-length sibling names or otherwise bound and clean the abandoned-marker chain.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 1f815af6b0

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incomplete .lock directory permanently blocks bulk-scan recovery

2 participants