fix(lock): break a registry lock whose recorded holder is gone (#865) - #866
Draft
fujibee wants to merge 2 commits into
Draft
fix(lock): break a registry lock whose recorded holder is gone (#865)#866fujibee wants to merge 2 commits into
fujibee wants to merge 2 commits into
Conversation
The acquire loop never asked whether the holder was still running. A lock left by a killed process was indistinguishable from one held by a live process doing slow work, so every later command waited out its budget and failed - forever, until somebody removed a directory by hand, working out which of several causes it was from a message that named none of them. Observed on a machine 8 minutes after boot under load average 8: two lock directories six seconds apart, still there three hours later. The widest source of these is roster-sync-driver.sh, which acquires and then runs node in the foreground: the lock is held for as long as that runs, so one kill -9 or one OOM kill in that span leaves a lock that does have a holder record. Those are the ones this breaks. Liveness comes from _agmsg_pid_alive_local, not a bare kill -0, which reads EPERM as dead and would take a live lock away in a sandbox. A recycled pid reads as alive, which is the safe direction: this waits and reports contention rather than breaking a stranger's lock. The break claims the holder file by renaming it before removing the directory, so two processes reaching the same verdict cannot both remove - and a lock that changed hands between the verdict and the removal is not the file that was claimed. Not in this change, and pinned as cases so they stay decisions: a lock with no holder record at all is left alone, because nothing here can tell an older version's lock from one being created right now. Also the prose above the traps, which said a crash leaves no stale lock. True of EXIT/INT/TERM, not of SIGKILL, an OOM kill, or the machine going down - which is the crash this issue was reported from.
The first version read the holder, decided it was dead, and then renamed whatever was at that path. Between those two steps a different breaker can claim the old holder and remove the directory, a new owner can take the same path and write a live holder, and the rename then succeeds against the new file - the path is the same. The rmdir after it removes a lock somebody is using. The claim bound nothing. Renaming first makes the claim the thing that is judged, and once it is claimed no other breaker can act (the file is gone) and no new owner can appear (the directory is still there). A claim that turns out to be alive is put back. A case drives the handoff: break, a new owner takes the freed path with a live holder, then the late breaker runs with its old verdict. Also: the timeout said a holder answered as alive whenever a holder file existed. A record with no pid line, or an unusable one, is 'could not be asked' - which is what sent the last diagnoses after processes that were not there.
fujibee
marked this pull request as draft
August 18, 2026 05:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #865 — not
Closes: this is one of the three fixes the issue describes, and merging it must not close the other two.Draft, and third in line. The order was changed after this was opened:
doctorreports the locks first (#867, no removal at all),doctor --fixsweeps second, and this — acquire breaking a stale lock on its own — comes last, because a wrong verdict here takes a lock away from a live process. Its own review history is the argument: the first version bound the removal to a path rather than to the holder it had judged, so a lock that changed hands between the verdict and the rename could be taken from somebody using it.Describes head
15d2e1bc4d6be98a9eb1cc05fd5a13ddfbe081b2.Affects existing users. A team that is wedged today becomes usable again on the next command, without anyone removing a directory by hand. Nothing changes for a lock that is genuinely held.
What was wrong
The acquire loop never asked whether the recorded holder was still running. A lock left behind by a killed process was indistinguishable, to that loop, from one held by a live process doing slow work — so it waited out its budget and failed. Every time. Forever. The message said
timed out acquiring registry lock, which describes contention, so the operator went looking for a process that was not there.Observed on a machine eight minutes after boot under load average 8: two lock directories six seconds apart, both still present nearly three hours later.
Scope: one of the three pieces
The issue names three fixes. This is the one it calls second — asking whether the holder is alive — and it is scheduled last, for the reason above.
doctornames every lock and which directory to remove — removes nothingdoctor --fixsweepsThe scope was chosen from how these locks are actually made, and there are two ways with very different windows:
mkdirand the holder write — leaves an empty lock. A narrow window; it takes load and simultaneity. The two observed locks are these.roster-sync-driver.shacquires and then runs node in the foreground, so it is the whole of that run. Onekill -9, one OOM kill or one force-quit is enough.The second is far likelier and it has a holder record to ask about. So asking is the change that closes the most for the least.
What it does not do, and why that is a decision
A lock with no holder record is left alone. Nothing here can tell a lock written by an older version of this file from one created microseconds ago whose owner has not written its record yet, and breaking on "I cannot tell" takes a live lock away — which is worse than the leak. There is a case pinning that, so changing it has to be a decision rather than a drift. The piece that makes empty locks impossible removes the question instead of answering it.
A recycled pid reads as alive. Without a start-time fence,
pid 1598 is alivecannot be told fromthe pid 1598 that took this lock is alive, so this waits and reports contention rather than breaking a stranger's lock. That is the safe direction, and it is the third piece.How it is done
Liveness is
_agmsg_pid_alive_local, which already exists for this: it readsEPERMas alive, a zombie as gone, and cross-checks withps. A barekill -0readsEPERMas dead, which in a sandbox turns "cannot signal" into "not running" — and here that would break a lock somebody is holding.The break claims the holder before it judges or removes anything, and the order is the correctness argument. The first version read the holder, decided it was dead, and then renamed whatever was at that path — between those two steps another breaker can claim the old holder and free the directory, a new owner can take the same path and write a live holder, and the rename then succeeds against the new file. Raised in review, and fixed by renaming first: two processes cannot both claim, and once the holder is claimed no new owner can appear, because the directory is still there.
That fix has no mutation row, and saying so is the point. The interleaving happens between two statements inside one function, so nothing outside it can drive the sequence; a fixture that forces the state anyway reddens the correct code, because with claim-first that state is unreachable. It rests on the structure, not on a measurement.
And one residual it does not close: an operator removing the lock directory by hand — which this code's own message tells them to do — while a breaker is mid-judgement. No ordering inside the helper reaches outside the primitive.
The earlier claim-before-remove reasoning Two processes can reach the same verdict in the same instant;
mvof a given file succeeds for exactly one of them, so exactly one goes on tormdir. It also binds the removal to the holder that was judged: if the lock changed hands in between, the file being renamed is not the file that was read, the rename fails, and normdirlands on a lock somebody else has since taken.The timeout message now says which of the two situations it is — a holder that answered as alive, or a lock with no holder record — because that is what decides where the operator looks next.
And the comment above the traps, which read "a crash with one or two locks held leaves no stale lock": true of
EXIT/INT/TERM, not ofSIGKILL, an OOM kill, or the machine going down. That is the crash this issue was reported from, and the sentence had no qualifier.Mutations
Each reverted alone, and each reddens its own case:
The live-holder row is the one that matters: breaking a lock somebody is using is worse than the leak this fixes.
Both liveness controls are asserted, not assumed. The "gone" case spawns a process,
waits for it, and then checks_agmsg_pid_alive_localsays gone before writing that number into the holder — a pid that merely happened to be free would pass for the wrong reason. The "alive" case checks the holder is alive at the moment the acquire runs, because asleepthat failed to start would make it pass by breaking a lock.Suites
tests/test_registry_lock.bats21/21 (5 new),tests/test_team.bats79/79,tests/test_local_team_ids.bats5/5 — the last one because this sourcesinstance-id.sh, and that suite is the one that runs the core join under an allow-listed PATH.Not the full suite locally; CI is the gate.