Skip to content

feat(process): escalate SIGTERM→SIGKILL on host-pid termination after grace (#15008) - #50489

Merged
teknium1 merged 3 commits into
mainfrom
feat/daemon-sigkill-escalation
Jun 22, 2026
Merged

feat(process): escalate SIGTERM→SIGKILL on host-pid termination after grace (#15008)#50489
teknium1 merged 3 commits into
mainfrom
feat/daemon-sigkill-escalation

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

A host daemon that ignores or stalls in its SIGTERM handler no longer leaks indefinitely — _terminate_host_pid now escalates to SIGKILL after a bounded, config-gated grace window. Implements the escalation idea from @tkwong's #15008, reworked onto the current tree-kill path and config-gated (not env-var-gated).

Root cause: _terminate_host_pid SIGTERMed the process tree and returned. A daemon that traps/stalls on SIGTERM (some agent-browser daemons do) survives the reap and leaks until reboot — observed as daemons accumulating to EMFILE on long-running gateways.

Changes

  • tools/process_registry.py: _terminate_host_pid snapshots the tree, SIGTERMs it (children first), then psutil.wait_procs(grace) and SIGKILLs any survivor. New _daemon_term_grace_seconds() reads the grace from config (floored at 0; 0 disables escalation). The recycled-PID identity guard runs first and still gates the entire path — escalation can never reach a stranger. Windows path unchanged (taskkill /F is already a hard kill).
  • hermes_cli/config.py: terminal.daemon_term_grace_seconds (default 2.0) — config.yaml, not an env var, per the .env-secrets-only policy. (fix(tools): escalate SIGTERM→SIGKILL on browser daemon + periodic orphan reap #15008 used BROWSER_* env vars, which is why it wasn't salvaged as-is.)
  • Tests: TestSigkillEscalation — real SIGTERM-trapping subprocesses prove escalation kills them, grace=0 spares them, well-behaved procs die on SIGTERM, the recycled-PID guard is not bypassed, and a negative grace floors to 0. Two pre-existing terminate tests pin grace to 0 (they cover SIGTERM ordering, not escalation).

Validation

Result
tests/tools/test_process_registry.py 84 passed (3 runs, no flake)
test_browser_orphan_reaper.py + test_browser_cleanup.py (call _terminate_host_pid) 32 passed
E2E, real SIGTERM-trapping process escalated to SIGKILL after ~grace
E2E grace=0 SIGTERM-ignoring process survives
E2E recycled-PID mismatch spared — no SIGTERM, no SIGKILL

Completes the process-kill-safety arc from this session (#50417 browser reaper identity, #50468 recycled-PID guard) by closing the "daemon ignores SIGTERM → leaks" gap. Credit @tkwong (Benjamin Wong) for the original escalation idea in #15008.

Infographic

sigterm-sigkill-escalation

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: feat/daemon-sigkill-escalation 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: 11378 on HEAD, 11367 on base (🆕 +11)

🆕 New issues (8):

Rule Count
invalid-argument-type 4
not-subscriptable 3
unresolved-attribute 1
First entries
tests/tools/test_process_registry.py:1565: [unresolved-attribute] unresolved-attribute: Attribute `readline` is not defined on `None` in union `IO[Any] | None`
tools/process_registry.py:513: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
tools/process_registry.py:513: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
tools/process_registry.py:513: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `Literal["daemon_term_grace_seconds"]` on object of type `list[Unknown]`
tools/process_registry.py:514: [invalid-argument-type] invalid-argument-type: Argument to constructor `float.__new__` is incorrect: Expected `str | Buffer | SupportsFloat | SupportsIndex`, found `Unknown | int | str | ... omitted 15 union elements`
tools/process_registry.py:513: [not-subscriptable] not-subscriptable: Cannot subscript object of type `int` with no `__getitem__` method
tools/process_registry.py:513: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `bound method str.__getitem__(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str` cannot be called with key of type `Literal["daemon_term_grace_seconds"]` on object of type `str`
tools/process_registry.py:513: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> str, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[str]]` cannot be called with key of type `Literal["daemon_term_grace_seconds"]` on object of type `list[str]`

✅ Fixed issues: none

Unchanged: 5975 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 comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists labels Jun 22, 2026
teknium1 and others added 3 commits June 21, 2026 18:10
…r grace

A daemon that ignores or stalls in its SIGTERM handler currently survives the
process-registry reap and leaks until reboot (observed as agent-browser
daemons accumulating to EMFILE on long-running gateways). _terminate_host_pid
now snapshots the tree, SIGTERMs it, waits a bounded grace window
(terminal.daemon_term_grace_seconds, default 2.0s, 0 disables), then SIGKILLs
any survivor. The recycled-PID identity guard still gates the whole path, so
escalation never reaches a stranger; Windows is unchanged (taskkill /F is
already a hard kill).

Config lives in config.yaml (terminal.daemon_term_grace_seconds), NOT an env
var, per the .env-secrets-only policy.

Implements the SIGKILL-escalation idea from @tkwong's #15008, reworked onto the
current _terminate_host_pid tree-kill path (the original predated it) and
config-gated instead of env-var-gated.

Co-authored-by: Benjamin Wong <tkwong@inspiresynergy.com>
…cs survivors

Live testing against a real SIGTERM-ignoring process TREE (parent + children,
the agent-browser daemon + renderer shape) revealed psutil.wait_procs's
gone/alive partition mis-handles a parent/child tree: it reaps via
Process.wait() and could mark targets gone/alive inconsistently across the
tree, leaving survivors un-killed (flaky — sometimes the parent lived,
sometimes a child). Replace it with: sleep out the grace window, then
directly re-probe every captured target (_proc_alive, treating zombies as
dead) and SIGKILL any that's still running. Add a multi-child-tree regression
test. 6/6 escalation tests green across repeated runs; the real-tree E2E now
kills the full tree 6/6 runs.
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 P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants