Skip to content

fix: publish multiscan locks atomically - #98

Open
GautamSharma99 wants to merge 2 commits into
openai:mainfrom
GautamSharma99:fix/atomic-multiscan-lock
Open

fix: publish multiscan locks atomically#98
GautamSharma99 wants to merge 2 commits into
openai:mainfrom
GautamSharma99:fix/atomic-multiscan-lock

Conversation

@GautamSharma99

@GautamSharma99 GautamSharma99 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Maintainer tracking

Related to #102. The follow-up in #196 preserves this original contributor-authored change.

Summary

Make multiscan lock publication atomic and recover incomplete or malformed legacy locks.

Previously, lock acquisition performed two separate operations:

  1. create .lock/;
  2. write .lock/owner.json.

A crash between those operations left a visible lock without ownership metadata. Subsequent campaigns attempted to read and parse owner.json
unconditionally, permanently preventing automatic recovery.

Truncated or structurally invalid owner files caused the same problem.

Changes

Atomic lock publication

The supervisor now:

  1. creates a uniquely named pending lock file;
  2. writes its complete owner record;
  3. flushes the owner record with fsync;
  4. atomically hard-links it to .lock;
  5. removes the pending name.

The published owner record contains:

  • the supervisor PID;
  • a unique ownership token.

Because hard-link creation has no-clobber semantics, only one contender can publish .lock. The lock never becomes visible with partially
written ownership metadata.

Crash behavior is now:

  • before publication: no .lock exists, so the next campaign can start;
  • after publication: .lock contains a complete owner record and can be recovered using the recorded PID.

Legacy lock compatibility

Recovery continues to recognize the previous directory layout:

.lock/
└── owner.json

Legacy locks are treated as stale when ownership metadata is:

- missing;
- truncated or malformed JSON;
- structurally invalid;
- oversized;
- associated with a process that no longer exists.

Valid legacy locks belonging to a running process still block another supervisor.

### Race-safe recovery

Lock recovery now:

- rechecks a live owner before reporting contention;
- atomically moves stale state aside before removing it;
- revalidates the moved owner;
- restores and preserves the lock if it belongs to a live process;
- retries acquisition if another contender changes the lock concurrently.

EPERM from process probing is treated as evidence that the owner exists rather than as a stale lock.

### Ownership-safe release

A supervisor removes .lock only when the published ownership token still matches its own token.

This prevents a finishing supervisor from deleting a replacement lock published by another process.

Pending and stale files created during normal acquisition and recovery are cleaned up.

## Impact

An interruption during lock creation can no longer permanently wedge a bulk-scan campaign.

Users can resume normally after:

- a process kill between old lock-directory creation and owner publication;
- a machine restart;
- a truncated owner.json;
- malformed or invalid owner metadata.

At the same time, concurrent live supervisors remain mutually exclusive.

## Tests

Added regression coverage confirming that:

- a live lock is a fully published regular file;
- its owner record contains the current PID and a unique token;
- no pending lock remains after publication;
- a second live supervisor is rejected;
- the previous directory-style lock remains recoverable;
- missing legacy owner.json is recovered;
- truncated owner JSON is recovered;
- structurally invalid owner metadata is recovered;
- stale and pending recovery files are cleaned up;
- valid dead-owner recovery and checkout cleanup continue to work.

Verification performed:

- pnpm run types
- pnpm run format
- pnpm run build
- focused multiscan tests
- pnpm run test

Full test result:

- 408 passed
- 5 expected platform/integration skips
- 0 failed

@GautamSharma99

Copy link
Copy Markdown
Contributor Author

@mldangelo-oai @ianw-oai
I updated multiscan locking so ownership is published atomically using a fully written and synced lock file. The new flow prevents crashes from leaving incomplete locks that permanently block future campaigns. It also recovers missing, malformed, truncated, or dead-owner legacy locks, while ownership tokens and PID checks ensure active supervisors remain protected and cannot remove another process’s lock. Regression tests cover atomic publication, stale recovery, cleanup, and concurrent supervisor rejection.

@mldangelo-oai

Copy link
Copy Markdown
Collaborator

Thank you for the original multiscan locking work. The follow-up in #196 preserves the contributor author and the same stable patch after rebasing, and is the working branch for #102. Its recovery/ownership race findings still need to be addressed, so neither branch should be merged yet; keeping this PR open preserves the original contribution and context.

@mldangelo-oai

Copy link
Copy Markdown
Collaborator

@codex review exact head 558ad1b

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 558ad1b992

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.

@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: 558ad1b992

ℹ️ 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 on lines +323 to +326
try {
await rename(stale, path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;

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 Do not overwrite a newly acquired lock during restoration

On POSIX, rename(stale, path) replaces an existing destination rather than raising EEXIST. During lock handoff, one contender can move a newly acquired lock after an earlier readLockOwner raced with the previous owner's unlink; if another contender then acquires .lock before this restoration, the rename silently overwrites that contender's lock. Both successful acquirers can consequently run against the same output directory and corrupt its ledger or artifacts. Restore the moved lock with a no-clobber operation instead of relying on rename to report EEXIST.

Useful? React with 👍 / 👎.

Comment on lines +366 to +370
if (
error instanceof SyntaxError ||
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
return null;

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 Delay reclaiming incomplete legacy lock files

When an older supervisor starts concurrently, its legacy sequence creates the .lock directory before writeFile finishes owner.json. If this reader observes the owner file after it is opened but before its JSON write completes, the SyntaxError is immediately treated as a stale lock and the directory can be renamed and removed. The older process retains its open descriptor, can finish successfully and believe it owns the lock, while this process publishes its own lock, allowing two campaigns to mutate the same output directory. Treat a freshly missing or malformed legacy owner as an in-progress publication and retry or apply a grace period before reclaiming it.

Useful? React with 👍 / 👎.

Comment on lines +294 to +298
try {
await link(pending, path);
return async () => await releaseLock(path, token);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;

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 Avoid requiring hard-link support for output directories

When --output-dir is on a filesystem that does not support hard links, such as FAT/exFAT or a mount that does not expose them, link(pending, path) fails with EPERM or ENOTSUP; this loop handles only EEXIST, so every bulk scan aborts before starting. The previous directory-based lock worked on these filesystems, and the CLI otherwise accepts arbitrary output paths on its supported operating systems. Use an atomic locking strategy with a fallback that does not require hard-link support.

Useful? React with 👍 / 👎.

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

Labels

bug Something isn't working duplicate This issue or pull request already exists

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants