Repository navigation
fix(workspace-lock): reap a dead same-host holder instead of waiting out its heartbeat - #5313
Conversation
…out its heartbeat A gateway bridge that dies without releasing (SIGHUP from a recreated tmux window, SIGKILL) left a lock that read fresh for up to 90 s, so the replacement bridge deferred (exit 75) to a holder nobody was behind. acquire() now reaps a holder recorded on this host whose pid no longer exists (os.kill(pid, 0) -> ProcessLookupError; EPERM counts as alive). A holder on another host is still judged by heartbeat alone, and a live pid is still never proof of ownership. New retain() lets a holder that exits on purpose while owning the role (the bridge's in-flight publication hold) keep the old age-out behaviour; the bridge calls it on that path. No process start time is recorded, so a dead holder whose pid was already reused falls back to the heartbeat rule (pinned in a test). src/workspace_lock.py is canonical; the ag2-sparrow copy is regenerated by tools/sync_from_src.py. Refs #5312 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 91%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
Drives the except branch of the publication-in-flight exit path: the exit hook must not raise, and the lock stays held (never released). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Approve at 892c7134 (Blue, Chi's Sutando; code owner for src/).
Checked
_holder_is_deadreturns true only for a non-retained holder whosehostequals ours and whose positive int pid givesProcessLookupErroronkill(pid, 0). EPERM, any other host, a bool or bad pid, and Windows all keep the heartbeat rule.acquire()now defers only when the holder is fresh and not dead, so akill -9'd same-host holder is reaped at once instead of after the stale window. The reap still happens under_guard, so two successors can't both take it.retain()marks only our own lock (same pid and host, under the guard)._release_singleton's publication-in-flight hold now calls it, so the deliberate hold still ages out by heartbeat instead of being reaped the moment the bridge exits. It's wrapped in try/except, so a failedretain()falls back to today's behaviour, and the last commit tests exactly that.- The vendored copy matches.
packages/ag2-sparrow/.../workspace_lock.pyis byte-identical tosrc/workspace_lock.py.
Ran locally
- At the head:
tests/workspace-lock.test.pyOK andtests/remote-gateway-singleton.test.pyOK. - With both
workspace_lock.pycopies and the bridge restored to the merge-base and the new tests kept: FAILED (failures=1, errors=2) and FAILED (failures=2, errors=1). So the tests pin the change.
Non-blocking, worth one docstring line: "same host" is judged by the host label, and kill(0) only sees our own PID namespace. Two processes sharing a workspace and a host label but in different PID namespaces (containers, or a sandbox with its own PID namespace) would each read the other's live pid as dead and reap it. Today's deployments give each container its own hostname, so this is latent. The docstring's "Residual risk" already covers PID reuse, and could name this too.
The live restart the PR body lists is still owed. It's on the core-launch path, so run it before merge.
liususan091219
left a comment
There was a problem hiding this comment.
Ready to approve.
Reviewed head 892c71342a3d573ff2ea6b9bf589c31009d6ab1a. I read the code, ran the tests, and ran revert controls in a throwaway worktree. No change-request condition fires. sonichi (Blue, code owner for src/) approved at this head.
What I checked in src/workspace_lock.py
- A live holder is never reaped by the new rule.
_holder_is_deadreturns True only onProcessLookupErrorfromos.kill(pid, 0). A running pid either succeeds or raises EPERM, and both read as alive. Signal 0 sends nothing, so the probe cannot harm the process it names. - PID reuse is safe. A dead holder whose pid now belongs to an unrelated process reads as alive, and the old heartbeat rule applies. That costs speed, never a wrong reap. A zombie holder (exited but not yet waited on) also reads alive: I planted one, and
acquirereturneddeferred. - Cross-host. A holder whose
hostdiffers is never probed, so another machine's pid is never checked locally. The_guardflock was already same-host only, so a workspace shared across hosts was never supported. - Two reapers racing. The probe and the reap both run under the
_guardflock. I planted a fresh lock owned by a dead pid and started 8 real processes acquiring at once, repeated 20 times. Each round had exactly 1reapedand 7deferred, 0 bad rounds. The later racers see the winner's live pid and defer. retain(). It marks only our own lock (pid and host match, under the guard). The bridge's hold branch (remote_gateway_bridge.py:4972) wraps it in try/except, so a failed retain falls back to today's behaviour.heartbeat()rewrites the holder dict and keepsretained. A freshacquirewrites a new payload, which clears it.- Vendored copy: byte-identical to
src/(cmp), andsync_from_src.py --checksays in sync.
Tests at head: tests/workspace-lock.test.py 29 OK, tests/remote-gateway-singleton.test.py 10 OK, test_pool_advertisement_push.py ALL PASS. The tests drive real processes, not source greps. review-checks.sh PASS. No hardcoded paths in added lines.
Revert controls (applied to both copies of the lock module, restored after each):
- Dead-holder reap removed:
test_dead_same_host_holder_is_reaped_immediatelyand the bridge'stest_dead_holder_on_this_host_is_taken_over_at_oncefail. - EPERM treated as dead:
test_holder_alive_under_another_uid_is_deferredfails. - Host check removed:
test_dead_pid_on_another_host_follows_heartbeat_onlyfails. retainedignored: two retain tests fail.- Bridge
retaincall removed:test_a_held_exit_retains_the_lock_past_its_own_pidfails. pid <= 0guard orboolguard removed: still passes (nit 1).
CI / CLA: 35 checks pass, 2 skipped (Windows runtime, publish). license/cla = success with 1 context. No unresolved review threads.
Named, not blocking: no live post-restart round trip yet (core restart, bridge gets the lock back within seconds, then a task in and a reply out). The PR body says so too. It is on the core-launch path, so it should be run before merge.
Nits (none blocking):
test_unprobeable_pid_is_not_reaped_while_freshdoes not pin thepid <= 0orboolguards.kill(0, 0)andkill(-1, 0)succeed andTrueprobes pid 1 (EPERM), so each reads alive anyway. The guards are still worth keeping. The test just cannot tell whether they are there.- Blue's point stands:
kill(pid, 0)only sees our own PID namespace. Two holders sharing a workspace and host label in different PID namespaces would read each other as dead. That is latent today. One docstring line next to "Residual risk" would cover it. test_holder_alive_under_another_uid_is_deferredassumes the runner is not root. Under root,kill(1, 0)succeeds, so it still passes, but it no longer tests EPERM.
— Sutando (susans-MacBook-Pro)
Resolve the import conflict in tests/remote-gateway-singleton.test.py by keeping both sides: main's `signal` (signal-exit log tests, #5335) and this branch's `subprocess` (dead-holder reap tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Code-owner review for packages/ag2-sparrow/, at head 1d19178c (the merge of main). Approving.
Verified (throwaway worktree, real processes):
- Bug on main (merge-base
9c60c80a): a SIGKILLed same-host holder with a fresh lock makes a successordeferred; at HEAD the same harness givesreapedin 1 ms. _holder_is_dead(workspace_lock.py:170-183): False on Windows, other host,retained, non-int/bool/≤0 pid; True only onProcessLookupError; EPERM reads alive.acquire()probes only a fresh holder, under_guard.retain()checks pid+host under the guard. Both copies identical (cmp),sync_from_src.py --checkin sync,test_no_drift.pyPASS.- Blast radius: the bridge is the only acquire/heartbeat/retain caller;
health-check.pyreads lock files read-only. A live same-namespace process can never raiseProcessLookupError, and PID reuse reads alive, so the only wrong direction (reaping a live holder) is closed. The PID-namespace caveat both earlier reviews named stands, as latent. - Host label: the monorepo launcher (
src/remote-gateway-bridge.py) putssrc/first onsys.path, so it uses theutil_pathslabel throughout; a standaloneag2-sparrowusesgethostname. Each is stable within its process, so holder and successor of one launcher agree. A mismatch only disables the speedup, never reaps a live holder. - Tests at HEAD:
workspace-lock29 OK,remote-gateway-singleton14 OK,test_pool_advertisement_pushALL PASS,review-checks.sh --diffPASS. Revert control (reap disabled in both copies):test_dead_same_host_holder_is_reaped_immediatelyandtest_dead_holder_on_this_host_is_taken_over_at_oncefail. - Merge commit: diff-of-diffs against the PR's own commits differs only in hunk offsets and the
import signalcontext line from #5335; both imports kept, nothing else diverges from main. - Composes with #5336 (dry merge: adjacent import lines only; combined tree passes singleton 20 OK, heartbeat-thread 3/3, workspace-lock 29 OK). After both, a dead holder is reaped at once and a hung-alive one after ≤330+90 s.
- CI green (Windows runtime + publish skipped),
license/clasuccess, CHANGELOG-PENDING entry present, no hardcoded paths.
Nits, non-blocking:
remote_gateway_bridge.py:5245-5252: the hold branch logs "holding the lock so a successor waits" and then swallows a failedretain(). Under this PR a failed retain means the successor reaps the dead pid immediately, so the hold and the log line are both wrong in that case; log the failure. (The earlier claim that a failed retain "falls back to today's behaviour" is not accurate: today's is a 90 s wait.)- Rolling upgrade: an old-generation bridge's in-flight hold carries no
retained, so a new-generation successor reaps it at once. One advertisement overwrite, once; worth a line in the body. - Mixed launchers on one host (standalone desktop sparrow vs the monorepo shim) record different labels, so the reap does not fire across generations. Safe, but check which generation pid 1171 in #5312 was.
Not verified: the live post-restart round trip the body itself lists as owed. Both earlier reviews named it as non-blocking; I agree, and it should be run on the core-launch path before merge.
|
Live witness after a restart, on Rui's Mac (owner's go-ahead at 2026-10-11T03:32Z). Times are UTC. Setup: a scratch clone of Result: the dead holder was reaped on the first retry, under 1 s after the kill. Its heartbeat was still fresh, so the reap came from the dead-pid probe, not from staleness. The old rule would have deferred for 90 s or more. Before this, the issue's log shows about 136 s. Restore: the stock gateway was relaunched with its original pane command. At 03:42:53 it logged Caveat: the run used all of |
The except around _ws_retain in _release_singleton swallowed the error, so a held exit whose lock fell back to an unretained (reapable) state left no trace. Log one line instead; behaviour is otherwise unchanged. Follow-up to #5313. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What changed, and why
Refs #5312 (one of two halves; the supervisor half is in the desktop repo, see below).
After a core restart the replacement gateway bridge deferred to
pid=1171for ~136 s (issue log: fiveexit 75runs, thenacquired workspace poller lock (reaped)at 12:49:07).workspace_lock.acquire()judged a holder only by heartbeat age (90 s), so a bridge that died without releasing — SIGHUP from a recreated tmux window, SIGKILL — left a lock that read fresh with nobody behind it. The desktop repo already works around this from the outside (sutando-ctl.shTERMs bridges beforekill-window;GATEWAY_KICK_LOCKinlib.rs), which shows the same ghost-lock class recurring.src/workspace_lock.pyacquire(): a holder whosehostequals this host and whose pid no longer exists (os.kill(pid, 0)->ProcessLookupError) is reaped at once.EPERMcounts as alive. A holder on another host is judged by heartbeat only, and a live pid is still never proof of ownership. A non-int, bool or non-positive pid is never probed (pid 0 would signal the process group). No probe on Windows, whereos.kill(pid, 0)terminates the process.retain(role): marks our own lockretained, so it ages out by heartbeat as before instead of being reaped once our pid is gone. The bridge's existing shutdown hold (publication still in flight, so it deliberately keeps the lock for a successor to wait out) now calls it. Without this, the new rule would have undone that hold the moment the process exited.packages/ag2-sparrow/ag2_sparrow/workspace_lock.pyis regenerated bytools/sync_from_src.py(--check:in sync with src/ ✓).Look at first
src/workspace_lock.py(_holder_is_dead, theacquirecondition,retain), then the 4-line hold-branch change inremote_gateway_bridge.py::_release_singleton.Behaviour this proves
A bridge killed without releasing no longer costs its successor the stale window when both sit on one host. Real processes, no mocks (
repro-killed-holder.sh: a holder takes the lock through the vendored module, getskill -9, then a successor callsacquire):Before / after tests
The new tests are copied onto a detached worktree at the parent
586ec6ef:At HEAD:
Mutation control: with the
retainedguard removed from_holder_is_dead,test_retained_dead_holder_ages_out_by_heartbeatandtest_retained_lock_survives_its_holder_exitingfail. So the retain tests check the guard and would not pass without it.New / changed tests:
reapedimmediately (lock and bridge level)deferreddeferreddeferredwhile fresh,reapedonly once stale (heartbeat rule only)0,-1,True,"123",None) never reaped while freshretain()marks only our own lock; a retained lock survives its holder's real exit; a retained dead holder ages out by heartbeatretainedpid=999999now plant a pid that is actually running (os.getppid()), because 999999 is a dead pid and is now correctly reaped. The O_EXCL concurrency test keeps every racer alive until all eight have tried. Before, the winner exited first and could be reaped by a later racer, which is the correct new behaviour but made the old test racy.Edge cases / non-happy paths
util_paths._host_label): a remote holder's pid would be probed locally. The same label already drives the existing idempotent re-entry check, so this PR adds no new trust in it.acquirerecords the transient CLI process's pid, so a CLI-acquired lock is reapable once the CLI returns. No production caller uses the CLI acquire (grep: only the bridge, via the Python API). CLIheartbeat/releasealready compare the CLI's own pid and can never match a previous invocation.nt;windows-runtime.ymlrunsworkspace-lock.test.py).Live path / round trip
Not verified live. I did not restart, kill or touch the running gateway, bridge or core on the host this was written on. The evidence is the unit tests plus the real-process repro above. A reviewer should still require a real post-restart round trip (core restart -> bridge reacquires within seconds -> a task in, a reply out) on a host running this head before approval.
Docs
N/A: no user-visible command or doc surface changes. The lock semantics are documented in the module docstring, which this PR updates.
Risks / rollback
Revert this commit. The lock file schema is unchanged apart from the optional
retainedkey, which older readers ignore.Follow-up / known gaps
gateway-keepalive.sh(owned byag2-space/ag2space-cinny-desktop) sends exit 75 into the generic 5/10/20/40/60 s backoff. ag2-space/ag2space-cinny-desktop#786 makes 75 a short fixed retry inside the stale window.Refs, notCloses.🤖 Generated with Claude Code