fix(telegram): reclaim the lock of a self-retired daemon owner - #4599
fix(telegram): reclaim the lock of a self-retired daemon owner#4599Yeachan-Heo wants to merge 2 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
PR #4599 review disposition — needs-human (head Adversarial review: CLEAR.
Local reproduction: CI 31915577912 reconciliation: no PR-owned failure.
Blocker (human-only): merge requires an authorized non-author exact-head No release/tag/publish/main mutation performed. |
|
Fix-forward pushed: What the red-team lane found (ADV-05, real deviation): recovery normalized The fix: normalize Evidence: new regression test Still blocking merge (human-only): authorized non-author exact-head |
|
Dev CI on
Remaining to merge, both outside this lane's authority: (1) exact-head |
A daemon that finishes its shutdown but never exits keeps its PID alive while still holding the ownership lock. Recovery accepted process liveness as the only proof of ownership, so it read that owner's own stoppedAt marker, reported left-active, and left the lock in place forever. The acquisition path had already stopped treating such a tombstone as a live owner, so the two paths disagreed about who owns the daemon. Observed in the field: two wedged owners held the lock with heartbeats frozen for minutes and no sockets open, while gjc notify recovery refused both with "live daemon owned by pid ... left untouched". They had to be killed by hand before notifications could recover. Lore-id: e2c228ab Constraint: a live owner without a stop marker must stay protected -- a paused or merely slow owner must never be reclaimed Constraint: reclaim keeps running under the steal-mutex with the exact-owner re-check, so it cannot race a concurrent takeover Rejected: reclaim on a stale heartbeat alone | a SIGSTOPped owner still holds the Telegram single-poller slot and would be stolen from Rejected: killing the wedged process | recovery never kills processes, and the lock is the only thing that must move Confidence: high Scope-risk: narrow Reversibility: easy Tested: recovery clears a retired-but-alive owner and reports it as retired; a live owner without a stop marker is still left active Not-tested: multi-host contention for the same lock on a shared volume
Recovery normalized stoppedAt with finiteNonNegativeNumber, so a fractional (or otherwise non-safe-integer) stoppedAt counted as owner consent and let gjc notify recovery unlink a live pid's lock. No compliant writer can emit such a marker — the daemon's own shape predicate requires Number.isSafeInteger — so recovery was again answering a state acquisition would never treat as a stopped owner. Red-team harness case m4 proved the unlink (stoppedAt 1.5 + live pid -> cleared-dead-owner-lock). Normalize stoppedAt with the same safeNonNegativeInteger rule; a non-canonical marker now reads as marker-less and the owner stays left-active. The fractional field was also surfaced by health as stopped:true; it now reports not stopped for the same reason. Lore-id: e2c228ab Constraint: a canonical marker (safe non-negative integer) is exactly what markDaemonOwnerStopped writes via writeJsonAtomic; anything else is not owner consent Constraint: stoppedAt 0 stays consent because hasSafeDaemonStateShape accepts it — rejecting it would reintroduce acquisition/recovery divergence Rejected: validating the whole state with hasSafeDaemonStateShape in recovery | recovery must keep reading pre-shape legacy states; only the consent field needs the canonical rule Confidence: high Scope-risk: narrow Reversibility: easy Tested: 79/79 notifications-service tests incl. new fractional-marker regression (red on the unfixed source); health fractional-stoppedAt expectation updated to not-stopped Not-tested: multi-host contention for the same lock on a shared volume
f78fe82 to
8544bcb
Compare
|
Rebased onto current Local verification at new head — |
What broke
Telegram notifications stopped for every session on my machine tonight. Root-causing it turned up two separate defects; this PR fixes the one that lives on
dev.A daemon that finishes its shutdown but never exits keeps its PID alive while still holding the ownership lock. Recovery accepts process liveness as the only proof of ownership:
…with
removeDeadOwnerLockre-checking the same thing (if (pidAlive(current.pid)) return "now-alive").The sharp edge:
stoppedAtis already parsed intoNormalizedDaemonStateand is even surfaced by status (stopped: state?.stoppedAt !== undefined) — reclaim just never consulted it. So an owner that had written its own retirement notice, stopped heartbeating, and torn down every socket was still classified as a live owner and protected indefinitely.That also put recovery in direct contradiction with the acquisition path, which had already stopped believing that tombstone:
classifyForeignLiveOwner: "A stopped tombstone with a canonical acquisition is not a live owner"isFreshLiveOwner: requiresnow - heartbeatAt <= HEARTBEAT_TTL_MSandpidAliveField evidence
Two wedged owners (pids
53382,13899) held the lock with heartbeats frozen for minutes and zero sockets open.gjc notify recoveryrefused both:They had to be killed by hand. Ownership itself kept migrating correctly (acquisition ignored them), so the cost was leaked wedged processes plus operator tooling confidently reporting a "live daemon" that was doing nothing.
The change
Recovery now uses the same liveness definition acquisition already uses: an owner that published
stoppedAtis reclaimable even if its PID lingers.Deliberately narrow:
SIGSTOPped or merely slow owner must never be reclaimed, because it still holds the Telegram single-poller slot.Verification
bun test packages/coding-agent/test/notifications-service.test.ts— 78 pass / 0 failbun --cwd=packages/coding-agent run check(biome + tsc) — exit 0 (the 11 biome warnings are pre-existing ondevinedit-result-persistence-bounding.test.ts, untouched here)clears the lock of a retired owner whose process never exitedstill protects a live owner that has not published a stop markerKnown follow-ups (not in this PR)
Why the process never exits after
run()completes. Both wedged owners emitted"shutdown persistence failed"and"shutdown was not durably quiesced"— the last lines ofrun()'sfinally— sorun()returned and the process simply never exited. Some handle survives teardown. This PR stops a wedged owner from pinning the lock; it does not stop the wedge. I explicitly did not ship a speculative fix here: my first hypothesis (an unboundedbeginToolActivityShutdown()await) was disproved by a test that passed without the fix.The shared topic registry is all-or-nothing. One record this build cannot parse makes the whole snapshot unreadable (
malformed Telegram topic state), which empties the registry, prevents topic-lease renewal, drops every attached session, and idle-exits the owner with zero attachments — a total notification outage from one bad record.That is what actually caused my outage, but as version skew: a
devdaemon wrote records carryingtelegramBindingwith a chat-only binding, and myv0.13.3build (1174 commits behind) rejected all 179 records. I verifieddev's parser accepts that data, sodevis not broken by it today — but the hazard is structural and reciprocal: the next version will do todevwhatdevdid tomain. Fixing it needs record-level isolation plus a repair/quarantine seam, anddevno longer has thehealTelegramDaemonNotificationStateseam that exists on the release line, so it needs a design pass rather than a port. Happy to open that separately.gajae.pr-review-verdict.v1 needs-human sha256:14a44c8259bb9c0c53f471e69c325d0d20bb0f26aab400eb0bd0ec19776f2fd2 reviewer:human reviewer-id:bellman-clawdbot evidence:fix-forward-f78fe820ec-on-top-of-fddf532218-red-team-found-fractional-stoppedAt-1.5-treated-as-consent-unlinking-a-live-pid-lock-while-canonical-hasSafeDaemonStateShape-rejects-it-fixed-by-normalizing-stoppedAt-with-safeNonNegativeInteger-new-regression-test-red-on-unfixed-source-79-of-79-pass-check-exit-0;CI-31915577912-and-31917778038-no-PR-owned-failure-shard1-3-files-all-END_PATCH_MARKER-TDZ-identical-to-base-dev-31912741125-owned-by-4597;blocking-needs-authorized-non-author-exact-head-APPROVED-review-on-f78fe820ec-probepark-HaD0Yun-or-IYENTeam-only-credential-here-is-author-Yeachan-Heo