Skip to content

fix(herdr): release the pane when the process is signalled, not just on clean exit - #55

Open
JulienEllie wants to merge 1 commit into
mpfaffenberger:mainfrom
JulienEllie:fix/herdr-release-pane-on-signal-exit
Open

fix(herdr): release the pane when the process is signalled, not just on clean exit#55
JulienEllie wants to merge 1 commit into
mpfaffenberger:mainfrom
JulienEllie:fix/herdr-release-pane-on-signal-exit

Conversation

@JulienEllie

Copy link
Copy Markdown
Contributor

The bug

herdr keeps showing a dead code-puppy in its agent list after the process is gone. The entry never expires — herdr trusts a reported agent claim indefinitely — so the sidebar slowly fills with ghosts that can only be cleared by hand.

Root cause

pane.release_agent is only sent from the shutdown / session_end callbacks, which fire from a finally: in cli_runner:

finally:
    await callbacks.on_session_end()   # -> herdr release_and_close()
    await callbacks.on_shutdown()

A finally: only runs if the interpreter unwinds. SIGTERM and SIGHUP don't unwind — their default disposition terminates the process outright. Closing a terminal pane, a plain kill, a logout, and service restarts all send SIGTERM, so the release is simply never sent.

Measured against a live herdr 0.8.2 pane, spawning a real code-puppy -i each time:

Exit path Process herdr pane
/exit dead released in ~1s
Ctrl-D dead released
SIGTERM dead stuck codepuppy/idle, permanently
SIGKILL dead stuck codepuppy/idle, permanently

I watched a stranded pane for 30s; herdr never reaps it. Its title had already fallen back to a bare shell prompt while herdr still reported agent=codepuppy.

Notably, other plugins in this package already defend against this (walmart_specific, conversation_scanner, browser_manager all register atexit); herdr registers nothing, and the only signal.signal calls in core are for SIGINT.

The fix

Two guards installed in the existing if _reporter.active: block.

Both are required — this is the part worth reviewing. My first instinct was an atexit one-liner. Before writing it I checked the assumption, and atexit does not run on SIGTERM:

--- sending SIGTERM ---
atexit output after SIGTERM: []      # never ran

So an atexit-only patch would have looked perfectly reasonable in review and fixed nothing at all. Hence:

  • atexit — covers interpreter teardown paths that bypass the callback.
  • a SIGTERM/SIGHUP handler — covers the signal paths atexit misses.

The handler releases, then chains any previously-installed handler, otherwise restores the default disposition and re-raises — so the process still dies from the signal with a correct 128 + signum status. Verified rc=143 for SIGTERM. release_and_close() is already idempotent and bounded, so double-firing is harmless and an unreachable herdr can't delay exit.

SIGHUP is resolved via getattr rather than referenced directly: it doesn't exist on Windows, where this module is still imported (client.py ships the named-pipe transport from #695). A bare signal.SIGHUP would raise AttributeError at import and disable the plugin outright — confirmed by simulating a SIGHUP-less signal module.

SIGKILL is out of scope and left explicitly documented — nothing in-process can run. Reaping that case needs a herdr-side liveness check on the reporting PID; happy to open an issue there if useful.

Verification

End-to-end against a live herdr pane, same script both ways:

### BASELINE (unpatched)   after SIGTERM: ('codepuppy','working')   FAIL
### PATCHED               after SIGTERM: (None,'unknown')           PASS
  • 6 new tests — registration, release-then-reraise, handler chaining, release failure, unavailable signals, platform-safe signal resolution.
  • The 5 behavioural tests all fail without the fix (verified by stashing the source), so they're not vacuous.
  • Full suite: 1983 → 1989 passing, with the same 36 pre-existing failures before and after. Those 36 are environment skew in my local pydantic-ai and are unrelated — I baselined them on clean main rather than assuming.
  • ruff check on the touched file: 4 warnings before, 4 after. No new lint. ruff format clean.

…on clean exit

herdr keeps showing a dead code-puppy in its agent list after the process
goes away. The stale entry never expires -- herdr trusts a reported agent
claim indefinitely -- so the sidebar accumulates ghosts.

pane.release_agent is only sent from the `shutdown`/`session_end` callbacks,
which fire from a `finally:` in cli_runner. That block runs only when the
interpreter unwinds. Closing a terminal pane, `kill`, logout, and service
restarts all deliver SIGTERM (or SIGHUP), whose default disposition
terminates the process outright: the `finally:` never runs and the release
is never sent.

Verified against a live herdr 0.8.2 pane:

    /exit    -> released
    Ctrl-D   -> released
    SIGTERM  -> STUCK as codepuppy/idle, forever
    SIGKILL  -> STUCK as codepuppy/idle, forever

Install two guards when the plugin activates:

* atexit -- covers interpreter teardown paths that bypass the callback.
* a SIGTERM/SIGHUP handler -- covers the signal paths atexit misses.

Both are needed. atexit does NOT run on SIGTERM (confirmed empirically),
so an atexit-only fix would not have addressed the reported bug at all.

The handler releases, then chains any previously-installed handler, or
else restores the default disposition and re-raises. The process still
dies from the signal with a 128+signum exit status; shutdown semantics
are unchanged. release_and_close() is already idempotent and bounded, so
double-firing is harmless and an unreachable herdr cannot delay exit.
Failures to install (non-main thread) and failures to release are both
swallowed -- reporting must never break the agent.

SIGHUP is resolved with getattr rather than referenced directly: it does
not exist on Windows, where this module is still imported because
client.py speaks a named pipe there, and a bare signal.SIGHUP would raise
AttributeError at import time and disable the plugin outright.

SIGKILL remains uncatchable by design; reaping that case needs a
herdr-side liveness check on the reporting process.

Tests: 6 new cases covering registration, release-then-reraise, handler
chaining, release failure, unavailable signals, and platform-safe signal
resolution. The 5 behavioural cases fail without the fix. Full suite:
1983 -> 1989 passing, same 36 pre-existing failures.
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.

1 participant