From f1254b1bc20a559e1cd1bbd2a7d0623fa634c07c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 19 May 2026 03:33:27 -0700 Subject: [PATCH] fix(cli): exit prompt_toolkit cleanly on SIGTERM/SIGHUP instead of raising KeyboardInterrupt (#28688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SIGTERM/SIGHUP handler raised KeyboardInterrupt() at the end of its agent-interrupt + grace-window sequence. Python delivers signals between bytecodes on the main thread, so when the signal hit mid-event-loop (typically inside prompt_toolkit's '_poll_output_size' coroutine's 'await asyncio.sleep()'), the KeyboardInterrupt unwound INTO that coroutine. prompt_toolkit's Task captured it as a BaseException; prompt_toolkit's '_handle_exception' then printed 'Unhandled exception in event loop' + the full asyncio traceback and parked the terminal on 'Press ENTER to continue...' before exiting. Same root cause as #13710, different surface: there the failure was an EIO cascade after a logging-cache KeyError escaped the handler; here it's the KBI raise itself landing inside an asyncio Task. The fix is the same shape — let the event loop unwind on its own terms. Now: schedule 'app.exit()' via 'loop.call_soon_threadsafe()'. The prompt_toolkit Application returns normally from 'app.run()' and the existing '(EOFError, KeyboardInterrupt, BrokenPipeError)' handler in the input loop catches everything else. Fallback to 'raise KeyboardInterrupt()' preserved for contexts where prompt_toolkit isn't the active app (e.g. -q one-shot mode). The agent interrupt + 1.5 s grace window run unchanged before the new exit path, so subprocess-group cleanup ('os.killpg' on Linux) still gets its window. Tested live: external SIGTERM to the CLI (with 'kill ') now exits cleanly with no traceback dump and no ENTER pause. --- cli.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index a22a836b7fa..8fdfa6faad0 100644 --- a/cli.py +++ b/cli.py @@ -13907,7 +13907,31 @@ class HermesCLI: time.sleep(_grace) except Exception: pass # never block signal handling - raise KeyboardInterrupt() + # Prefer a clean prompt_toolkit exit over `raise KeyboardInterrupt()`. + # Raising KBI from a signal handler unwinds into whatever Python + # frame the interpreter happens to be running — typically an + # `await asyncio.sleep()` inside prompt_toolkit's + # `_poll_output_size` coroutine. The KBI becomes a Task + # exception, prompt_toolkit's `_handle_exception` prints + # "Unhandled exception in event loop" + the full traceback, and + # parks the terminal on "Press ENTER to continue..." (#13710 + # variant — same root cause, different surface). + # + # `app.exit()` scheduled via `call_soon_threadsafe` lets the + # event loop unwind normally; `app.run()` returns and our + # existing `except (EOFError, KeyboardInterrupt, BrokenPipeError)` + # block at the bottom of the input loop handles the rest. + try: + from prompt_toolkit.application.current import get_app_or_none + _app = get_app_or_none() + if _app is not None: + _loop = getattr(_app, "loop", None) + if _loop is not None: + _loop.call_soon_threadsafe(_app.exit) + return # clean unwind — no traceback, no ENTER pause + except Exception: + pass + raise KeyboardInterrupt() # fallback for non-prompt_toolkit contexts try: import signal as _signal