Skip to content

fix(instance-id): distinguish a failed ps observation from proof of death (#954) - #970

Open
fujibee wants to merge 4 commits into
mainfrom
fix/954-pid-alive-observation
Open

fix(instance-id): distinguish a failed ps observation from proof of death (#954)#970
fujibee wants to merge 4 commits into
mainfrom
fix/954-pid-alive-observation

Conversation

@fujibee

@fujibee fujibee commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Fixes #954.

_agmsg_pid_alive_local's ps cross-check (reached only after kill(2) returns ESRCH) read an EMPTY ps result as "gone". A transient ps failure produces the same empty output as a truly-absent pid, so a blip returned "dead" — and roughly 38 call sites act destructively on that: the sync-engine status oracle marks a live engine "stale" (which orphans it and drives a human sync start into a double-start), the roster lock is released beside a live journal writer, the launcher reuse-check respawns a duplicate bridge, session-start's watcher GC deletes a live watcher's pidfile, and more. Because they all route through this one leaf helper, a single fix covers them.

An audit of every caller classified 25 as HARMFUL-if-a-live-pid-reads-dead (they need "definitely absent") and 13 as BENIGN (a false "absent" only skips an optimization, mislabels a status line, or retries next tick). No caller needs "assume-dead-on-doubt", so the helper can return alive on an unresolved observation and every caller is left in a safe state.

The fix

Mix a known-live pid into the SAME observation — our own $$, alive by definition:

  • $$ absent from the output ⇒ ps could not answer ⇒ UNKNOWN ⇒ assume alive, exactly as the existing EPERM branch does. A failed observation is not proof of absence.
  • $$ present, target absent ⇒ ps answered and did not list the target ⇒ positive proof the target is gone ⇒ dead.
  • target present but a zombie ⇒ gone too.

A pid ps rejects outright ("process id too large") poisons any query it appears in; one retry (a transient failure does not repeat, a poison pid does) plus a $$-only probe tells "ps works, only the target is unacceptable ⇒ gone" from "ps cannot observe at all ⇒ alive", so a valid-but-unreal pid still reads dead without reintroducing a false-dead for a real one. Parsed with bash builtins (no awk/grep) so a stripped PATH cannot itself become the failed observation — only ps is external.

Tests

Both halves are asserted with the SAME genuinely-dead pid, so a result of "alive" can only be the canary suppressing the death verdict, never the pid being live: proven-dead ⇒ cleanup fires; ps-cannot-answer ⇒ reads alive and cleanup is suppressed; ps-answers-but-omits-the-target ⇒ dead. Full instance-id suite, and the delivery / remote / watch / launcher caller suites, pass.

Out of scope

The Windows tasklist | grep -q path in _agmsg_pid_alive has the same observation-as-absence shape and is left for a separate change (it needs a Windows environment to exercise).

@fujibee

fujibee commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Correcting the park reason recorded earlier (it named the wrong blocker).

Measured from the actual CI run (32594790457, 2026-08-22), the reds are:

  • bats (macos-latest 2/4)not ok 13 sync start: starts a stopped engine and status reports it running (tests/test_remote_status_liveness.bats:356). A stop→start timing flake; passes locally, and does not exercise this change (the ps cross-check runs only after kill -0 returns ESRCH; "already running" is kill -0 succeeding on a still-shutting-down engine).
  • bats (ubuntu-latest 4/4)not ok 248 launcher: an alice reaper does not kill a bridge that also serves bob (pair superset) (#937). This is a main-side failure, tracked in Two launcher tests fail on main, both naming the closed #937 #984, not caused by this change (the reaper's kill decision uses the start-token/pgrep path, not the helper touched here).
  • bats (windows-latest, windows runtime)CANCELLED, not a failure. The earlier note that a sqlite download (Every Windows bats leg is gated on a third-party package feed being up #824) blocked green was wrong: this leg did not fail on the change, it was cancelled.

So the blockers are two flakes, one of which (#984) is a pre-existing main-side problem — not an infra wall that makes green unreachable. co1 cleared the change; every relevant local suite passes (instance-id, delivery, remote, launcher, and test_remote_status_liveness). A fresh CI take is planned once the engine-start flake fix (#972) lands.

…stinguish it with a $$ canary (#954)

_agmsg_pid_alive_local's ps cross-check (reached after kill(2) returns ESRCH) read
an EMPTY ps result as "gone". But a transient ps failure produces the same empty
output as a truly-absent pid, so a blip returned "dead" -- and its ~38 callers
delete pidfiles/records, release locks, reclaim runtime locks, and respawn on that
false (the central sync-engine status oracle, the roster lock release, the
launcher reuse-check, session-start's watcher GC, and more; leaf fix, so all are
covered at once). This is the same observation-as-state trap fixed across the reap
path in #943, now applied to the shared helper itself.

Mix a known-live pid into the SAME observation: our own $$, alive by definition.
Query the target and $$ together --
  - $$ absent from the output  => ps could not answer => UNKNOWN => assume alive,
    exactly as the existing EPERM branch does. A failed observation is not proof.
  - $$ present, target absent  => ps answered and did not list the target
    => positive proof the target is gone => dead.
  - target present, zombie     => gone too.
A pid ps rejects outright ("process id too large") poisons any query it appears
in; one retry (a transient failure does not repeat, a poison pid does) plus a
$$-only probe tells "ps works, only the target is unacceptable => gone" from "ps
cannot observe at all => alive", so a valid-but-unreal pid still reads dead without
reintroducing a false-dead for a real one. Parsed with builtins (no awk/grep) so a
stripped PATH cannot itself become the failed observation -- only ps is external.

Tests assert both halves with the SAME genuinely-dead pid, so a result of "alive"
can only be the canary suppressing the death verdict, never the pid being live:
proven-dead => cleanup fires; ps-cannot-answer => reads alive, cleanup suppressed;
ps-answers-but-omits-target => dead.
…ered query (#954)

The previous form queried the target and $$ together (`ps -p "$pid,$$"`) and, when
that produced no canary twice, fell back to a $$-only probe and inferred "the
target poisoned the query -> dead". That inference is unsound: a successful
$$-only probe proves only that ps could list itself on that third call, not that
the two earlier combined observations failed because of the target. Two transient
failures of the multi-pid query followed by a working $$-only probe -- or any
partial output that omits the canary -- produce the same sequence for a genuinely
live target, so UNKNOWN could still reach the cleanup side as "dead".

Remove the inference entirely by never handing the target to ps. Take one FULL
snapshot (`ps -Ao pid=,stat=`) and parse it with builtins for both the target and
$$: canary present + target absent is the only path to "dead"; canary absent is
always "alive". Because the target pid is not a query argument, an out-of-range
value cannot poison the observation (it is simply not in the snapshot -> gone), so
the retry and the $$-only fallback are gone with it. Add a test pinning that a
snapshot which returns output but omits the canary is still UNKNOWN -> alive and
suppresses cleanup, using the same genuinely-dead fixture.
…#954)

The full snapshot removed the poison, but the verdict still ignored ps's exit
status. ps can print part of a listing -- even our own $$ -- and then fail
non-zero; a target missing from that TRUNCATED output is not proof it is gone, its
line may simply never have been reached. The canary shows only that WE were listed,
never that the listing FINISHED, so "canary present + target absent" could still
read a live pid as dead when ps died mid-write.

Capture the exit status and gate on it: a "gone" verdict now requires ps to have
exited 0 AND that completed snapshot to include $$. Any non-zero exit reads as
UNKNOWN -> alive regardless of the partial output -- which also makes a platform
whose ps does not support `-Ao` fail safe (it exits non-zero rather than lying
"gone"). A present target is still alive on sight (seeing its line is proof enough,
even from a partial listing). Test added: a ps that prints the canary and then
exits non-zero must read alive and suppress cleanup.
Capturing the snapshot as `probe="$(ps ...)"` returns ps's exit status as the
assignment's own. Under errexit, in a caller that did NOT invoke the helper as a
condition (if/while/! suppress errexit inside the function; a bare statement does
not), a non-zero ps would terminate the shell at that assignment -- before rc is
captured and before the UNKNOWN => alive verdict -- so a failed observation leaks
to caller-process death instead of "alive". The leaf helper's contract cannot
depend on how each of its ~38 call sites spelled the call.

Default rc=0 and capture a failure with `|| rc=$?`, which places the assignment in
a condition so errexit does not fire, while still recording ps's real exit status
for the exit-0 gate. Test added: called as a bare statement under `set -e` with a
failing ps, the caller shell continues and gets the alive verdict.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The sanctioned liveness helper treats a failed ps as proof of absence, and 31 call sites read false as 'safe to take over'

1 participant