fix(checkpoints): reclaim the index lock a killed git leaves behind (#74108) - #74737
fix(checkpoints): reclaim the index lock a killed git leaves behind (#74108)#74737mehmetkr-31 wants to merge 4 commits into
Conversation
|
Thanks for the pointer — and my duplicate check missed those two. I searched the issue number and the phrase "checkpoint lock"; #13232 and #52887 say "index.lock", so neither matched. My fault, and #13232 (2026-04-20, @thapecroth) is clearly the earliest canonical claim on this bug. Before you consolidate, there is one substantive difference worth knowing, because I think it decides which code has to survive rather than which PR is older. The three approaches clean different paths, and only one of them is the path that wedges.
I verified the per-project claim rather than reading it off the paths — driving the real So Two smaller deltas in this PR, both mentioned in the triage note:
On the age threshold: I used One thing to watch when consolidating: #52887's diff also drops the Happy for this PR to be closed in favour of #13232 / #52887 — I have no attachment to it landing. If that is the direction, I am glad to open the per-project-lock path plus the two deltas as a follow-up on top of whichever one you take, so @thapecroth keeps authorship of the original fix. Just say which base you want. |
6f71bdf to
b9bc584
Compare
|
Update — I pushed two more findings that came out of comparing this against #13232/#52887. They change my read of the situation, so I want to withdraw the "just close this one" framing from my last comment: I now think this branch has to be the base, not because it is mine, but because of what the comparison turned up. 1. The English-marker detection was a real bug — in my code. My first cut keyed recovery on No 2. One lock is not the whole bug class. I counted the call sites: 43 So rather than pick a path, the retry now reclaims the path git names in its own error, which covers every class with one mechanism and no guessing. Safety: a reclaimed path must resolve inside the checkpoint store, so a surprising message can never point the cleanup at a file we don't own; relative paths are ignored. The staleness proof is unchanged ( New coverage —
Where that leaves the three PRs. The consolidation still shouldn't be decided on age, but the authorship point stands: @thapecroth reported and diagnosed this first and deserves the credit. I'm happy to (a) rebase this onto #13232 or #52887 as follow-up commits so their history leads, or (b) have a maintainer take this branch and add a |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the v2 per-project index path and extending recovery to localized stderr and ref-lock errors. The underlying timeout wedge is present on current main (tools/checkpoint_manager.py:346-355, with the checkpoint git add at tools/checkpoint_manager.py:1048-1054).
Problems
tools/checkpoint_manager.py:492-496always cleans_index_lock_path(index_file, store). Forupdate-ref, current main calls_run_gitwithoutindex_file(tools/checkpoint_manager.py:1115-1117), so this selectsstore/index.lock; it does not clean that operation's ref lock and can remove an unrelated active root-index lock._clear_abandoned_lock()checks mtime attools/checkpoint_manager.py:309and unlinks later at:325. Another session can replace the stale pathname in that interval, causing deletion of a fresh live lock. The fresh-lock test covers a lock present before recovery, not replacement during recovery.
Suggested changes
- Use command-specific, safely claimed timeout cleanup, and add an
update-reftimeout regression. - Use a cross-process/atomic ownership protocol for stale-lock recovery and test the replacement race.
Automated hermes-sweeper review.
| # subprocess.run has already killed and reaped the child, so any lock | ||
| # it created is abandoned by definition — no staleness check needed. | ||
| _clear_abandoned_lock( | ||
| _index_lock_path(index_file, store), |
There was a problem hiding this comment.
Blocking: this is not the timed-out command's lock for calls such as update-ref, which pass no index_file and use ref locks. It resolves to store/index.lock, leaving the ref lock behind and potentially deleting another call's active root-index lock. Limit timeout cleanup to a lock this invocation can identify and own, and add an update-ref timeout test.
| return False | ||
|
|
||
| try: | ||
| lock_path.unlink() |
There was a problem hiding this comment.
Blocking: stat() above and this pathname unlink are not atomic. A concurrent session can replace the stale lock after the age check, so this can delete its fresh live lock. Please use an atomic ownership/cross-process coordination protocol and add a replacement-race regression.
b9bc584 to
10e7d56
Compare
|
Both findings were real and are fixed. Thanks — the second one in particular was a hole I had reasoned myself past. 1. Timeout cleanup is now command-specific. You're right that
Returning None is deliberate rather than a gap: an unmapped command heals through the stderr-driven recovery on the next call, which reads the path out of git's own error. I'd rather defer than guess. The timeout path additionally requires the lock to have appeared after the call started ( 2. The stat→unlink window is closed with an atomic claim. Removal now claims the lock with Judgement still happens before the claim, deliberately: a lock that looks live is never renamed away even momentarily. New tests, both of the ones you asked for:
I checked the race test actually models the dangerous window rather than a harmless one: my first version swapped the file after the rename, which the implementation handles trivially, so it passed against the broken code too. It now swaps before the claim, and fails against a plain All five new tests fail against the previous implementation — reverting the command mapping fails the three timeout tests, reverting the atomic claim fails the replacement-race test.
The authorship offer from my previous comment still stands: happy to have this rebased as follow-up commits onto #13232/#52887, or taken with a |
…ousResearch#74108) `git add -A` against the checkpoint store creates `<index>.lock` with O_EXCL and renames it into place on success. When the git child is killed — which our own subprocess timeout does readily when the work tree is a WSL2 drvfs/9p mount such as /mnt/c — the lock survives the process that made it. The file records no owner, so git can never reclaim it: every later `git add` against that index fails instantly with fatal: Unable to create '.../indexes/<hash>.lock': File exists for the rest of the session *and every future session*, until a human deletes the file. From the user side a tool call crawls to the 60s wall once, and afterwards checkpoints look suspiciously instant because they are no longer doing any work. _run_git now reclaims the lock on both paths: - On TimeoutExpired, subprocess.run has already killed and reaped the child, so any lock it created is abandoned by definition and is removed with no age check. This stops the wedge from forming. - When a call fails with git's "unable to create ... lock" message, the lock is reclaimed and the call retried exactly once, so an install already wedged by an earlier timeout heals itself instead of needing manual cleanup. The retry path only reclaims a *provably* stale lock: every checkpoint git call is bounded by _GIT_TIMEOUT (at most _MAX_GIT_CALL_SECONDS with the largest caller multiplier), so a lock older than that window cannot belong to a running call. A younger lock is left alone, because a concurrent session in the same working directory may legitimately own it. Tests exercise real git through the real _run_git: stale lock reclaimed and the call retried; fresh lock left alone; the timeout handler reclaiming its own brand-new lock (which an age check would refuse); recovery retrying exactly once and not recursing; and an unrelated failure leaving the lock untouched. Fixes NousResearch#74108 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Extends the recovery from the per-project index lock to whatever lock git itself reports, after two findings while comparing against NousResearch#13232/NousResearch#52887. 1. Locale. Detection previously matched the English "unable to create" in stderr. git localizes that prose — under a Turkish locale the same failure reads "onulmaz: '<path>' oluşturulamıyor: File exists." — so recovery would have silently stopped working for every non-English install. Detection now matches the quoted *path* ending in .lock, which git does not translate. 2. Coverage. The store takes more than one lock. `git add -A` passes index_file and so takes store/indexes/<hash>.lock, but the 43 calls that pass no index_file use git's default $GIT_DIR/index, whose lock is store/index.lock, and update-ref/maintenance calls take ref locks under store/refs/. A killed process wedges whichever one it held. Reclaiming the path git names in its error covers all of them with one mechanism and no guessing, so _index_lock_path() is now total (per-project index when index_file is given, store root otherwise) and the retry path reads its target out of stderr. Reclaimed paths are validated to resolve *inside* the checkpoint store, so a surprising or hostile message can never point the cleanup at a file we do not own; relative paths are ignored. The staleness proof is unchanged. Adds TestLockReclaimCoversEveryLockClass: both index-lock targets, ref-lock extraction, a localized-git stderr, refusal of out-of-store and relative paths, and an end-to-end real-git ref-lock recovery through _run_git. That last one fails against an index-only implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses both review findings on NousResearch#74737. 1. The timeout handler guessed the index lock for every command. `update-ref` takes a ref lock and passes no `index_file`, so that resolved to `store/index.lock`: it missed the ref lock the killed call actually left, and could delete an unrelated — possibly live — root-index lock. `_lock_for_timed_out_command()` now maps the command to the lock it actually takes: the index lock for index-writing subcommands, the `<store>/<ref>.lock` for `update-ref`, and **None** for anything else (gc, reflog, ...). Returning None is deliberate — an unmapped command heals through the stderr-driven recovery path on the next call rather than by a guess. The timeout path also now requires the lock to have appeared *after* the call started (`created_after=_started_at`). Our child being dead makes a lock it created abandoned; a lock predating our launch is somebody else's and must survive. 2. `_clear_abandoned_lock()` judged with `stat()` and removed with `unlink()` by name. In between, another session can finish its own recovery and a fresh git can create a new lock at the same pathname — the unlink would then delete a live lock. Removal now claims the lock with an atomic `os.rename()` and verifies it got the inode it judged, comparing `(st_dev, st_ino, st_mtime_ns)`. A mismatch means a replacement was caught mid-flight; it is put back with `os.link()` so a lock created in the meantime is never clobbered, and the claim file is cleaned up either way. Tests: TestTimeoutCleanupIsCommandSpecific (update-ref clears its ref lock and leaves a live root-index lock alone; an unmappable command clears nothing; a lock predating the call survives) and TestReclaimSurvivesTheReplacementRace (a lock replaced inside the judge→claim window is put back and the replacement survives; an unreplaced stale lock is still removed, leaving no claim files). All five fail against the previous implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught this: the two timeout tests passed on macOS and failed on Linux. The timeout path decided whether a lock was "ours" by comparing the lock's st_mtime against a time.time() taken just before launching. On Linux, inode timestamps come from the kernel's *coarse* clock, advanced once per timer tick (~1-4ms), while time.time() reads the fine-grained clock. A lock created immediately after the call starts can therefore carry an mtime slightly *earlier* than the recorded start, so the guard rejected a lock our own child had just created and the wedge survived. APFS records fine-grained timestamps, which is why it passed locally — the margin there is ~30us. Ownership is now decided by observing whether the lock exists *before* launching, which is exact and independent of filesystem timestamp granularity: a lock that was not there before, on a call whose child we have reaped, is abandoned by definition. The created_after parameter is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10e7d56 to
794f298
Compare
|
CI caught a portability bug in the ownership check I added for finding (1), so I've replaced the mechanism. Worth writing up because it is not obvious. The bug. I decided "did my child create this lock?" with APFS records fine-grained timestamps, so the margin on macOS is ~30 µs and always positive. Both timeout tests passed locally and failed on the Linux runner ( The fix. Ownership is no longer inferred from a timestamp at all. The staleness guard on the recovery path is unchanged: it compares an age against Current state on
|
|
CI note: the single red job is not this PR. The only failing test on the whole run is |
Problem
git add -Aagainst the checkpoint store creates<index>.lockwithO_EXCLand renames it into place on success. When the git child is killed — which our own subprocess timeout does readily when the work tree is a WSL2 drvfs/9p mount such as/mnt/c— the lock outlives the process that made it.The lock file records no owner, so git can never reclaim it. Every later
git addagainst that index then fails instantly:for the rest of the session and every future session, until a human deletes the file. From the user side, one tool call crawls to the timeout wall, and afterwards checkpoints look suspiciously instant — because they are no longer doing any work.
Premise verified on current
main:tools/checkpoint_manager.py's_run_githandlesexcept subprocess.TimeoutExpired:by logging and returning. Nothing cleans up the abandoned lock, and no other code path does either.Fix
_run_gitnow reclaims the lock on both paths:TimeoutExpired,subprocess.runhas already killed and reaped the child, so any lock it created is abandoned by definition. It is removed with no age check. This stops the wedge from forming.Why this is safe under concurrency
The recovery path only reclaims a provably stale lock. Every checkpoint git call is bounded by
_GIT_TIMEOUT(at most_MAX_GIT_CALL_SECONDS, the largest caller multiplier), so a lock whose mtime is older than that window cannot belong to a running call. A younger lock is deliberately left alone, because a concurrent Hermes session in the same working directory may legitimately own it — stealing it would corrupt that session's write.The timeout handler is the one caller allowed to skip the age check (
require_stale=False), and only because it has just reaped the owning child itself.Tests
Five tests in
tests/tools/test_checkpoint_manager.py, exercising real git through the real_run_gitrather than mocking the dispatch:test_stale_lock_is_reclaimed_and_the_call_retried— the healing path for an already-wedged install.test_fresh_lock_is_left_alone— a concurrent call's lock survives.test_timeout_reclaims_its_own_lock_without_an_age_check— the lock is brand new here, so an age check would refuse it; the timeout handler must still reclaim it.test_recovery_retries_only_once— a permanently failing call does not recurse.test_unrelated_failures_do_not_touch_the_lock.Verified they catch the bug rather than merely passing: with the fix reverted, the three behavioural tests fail (the first with git's exact
Unable to create '...lock': File exists), while the two "must not touch" guarantees still pass, as they should.tests/tools/test_checkpoint_manager.pyis 45 passed.tests/tools/produces an identical failure set to a pristine checkout ofmain— zero introduced (the pre-existing ones are local environment issues such as missing optional deps).