| import json |
| import os |
| import signal |
| import sys |
| import time |
| import traceback |
|
|
| from tui_gateway.server import _CRASH_LOG, dispatch, resolve_skin, write_json |
|
|
|
|
| def _log_signal(signum: int, frame) -> None: |
| """Capture WHICH thread and WHERE a termination signal hit us. |
| |
| SIG_DFL for SIGPIPE kills the process silently the instant any |
| background thread (TTS playback, beep, voice status emitter, etc.) |
| writes to a stdout the TUI has stopped reading. Without this |
| handler the gateway-exited banner in the TUI has no trace — the |
| crash log never sees a Python exception because the kernel reaps |
| the process before the interpreter runs anything. |
| """ |
| name = { |
| signal.SIGPIPE: "SIGPIPE", |
| signal.SIGTERM: "SIGTERM", |
| signal.SIGHUP: "SIGHUP", |
| }.get(signum, f"signal {signum}") |
| 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=== {name} received · {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n" |
| ) |
| if frame is not None: |
| f.write("main-thread stack at signal delivery:\n") |
| traceback.print_stack(frame, file=f) |
| |
| |
| import threading as _threading |
| for tid, th in _threading._active.items(): |
| f.write(f"\n--- thread {th.name} (id={tid}) ---\n") |
| f.write("".join(traceback.format_stack(sys._current_frames().get(tid)))) |
| except Exception: |
| pass |
| print(f"[gateway-signal] {name}", file=sys.stderr, flush=True) |
| sys.exit(0) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| signal.signal(signal.SIGPIPE, signal.SIG_IGN) |
| signal.signal(signal.SIGTERM, _log_signal) |
| signal.signal(signal.SIGHUP, _log_signal) |
| 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: |
| line = raw.strip() |
| if not line: |
| continue |
|
|
| try: |
| 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() |
|
|