From 4441dd34a52a4a00f7634207ed5c8dbcb398a53b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:39:57 -0700 Subject: [PATCH] fix(tui-gateway): an ignored SIGINT no longer fences every later terminal command A server started as `cmd &` from a non-interactive shell inherits SIGINT as SIG_IGN. The exit-flush handler still ran _stop_turns_before_exit, which raises the one-way foreground-spawn fence, then chained to the ignored disposition and kept running: every later terminal command returned "[host is exiting: command not started]" rc 130. An ignored signal ends nothing, so the handler now returns before flushing or stopping turns. --- tests/tui_gateway/test_serve_exit_flush.py | 21 +++++++++++++++++++++ tui_gateway/session_reaper.py | 10 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/tui_gateway/test_serve_exit_flush.py b/tests/tui_gateway/test_serve_exit_flush.py index 44e7278309..7ea8976e9c 100644 --- a/tests/tui_gateway/test_serve_exit_flush.py +++ b/tests/tui_gateway/test_serve_exit_flush.py @@ -113,6 +113,27 @@ def test_sigterm_flushes_populated_session_into_state_db( assert any("survive the kill" in str(r.get("content", "")) for r in rows) +def test_ignored_sigint_leaves_terminal_commands_runnable(): + """SIGINT inherited as SIG_IGN (a server started as ``cmd &`` from a non-interactive shell) ends + nothing, so it must not raise the one-way exit fence: the process lives on and every later + terminal command would return 'host is exiting' rc 130.""" + from tools.environments.local import LocalEnvironment + + prev = {signal.SIGTERM: signal.getsignal(signal.SIGTERM), + signal.SIGINT: signal.signal(signal.SIGINT, signal.SIG_IGN)} + try: + assert server.install_exit_flush_signal_handlers() is True + os.kill(os.getpid(), signal.SIGINT) + time.sleep(0.05) + finally: + _restore_signal_state(prev) + env = LocalEnvironment(cwd=os.getcwd()) + try: + assert env.execute("echo still-alive", timeout=30)["returncode"] == 0 + finally: + env.cleanup() + + def test_exit_flush_is_bounded(registered_session): """A hung persist must never block exit longer than the budget.""" diff --git a/tui_gateway/session_reaper.py b/tui_gateway/session_reaper.py index 21e92dad12..8afc252a8d 100644 --- a/tui_gateway/session_reaper.py +++ b/tui_gateway/session_reaper.py @@ -151,17 +151,21 @@ _exit_flush_handlers_installed = False def _handle_exit_flush_signal(signum, frame) -> None: """Flush in-memory sessions, then hand off to the prior handler (uvicorn's graceful shutdown, a supervisor's handler, or the default disposition) — this only *prepends* a bounded flush.""" + import signal as _signal + prev = _exit_flush_prev_handlers.get(signum) + if prev is _signal.SIG_IGN: + # An inherited ignore (`cmd &` from a non-interactive shell) ends nothing: stopping turns here + # would raise the one-way exit fence in a process that keeps running and refuses every command. + return with contextlib.suppress(Exception): _flush_sessions_before_exit() # The group signal that stopped us never reaches a command in its own session: reap it now, # before a supervisor's SIGKILL can cut the graceful shutdown (and its atexit) short. with contextlib.suppress(Exception): _stop_turns_before_exit() - import signal as _signal - prev = _exit_flush_prev_handlers.get(signum) if callable(prev): prev(signum, frame) - elif prev is not _signal.SIG_IGN: + else: # Default disposition: restore it and re-raise so the process dies with the correct signal (exit status # visible to supervisors). try: