Skip to content

Remove apply.lock when a lock-holding run is interrupted (#808) - #1030

Merged
Mikola Lysenko (mikolalysenko) merged 10 commits into
mainfrom
arch-refactor/808-apply-lock-interrupt-cleanup
Oct 8, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 10 commits into
mainfrom
arch-refactor/808-apply-lock-interrupt-cleanup

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The maintainer decided on #808 that socket-patch must never leave a lock file in the project directory. .socket/apply.lock stays transient (Option C). A persistent lock, if ever needed, goes outside the project (home/cache dir or a configurable path).

Normal exits, error exits and panics already drop LockGuard, which deletes the lock. The one path that left apply.lock (and a .socket/ the run created) behind was an interrupted run: Ctrl-C, SIGTERM or SIGHUP while apply, vendor, scan --mode vendored, rollback, repair or remove held the lock. This PR closes that path.

What changed

  • Core (apply_lock.rs): every held lock is registered in a process-global table. New cleanup_held_lock_on_interrupt() unlinks each registered lock file only if the path still names the inode this process locked (same identity check as Drop), then removes .socket/ if it is now empty (Unix only, non-recursive, only a dir literally named .socket).
    • Unix: fixed slots of preallocated C strings plus dev/ino, so the cleanup only calls stat/unlink/rmdir and is async-signal-safe.
    • Windows: a mutex-guarded table with a duplicate handle used for the identity check. No rmdir, since the name stays delete-pending until exit.
    • The guard leaves the table just before its own unlink, after the durability barrier, so an interrupt during a long fsync still cleans up.
  • CLI (src/interrupt.rs, wired in main.rs): Unix sigaction handlers for SIGINT/SIGTERM/SIGHUP run the cleanup, reset to SIG_DFL and re-raise, so the process still dies by the signal. Signals ignored at startup stay ignored. Windows uses SetConsoleCtrlHandler for Ctrl-C/Ctrl-Break/close and returns FALSE so the default handler still ends the process.
  • Failpoint: debug-only <name>[@n]~pause form and a new apply_lock.acquired failpoint, used by the e2e tests to pause while the lock is held.
  • The Node addon installs no handler, so it gets no interrupt cleanup (documented: installing the handler is the host's job).

User-visible changes

An interrupted lock-holding run no longer leaves .socket/apply.lock (or an empty .socket/) in the project. Exit codes and signal deaths are unchanged; the prompt's cursor restore still runs first. Only SIGKILL or power loss can still leave the file, and the next lock-taking command reclaims it as before.

Docs updated

  • CLI_CONTRACT.md: the Lock lifecycle paragraph covers interrupts, the uncatchable-kill case, and "never keeps a persistent lock file in the project". The --dry-run row says the lock is removed on exit or on interrupt.
  • Module docs in apply_lock.rs and lock_cli.rs say the same, including the small Unix window between the handler's unlink and the process dying.
  • CHANGELOG.md and migrating-to-v5.md are untouched.

Tests run (macOS, debug)

  • cargo test -p socket-patch-core --lib -- apply_lock failpoint group_commit: 45 passed (3 new: cleanup removes file and empty .socket/ and the later drop copes; a replaced file is left alone; a dropped guard is unregistered).
  • cargo test -p socket-patch-cli --lib -- lock_cli prompt: 41 passed.
  • --test e2e_safety_lock: 32 passed, including 4 new Unix tests (SIGINT, SIGTERM, SIGHUP each remove apply.lock, the process dies by the signal, the manifest survives; SIGINT ignored at startup keeps the run alive, then SIGTERM still cleans up). With interrupt::install() disabled all 4 fail.
  • --test cli_remove_silent 10, --test vendor_group_commit_e2e 11, --test repair covgap_commands_repair:: 9: all passed.
  • Clippy shows no new warnings in touched files.

Not run: Windows. The Windows modules were compiled for x86_64-pc-windows-gnu in a scratch crate with the same pinned deps, but the Windows tests and the console handler need Windows CI. The empty-.socket/ prune is covered by the unit test only; no lock-taking command reaches the lock in an empty project.

Review findings fixed

  • LockGuard::drop unregistered before the durability barrier, so an interrupt during a long fsync left apply.lock behind. It now unregisters just before the unlink (27db2d9).
  • The Unix window between the handler's unlink and process death is now documented.

Out of scope: moving journal replay and the durability barrier out of the lock stays with #809 / #793.

Closes #808

🤖 Generated with Claude Code


Note

Medium Risk
Changes lock lifecycle and signal/console handlers on all platforms; incorrect interrupt cleanup could unlink the wrong file or briefly overlap holders, though identity gating and new tests mitigate this.

Overview
Ensures Ctrl-C, SIGTERM/SIGHUP, and Windows console interrupts no longer leave .socket/apply.lock (or an empty .socket/) in the project — only SIGKILL or power loss can still leave the file, which the next lock-taking command reclaims as before.

Core: Held locks register in a process-global table; new cleanup_held_lock_on_interrupt() unlinks each lock with the same inode identity check as LockGuard drop, then prunes an empty .socket/ (Unix: async-signal-safe slots; Windows: mutex table + POSIX-semantics delete so the name can go before process exit). Registration stays active through the durability barrier until after unlink so interrupts during long fsync still clean up.

CLI: New interrupt module installed at startup — Unix re-raises after cleanup; Windows console ctrl handler; signals already ignored stay ignored. Interactive prompt chains SIGINT so cursor restore runs before lock cleanup.

Testing/docs: Failpoints gain ~pause (e.g. apply_lock.acquired); Unix e2e tests signal a parked apply and assert no lock residue. CLI_CONTRACT.md and module docs describe interrupt vs uncatchable-kill behavior.

Reviewed by Cursor Bugbot for commit 5608a7e. Configure here.


Generated by Claude Code

acquire now records every held lock in a process-global table, and the
guard's drop takes it out again before it does anything else. The new
cleanup_held_lock_on_interrupt() unlinks each registered lock file,
gated on the same inode identity as the drop, and prunes an empty
.socket/. On Unix it is async-signal-safe: the table holds paths and
dev/ino built at acquire time, and the cleanup calls only stat, unlink
and rmdir. On Windows it compares a duplicate handle's identity and
skips the prune, since the name stays delete-pending until exit.

The module doc now states the rule from #808: socket-patch never keeps
a persistent lock file in the project, and a persistent lock, if ever
needed, lives outside it.

failpoint gains a debug-only `<name>~pause` form that parks the process
and writes a ready marker, and acquire gets an `apply_lock.acquired`
failpoint, so tests can signal a process that holds the lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The default disposition of SIGINT, SIGTERM and SIGHUP ends the process
without running LockGuard's drop, so Ctrl-C during apply, vendor,
rollback, repair, remove or a vendored scan left .socket/apply.lock
behind. main now installs handlers that remove the held lock file, then
reset the signal to SIG_DFL and re-raise it, so the process still dies
by that signal. A signal the process started with ignored stays
ignored. On Windows a console ctrl handler does the same for Ctrl-C,
Ctrl-Break and console close and returns FALSE so the default handler
still ends the process.

The prompt's cursor guard re-raises SIGINT into this handler, so a
Ctrl-C at a menu restores the cursor first and removes the lock second.

New e2e tests park apply on the lock failpoint and send SIGINT, SIGTERM
and SIGHUP, and check that a started-with-SIGINT-ignored run ignores
SIGINT and still cleans up on SIGTERM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CLI_CONTRACT.md's lock lifecycle now says an interrupted run removes
apply.lock, that only an uncatchable kill can leave it, and that
socket-patch never keeps a persistent lock file in the project. The
--dry-run row says the lock is removed on exit or on interrupt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
st_dev is i32 on macOS and u64 on Linux, so the casts are needed on
some targets and clippy's unnecessary_cast fires on the others.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LockGuard::drop left the interrupt-cleanup table before R0, the
durability barrier, which can fsync a large vendored tree for seconds.
A Ctrl-C or SIGTERM during that barrier killed the process with the
lock no longer registered, so apply.lock was left behind. The guard
now unregisters after R0, just before its own unlink (R1). That still
keeps the interrupt from racing the drop's unlink and from matching a
reused inode after the handle closes.

Also document the small Unix window between the handler's unlink and
the process's death.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor 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.

Stale Bugbot comment from a previous run.

@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 8, 2026 11:12
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Merged main, CI green (one Maven Central warm-up flake in the maven_reactor e2e leg passed on re-run); ready for review.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor 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.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-core/src/patch/apply_lock.rs Outdated
Comment thread crates/socket-patch-core/src/patch/apply_lock.rs
The guard's drop left the interrupt table before its own unlink, so a
signal landing between the two (including a slow identity probe) was a
no-op in the cleanup, and the handler then ended the process with
apply.lock still on disk. The drop now unlinks first, then marks its
entry released (prune only: once the handle closes the inode can be
reused by the next holder's file), and leaves the table only after
pruning .socket/, so an interrupt anywhere in the drop is covered.

On Windows the interrupt cleanup used DeleteFile, which leaves the name
delete-pending until the dying process's handle closes, so the empty
.socket/ could never be pruned and a Ctrl-C'd lock-only run left it
behind. The cleanup now unlinks with POSIX semantics (falling back to
DeleteFile where unsupported) and prunes the directory like Unix does.

Co-Authored-By: Claude <noreply@anthropic.com>
…ly-lock-interrupt-cleanup

# Conflicts:
#	crates/socket-patch-cli/CLI_CONTRACT.md
The interrupt-cleanup fix added windows-sys to socket-patch-core's
cfg(windows) dependencies; CI builds with --locked, so the lockfile has to
name it.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor 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.

Stale Bugbot comment from a previous run.

The drop now stays in the interrupt table until after its own unlink,
but nothing exercised a real signal landing there. Add a debug-only
`apply_lock.releasing` failpoint just before R1 and an e2e test that
parks `apply` in the drop, sends SIGTERM, and checks the process dies
by it without leaving apply.lock. The test fails if the guard leaves
the table before the unlink, as it did before.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5608a7e. Configure here.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 8, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at 5608a7e.

  • CI: 395/395 non-skipped checks green (ci-ok success). sbt 1.9.9 / jdk 17 / agent, mill 0.12.17 and vlt install-proof (ubuntu-latest, 1.0.0-rc.8) failed on the first attempt and passed on one re-run; the same sbt 1.9.9/jdk 17 leg and the vlt workflow also failed intermittently on recent main pushes.
  • Bugbot: reviewed 5608a7e, no unresolved findings.
  • Mergeable: yes.

Generated by Claude Code

@mikolalysenko
Mikola Lysenko (mikolalysenko) added this pull request to the merge queue Oct 8, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Tanmay Singla (@Tanmay182003) — final reviewer: not enqueueing yet. One non-merge commit landed after your approval at 1881e15:

  • 5608a7e Test that an interrupt inside the guard's drop still removes apply.lock — adds a debug-only apply_lock.releasing failpoint that parks apply inside LockGuard::drop just before the identity probe and unlink, plus an end-to-end test (interrupted_holder::interrupt_during_release_removes_the_lock_…) covering Bugbot's "Interrupt can skip lock unlink" gap. It's test plus a debug-only failpoint, not a behavior change for release builds, but it does touch non-test code.

CI is green on 5608a7e (ci-ok, clippy). Could you take another look at the diff since your approval? Once you re-approve, it can go straight to the merge queue.


Generated by Claude Code

Merged via the queue into main with commit 6beb181 Oct 8, 2026
510 of 513 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the arch-refactor/808-apply-lock-interrupt-cleanup branch October 8, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-refactor PR opened by the scheduled architecture refactor routine Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide: keep .socket/apply.lock transient, or give the lock a file that never has to be deleted

3 participants