From eeda18a9b75027408bccd1ac7308ac8a1c469d7c Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Fri, 24 Apr 2026 01:44:14 +0300 Subject: [PATCH] chore(tui): record gateway exit reason in crash log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway exits weren't reaching the panic hook because entry.py calls sys.exit(0) on broken stdout — clean termination, no exception. That left "gateway exited" in the TUI with zero forensic trail when pipe breaks happened mid-turn. Entry.py now tags each exit path — startup-write failure, parse-error- response write failure, per-method response write failure, stdin EOF — with a one-line entry in ~/.hermes/logs/tui_gateway_crash.log and a gateway.stderr breadcrumb. Includes the JSON-RPC method name on the dispatch path, which is the only way to tell "died right after handling voice.toggle on" from "died emitting the second message.complete". --- tui_gateway/entry.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index d2b82b9da..42f636d31 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -1,19 +1,44 @@ import json +import os import signal import sys +import time -from tui_gateway.server import dispatch, resolve_skin, write_json +from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json signal.signal(signal.SIGPIPE, signal.SIG_DFL) signal.signal(signal.SIGINT, signal.SIG_IGN) +def _log_exit(reason: str) -> None: + """Record why the gateway subprocess is shutting down. + + Three exit paths (startup write fail, parse-error-response write fail, + dispatch-response write fail, stdin EOF) all collapse into a silent + sys.exit(0) here. Without this trail the TUI shows "gateway exited" + with no actionable clue about WHICH broken pipe or WHICH message + triggered it — the main reason voice-mode turns look like phantom + crashes when the real story is "TUI read pipe closed on this event". + """ + try: + os.makedirs(os.path.dirname(_CRASH_LOG), exist_ok=True) + with open(_CRASH_LOG, "a", encoding="utf-8") as f: + f.write( + f"\n=== gateway exit · {time.strftime('%Y-%m-%d %H:%M:%S')} " + f"· reason={reason} ===\n" + ) + except Exception: + pass + print(f"[gateway-exit] {reason}", file=sys.stderr, flush=True) + + def main(): if not write_json({ "jsonrpc": "2.0", "method": "event", "params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}}, }): + _log_exit("startup write failed (broken stdout pipe before first event)") sys.exit(0) for raw in sys.stdin: @@ -25,14 +50,19 @@ def main(): req = json.loads(line) except json.JSONDecodeError: if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}): + _log_exit("parse-error-response write failed (broken stdout pipe)") sys.exit(0) continue + method = req.get("method") if isinstance(req, dict) else None resp = dispatch(req) if resp is not None: if not write_json(resp): + _log_exit(f"response write failed for method={method!r} (broken stdout pipe)") sys.exit(0) + _log_exit("stdin EOF (TUI closed the command pipe)") + if __name__ == "__main__": main()