Skip to content

Shut down gracefully on SIGHUP (terminal closed), respecting nohup - #152

Open
eazene wants to merge 1 commit into
NateBJones-Projects:mainfrom
eazene:fix/sighup-graceful-shutdown
Open

eazene wants to merge 1 commit into
NateBJones-Projects:mainfrom
eazene:fix/sighup-graceful-shutdown

Conversation

@eazene

@eazene eazene commented Oct 11, 2026 •

Copy link
Copy Markdown

Closing the terminal window that hosted ringer run killed in-progress stages outright. The run state stayed unfinished and worker processes were left orphaned, so a task that had done nothing wrong showed up as an error. We hit it when the operator's terminal app closed mid-stage.

The cause: the terminal sends SIGHUP, and ringer only installs handlers for SIGINT and SIGTERM. At Python's default disposition SIGHUP terminates the process immediately — none of the existing one-shot cleanup (worker group termination, state flush) runs.

What this changes

  • SIGHUP joins the graceful shutdown. It calls the same one-shot request_shutdown(), so the existing "second signal does not cancel cleanup" behavior applies to it too.
  • nohup is respected. If SIGHUP arrives inherited as SIG_IGN, no handler is installed, so nohup ./ringer.py run … keeps running through a hangup exactly as before.
  • A vanished terminal can't break cleanup. After a real hangup, writes to the tty fail with EIO. Without handling, cleanup does complete but the interpreter exits 120 on the final flush instead of 130. So the stdout/stderr fds that were ttys when the handlers were registered are pointed at /dev/null before shutdown. Pipes and files keep their output (the summary still arrives), and shutdown is requested even if that redirect fails.
  • Windows unchanged. SIGHUP is absent there and is guarded with hasattr; loop signal handlers stay suppressed as before.

Proof

Three tests, plus the existing SIGTERM test refactored into a shared helper:

  • test_sighup_cleans_up_active_worker_and_finishes_state: SIGHUP over a pipe → exit 130, worker gone, state finished, and Your results: still in the output.
  • test_terminal_hangup_cleans_up_even_though_output_is_gone: ringer runs on a pty, the controller is closed before the signal (a real window close) → exit 130, worker gone, state finished.
  • test_sighup_is_ignored_under_nohup: started with SIGHUP ignored, a SIGHUP leaves both the run and the worker alive; SIGTERM then shuts down cleanly.

The first two fail on current main (-1 and 120 instead of 130) and pass with the change. The full suite passes locally on macOS (python3 -m unittest discover -s tests, 287 tests, RINGER_NO_SELF_UPDATE=1).

Small and single-purpose: ringer.py +27/−2 (mostly comments and the guarded redirect), the rest is tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VuxjLwN47bGDGAKEv5Nfz6

Closing the terminal that hosts `ringer run` sends SIGHUP. Ringer handled
only SIGINT and SIGTERM, so Python's default killed it at once: workers
were orphaned and the run state was never finished.

SIGHUP now goes through the same one-shot graceful shutdown. An inherited
SIG_IGN (nohup) is respected. Because the terminal is gone, stdout/stderr
fds that were ttys are pointed at /dev/null first; otherwise cleanup
completes but the interpreter exits 120 on the final flush (EIO). Pipes
and files keep their output, and shutdown is requested even if the
redirect fails.

Tests: SIGHUP over a pipe (summary preserved), a real pty hangup with the
controller closed before the signal, and SIGHUP under nohup (run and
worker keep going until SIGTERM). The existing SIGTERM test now shares
the helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VuxjLwN47bGDGAKEv5Nfz6
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