Skip to content

fix: re-validate PID identity before killing host processes (#43846) - #50468

Merged
teknium1 merged 7 commits into
mainfrom
salvage/pr-43846-kill-safety
Jun 22, 2026
Merged

fix: re-validate PID identity before killing host processes (#43846)#50468
teknium1 merged 7 commits into
mainfrom
salvage/pr-43846-kill-safety

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

The gateway can no longer SIGTERM an unrelated process because a stored PID got recycled onto it. Three independent kill paths now re-validate process identity (kernel start time) before signalling, and WhatsApp port cleanup only ever kills LISTENers — never clients. Salvages #43846 (@valentt). Same hazard class as the browser orphan-reaper fix (#14073 / #50417), in different subsystems.

Root cause: once a tracked process exits and is reaped, the kernel recycles its PID number onto an unrelated process. Code that signalled a stored PID guarded only by a liveness check then tree-killed the stranger — proven live by @valentt with a kernel signal:signal_generate tracepoint catching gateway → SIG15 → firefox.

Changes

  • tools/process_registry.py: record kernel start time (/proc/<pid>/stat field 22) at spawn (host_start_time), persisted in the checkpoint; _terminate_host_pid(pid, expected_start=…), checkpoint recovery, detached-session refresh, and kill_process all re-validate (pid, start_time) before signalling. No baseline (legacy) → degrades to prior liveness behavior.
  • plugins/platforms/whatsapp/adapter.py: _kill_stale_bridge_by_pidfile gates on identity (bridge.pid now records start time; legacy pidfiles fall back to a node+session-path cmdline signature); _kill_port_process targets only LISTEN-state sockets (lsof -sTCP:LISTEN, ss -ltnp fallback) instead of fuser/bare lsof -i, which also matched client connections sharing the port (default 3000 → a browser tab on :3000 was killed on every bridge restart).
  • Follow-up fixes by us on top of the salvage:
    • added missing import re to the relocated adapter (the ss fallback's re.finditer was a latent NameError — the adapter moved gateway/platforms/whatsapp.pyplugins/platforms/whatsapp/adapter.py since the PR was authored);
    • repointed the new test's import to the current module path;
    • replaced two fuser-asserting change-detector tests with LISTEN-only contract tests.

Validation

Result
test_process_registry.py + test_whatsapp_bridge_pidfile.py + test_whatsapp_connect.py 117 passed
E2E, real processes — recycled PID (start-time mismatch) spared
E2E — genuine PID (start-time match) terminated
E2E — WhatsApp recycled bridge PID spared; ss fallback runs without NameError

Authorship: @valentt's 3 commits preserved (rebased onto current main); 2 follow-up commits are ours. Closing #15008 (@tkwong, SIGTERM→SIGKILL escalation) separately — it's a different concern, 6.7k commits stale, and adds BROWSER_* env vars that violate the config.yaml-not-.env policy.

Infographic

kill-safety

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: salvage/pr-43846-kill-safety vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11365 on HEAD, 11364 on base (🆕 +1)

🆕 New issues (4):

Rule Count
invalid-assignment 1
unresolved-attribute 1
unsupported-operator 1
unresolved-import 1
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
tests/gateway/test_whatsapp_bridge_pidfile.py:179: [unresolved-attribute] unresolved-attribute: Attribute `readline` is not defined on `None` in union `IO[Any] | None`
tests/gateway/test_whatsapp_bridge_pidfile.py:85: [unsupported-operator] unsupported-operator: Operator `+` is not supported between objects of type `int | None` and `Literal[1]`
tests/gateway/test_whatsapp_bridge_pidfile.py:20: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues (2):

Rule Count
unresolved-attribute 2
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
run_agent.py:2984: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`

Unchanged: 5970 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery platform/whatsapp WhatsApp Business adapter labels Jun 21, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related: #43846 (original by @valentt, salvaged here with authorship preserved), #43847 (follow-up hardening tracker), #43044 (MCP orphan-reaper recycled-PGID sibling), #50417 / #14073 (merged browser-daemon analog of the same hazard class), #15008 (SIGTERM→SIGKILL escalation — a separate concern, intentionally not folded in). This salvage PR is the canonical kill-safety fix for process_registry.py + WhatsApp port cleanup.

@teknium1
teknium1 force-pushed the salvage/pr-43846-kill-safety branch from 75658b5 to d57998a Compare June 21, 2026 23:54
valentt and others added 7 commits June 21, 2026 17:11
…rocesses

The background-process registry signalled host PIDs (recovery adoption,
detached-session kill, tree-kill) using a number captured at spawn, guarded
only by a bare liveness check. Once a session's process exits and is reaped the
kernel recycles that PID onto an unrelated process, so an alive-but-different
PID passed the check and got tree-killed.

Observed in the wild: a recycled background-session PID landed on Firefox's
session leader; a later kill/refresh walked its process tree and SIGTERMed
every tab — Firefox "closing" at irregular intervals with no crash/coredump.

This is the same PID/PGID-recycling class fixed for the MCP orphan reaper in
7bd1f8a, but the process_registry subsystem was never guarded — so the bug
persisted.

Fix: record each host process's kernel start time (/proc/<pid>/stat field 22)
at spawn, persist it in the checkpoint, and re-validate it before every signal
via `_host_pid_is_ours`. A PID whose start time no longer matches — or that is
gone — is never signalled:
  - recover_from_checkpoint: a recycled PID is not adopted as a session.
  - _refresh_detached_session: a recycled detached PID is marked exited.
  - kill_process / _terminate_host_pid: refuse to tree-kill a stranger.
Legacy checkpoints and platforms without /proc (no baseline) degrade to the
prior best-effort liveness behaviour, so nothing else changes.

Adds TestPidReuseGuard: real-process tests proving a mismatched start time
refuses termination while a matching one still kills, plus recovery/refresh
recycling paths. 74 registry + 22 MCP-stability tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ile entry

`_kill_stale_bridge_by_pidfile` SIGTERMed the PID recorded in `bridge.pid`
after only a bare liveness check. Once the bridge exits and is reaped the
kernel recycles that PID onto an unrelated process; because the WhatsApp bridge
crash-loops ("Bridge process died (exit code 1)" repeating), this cleanup ran
on every restart and could SIGTERM a recycled PID that had landed on the user's
browser — closing Firefox at irregular intervals with no crash and no coredump
(a clean kill of a stranger).

Same PID-recycling class as the MCP reaper (7bd1f8a) and the process-registry
host-PID guard (e6a99cef2); this was the third, and most actively-fired, path.

Fix: `_write_bridge_pidfile` now also records the leader's kernel start time
(line 2). `_kill_stale_bridge_by_pidfile` re-validates identity via
`_bridge_pid_is_ours` before signalling — the (pid, start time) pair must match,
or for legacy single-line pidfiles the live cmdline must name `node` + this
session's unique path. A recycled PID (different start time / cmdline) is logged
and skipped, never signalled. Legacy pidfiles stay readable.

Adds TestWhatsappBridgePidfile: real-process tests proving a genuine bridge is
reaped while a recycled PID (start-time mismatch, or non-bridge cmdline) is
spared. 7 new + 108 gateway/registry tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r clients

This is the bug that was actually closing Firefox. `_kill_port_process`, run on
every bridge (re)start to free the port, used `lsof -ti :PORT` / `fuser PORT/tcp`
— both of which match a process whose socket merely *involves* that port number
in ANY state, including ESTABLISHED client connections. It then SIGTERMed every
match.

The bridge defaults to port 3000 — a ubiquitous local dev-server port. With a
browser tab open on localhost:3000, `lsof -ti :3000` returned Firefox's PID, so
each restart of the (crash-looping) WhatsApp bridge SIGTERMed Firefox, closing
the whole browser at irregular intervals with no crash and no coredump.

Proven live with the kernel `signal:signal_generate` tracepoint:
  hermes-gateway(3396516) -> sig=15 (code=0/SI_USER) -> comm=firefox pid=3371585
captured immediately after a gateway start, while Firefox held a socket on the
bridge port. Demonstrated over-match: `lsof -ti :8080` returns the listener AND
the gateway's own client connection; `lsof -ti tcp:8080 -sTCP:LISTEN` returns
only the listener.

Fix: `_listener_pids_on_port` resolves only LISTEN-state sockets
(`lsof -ti tcp:PORT -sTCP:LISTEN`, with an `ss -ltnp` fallback) and
`_kill_port_process` signals just those. A client whose connection happens to
involve the port number is never touched — which is also more correct, since a
client never blocks the new bridge from binding. Windows already filtered
LISTENING; the broad `fuser -k` path is removed.

Adds TestKillPortProcess: real-socket tests proving a separate client process
is excluded from the listener lookup and survives port cleanup. 9 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pter relocation

Follow-up to the salvaged #43846 commits: the WhatsApp adapter moved from
gateway/platforms/whatsapp.py to plugins/platforms/whatsapp/adapter.py since the
PR was authored. The cherry-pick brought _listener_pids_on_port's `re.finditer`
ss-fallback and the new test's import, but the new module location doesn't import
`re` (latent NameError on the lsof-absent fallback path) and the test imported the
old module path. Add `import re` to the adapter and repoint the test import.
…nce + retry connect)

The salvaged test spawned a listener subprocess that printed its port
immediately after bind() but BEFORE listen(), so under CI's loaded 8-worker
box the parent connected before the socket was listening -> ConnectionRefused
(flaked on test slice 2/6). Reorder the child to listen() then print the port,
and make the client connect with a short bounded retry to absorb scheduler
jitter. 15/15 green locally including direct hammering.
The PID-reuse guard (#43846) reads /proc/<pid>/stat field 22, which only
exists on Linux — on macOS/Windows it returned None and the guard silently
degraded to a bare liveness check (a no-op, safety-wise). Add a
psutil.create_time() fallback (psutil is a hard dep, cross-platform),
quantized to centiseconds for stable equality, so the recycled-PID guard
actually protects macOS/Windows too. /proc always wins first on Linux and
always misses on macOS/Windows, so the two sources never mix on one host and
same-source equality is all the guard needs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/whatsapp WhatsApp Business adapter type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants