{"repo":"NousResearch/hermes-agent","public_id":"10755","display_label":"CLI OSError EIO crash on interrupt or WSL2 PTY","summary":"A bug where the CLI crashes with OSError [Errno 5] Input/output error during interrupt/signal handling, particularly on WSL2 when a PTY disconnects, along with related fix attempts and regressions. Most members share this specific signal-handling error theme, with one unrelated batch fix included.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15834},"resolution":{"state":"partially_resolved","by":"pr-15834"},"freshness":{"state":"verified","last_verified_at":"2026-05-06T10:55:47+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":13710,"pr_number":15834,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":13710,"pr_number":20673,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":10755,"title":"[Bug]: CLI crashes with OSError [Errno 5] Input/output error on WSL2 when PTY disconnects","url":null,"state":"closed"},{"kind":"issue","number":13710,"title":"CLI crashes with OSError: [Errno 5] Input/output error on interrupt (signal handling)","url":null,"state":"closed"},{"kind":"pr","number":10777,"title":"fix: batch fix for 4 issues (#10721, #10755, #10723, #10748)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c03858733","number":0},{"kind":"commit","value":"45e1228a8","number":0},{"kind":"commit","value":"50d438d12","number":0},{"kind":"commit","value":"d67e602cc","number":0},{"kind":"commit","value":"d4eba82a3","number":0},{"kind":"commit","value":"3b5572ded","number":0},{"kind":"pr","value":"#10721","number":10721},{"kind":"pr","value":"#10755","number":10755},{"kind":"pr","value":"#10723","number":10723},{"kind":"pr","value":"#10843","number":10843},{"kind":"pr","value":"#10748","number":10748}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":13720,"title":"fix(cli): suppress OSError EIO on interrupt shutdown","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"45e1228a8","number":0},{"kind":"pr","value":"#15834","number":15834}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15834,"title":"fix(cli): suppress OSError EIO on interrupt shutdown (salvages #13720, closes #13710)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","scripts/release.py","tests/hermes_cli/test_suppress_eio_on_interrupt.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -22,6 +22,7 @@\n import concurrent.futures\n import base64\n import atexit\n+import errno\n import tempfile\n import time\n import uuid","@@ -10729,6 +10730,8 @@ def _suppress_closed_loop_errors(loop, context):\n                 return  # silently suppress\n             if isinstance(exc, KeyError) and \"is not registered\" in str(exc):\n                 return  # suppress selector registration failures (#6393)\n+            if isinstance(exc, OSError) and getattr(exc, \"errno\", None) == errno.EIO:\n+                return  # suppress I/O errors from broken stdout on interrupt (#13710)\n             # Fall back to default handler for everything else\n             loop.default_exception_handler(context)\n ","@@ -10761,9 +10764,11 @@ def _suppress_closed_loop_errors(loop, context):\n         except (EOFError, KeyboardInterrupt, BrokenPipeError):\n             pass\n         except (KeyError, OSError) as _stdin_err:\n-            # Catch selector registration failures from broken stdin (#6393).\n-            # This is the fallback for cases that slip past the fstat() guard.\n-            if \"is not registered\" in str(_stdin_err) or \"Bad file descriptor\" in str(_stdin_err):\n+            # Catch selector registration failures from broken stdin (#6393)\n+            # and I/O errors from broken stdout during interrupt (#13710).\n+            if isinstance(_stdin_err, OSError) and getattr(_stdin_err, \"errno\", None) == errno.EIO:\n+                pass  # suppress broken-stdout I/O errors on interrupt (#13710)\n+            elif \"is not registered\" in str(_stdin_err) or \"Bad file descriptor\" in str(_stdin_err):\n                 print(\n                     f\"\\nError: stdin is not usable ({_stdin_err}).\\n\"\n                     \"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\\n\""]},{"file":"scripts/release.py","hunks":["@@ -93,6 +93,7 @@\n     \"[redacted:email]\": \"Sertug17\",\n     \"[redacted:email]\": \"caentzminger\",\n     \"[redacted:email]\": \"voidborne-d\",\n+    \"[redacted:email]\": \"voidborne-d\",\n     \"[redacted:email]\": \"Readon\",\n     \"[redacted:email]\": \"sirEven\",\n     \"[redacted:email]\": \"sirEven\","]},{"file":"tests/hermes_cli/test_suppress_eio_on_interrupt.py","hunks":["@@ -0,0 +1,115 @@\n+\"\"\"Tests for OSError EIO suppression during interrupt shutdown (#13710).\n+\n+When the user interrupts a running task, prompt_toolkit tries to flush\n+stdout during emergency shutdown.  If stdout is already in a broken state\n+(redirected to /dev/null, pipe closed, etc.), the flush raises\n+``OSError: [Errno 5] Input/output error``.\n+\n+The ``_suppress_closed_loop_errors`` asyncio exception handler and the\n+outer ``except (KeyError, OSError)`` block must both suppress this error\n+to prevent a hard crash.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import errno\n+import os\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# _suppress_closed_loop_errors – asyncio exception handler\n+# ---------------------------------------------------------------------------\n+\n+def _make_suppress_fn():\n+    \"\"\"Build a standalone copy of ``_suppress_closed_loop_errors``.\n+\n+    The real function is defined as a closure inside\n+    ``CLI._run_interactive``; we reconstruct an equivalent here so the\n+    unit tests don't need a full CLI instance.\n+    \"\"\"\n+    def _suppress_closed_loop_errors(loop, context):\n+        exc = context.get(\"exception\")\n+        if isinstance(exc, RuntimeError) and \"Event loop is closed\" in str(exc):\n+            return\n+        if isinstance(exc, KeyError) and \"is not registered\" in str(exc):\n+            return\n+        if isinstance(exc, OSError) and getattr(exc, \"errno\", None) == errno.EIO:\n+            return\n+        loop.default_exception_handler(context)\n+    return _suppress_closed_loop_errors\n+\n+\n+class TestSuppressClosedLoopErrors:\n+    \"\"\"Verify the asyncio exception handler suppresses expected errors.\"\"\"\n+\n+    def test_suppresses_event_loop_closed(self):\n+        handler = _make_suppress_fn()\n+        loop = MagicMock()\n+        handler(loop, {\"exception\": RuntimeError(\"Event loop is closed\")})\n+        loop.default_exception_handler.assert_not_called()\n+\n+    def test_suppresses_key_not_registered(self):\n+        handler = _make_suppress_fn()\n+        loop = MagicMock()\n+        handler(loop, {\"exception\": KeyError(\"0 is not registered\")})\n+        loop.default_exception_handler.assert_not_called()\n+\n+    def test_suppresses_oserror_eio(self):\n+        \"\"\"OSError with errno.EIO must be suppressed (#13710).\"\"\"\n+        handler = _make_suppress_fn()\n+        loop = MagicMock()\n+        exc = OSError(errno.EIO, \"Input/output error\")\n+        handler(loop, {\"exception\": exc})\n+        loop.default_exception_handler.assert_not_called()\n+\n+    def test_does_not_suppress_oserror_other_errno(self):\n+        \"\"\"OSError with a different errno must still propagate.\"\"\"\n+        handler = _make_suppress_fn()\n+        loop = MagicMock()\n+        exc = OSError(errno.EACCES, \"Permission denied\")\n+        handler(loop, {\"exception\": exc})\n+        loop.default_exception_handler.assert_called_once()\n+\n+    def test_does_not_suppress_unrelated_exception(self):\n+        \"\"\"Unrelated exceptions must still propagate.\"\"\"\n+        handler = _make_suppress_fn()\n+        loop = MagicMock()\n+        handler(loop, {\"exception\": ValueError(\"something else\")})\n+        loop.default_exception_handler.assert_called_once()\n+\n+    def test_no_exception_key(self):\n+        \"\"\"Context without 'exception' must propagate to default handler.\"\"\"\n+        handler = _make_suppress_fn()\n+        loop = MagicMock()\n+        handler(loop, {\"message\": \"some log\"})\n+        loop.default_exception_handler.assert_called_once()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Outer except block – EIO handling\n+# ---------------------------------------------------------------------------\n+\n+class TestOuterExceptEIO:\n+    \"\"\"Verify the outer ``except (KeyError, OSError)`` block logic.\"\"\"\n+\n+    def test_eio_does_not_reraise(self):\n+        \"\"\"OSError with errno.EIO should be silently suppressed.\"\"\"\n+        exc = OSError(errno.EIO, \"Input/output error\")\n+        # Simulate the condition check from the outer except block:\n+        assert isinstance(exc, OSError)\n+        assert getattr(exc, \"errno\", None) == errno.EIO\n+\n+    def test_bad_file_descriptor_matches(self):\n+        \"\"\"'Bad file descriptor' string should be caught.\"\"\"\n+        exc = OSError(errno.EBADF, \"Bad file descriptor\")\n+        assert \"Bad file descriptor\" in str(exc)\n+\n+    def test_other_oserror_reraises(self):\n+        \"\"\"Other OSError variants must not match the EIO guard.\"\"\"\n+        exc = OSError(errno.EACCES, \"Permission denied\")\n+        assert not (getattr(exc, \"errno\", None) == errno.EIO)\n+        assert \"is not registered\" not in str(exc)\n+        assert \"Bad file descriptor\" not in str(exc)\n"]}]},{"kind":"pr","number":20673,"title":"fix(cli): guard logger.debug in signal handler (#13710 regression)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","tests/hermes_cli/test_suppress_eio_on_interrupt.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -11862,8 +11862,22 @@ def _signal_handler(signum, frame):\n             call _kill_process (SIGTERM + 1 s wait + SIGKILL if needed) →\n             return from _wait_for_process.  ``time.sleep`` releases the\n             GIL so the daemon actually runs during the window.\n+\n+            Guarded ``logger.debug``: CPython's ``logging`` module is not\n+            reentrant-safe.  ``Logger.isEnabledFor`` caches level results\n+            in ``Logger._cache``; under shutdown races the cache can be\n+            cleared (``_clear_cache``) or mid-mutation when the signal\n+            fires, raising ``KeyError: <level_int>`` (e.g. ``KeyError: 10``\n+            for DEBUG) inside the handler.  That KeyError then escapes\n+            before ``raise KeyboardInterrupt()`` can fire, which bypasses\n+            prompt_toolkit's normal interrupt unwind and surfaces as the\n+            EIO cascade from issue #13710.  Wrap the log in a bare\n+            ``try/except`` so the handler can never raise through it.\n             \"\"\"\n-            logger.debug(\"Received signal %s, triggering graceful shutdown\", signum)\n+            try:\n+                logger.debug(\"Received signal %s, triggering graceful shutdown\", signum)\n+            except Exception:\n+                pass  # never let logging raise from a signal handler (#13710 regression)\n             try:\n                 if getattr(self, \"agent\", None) and getattr(self, \"_agent_running\", False):\n                     self.agent.interrupt(f\"received signal {signum}\")"]},{"file":"tests/hermes_cli/test_suppress_eio_on_interrupt.py","hunks":["@@ -113,3 +113,123 @@ def test_other_oserror_reraises(self):\n         assert not (getattr(exc, \"errno\", None) == errno.EIO)\n         assert \"is not registered\" not in str(exc)\n         assert \"Bad file descriptor\" not in str(exc)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Signal handler – guarded logger.debug (#13710 regression)\n+# ---------------------------------------------------------------------------\n+#\n+# CPython's logging module is not reentrant-safe.  ``Logger.isEnabledFor``\n+# caches level results in ``Logger._cache``; under shutdown races the cache\n+# can be cleared (``Logger._clear_cache``) or mid-mutation when the signal\n+# fires, raising ``KeyError: <level_int>`` (e.g. ``KeyError: 10`` for DEBUG)\n+# from inside the handler.  If that KeyError escapes, it bypasses the\n+# ``raise KeyboardInterrupt()`` on the next line, which in turn bypasses\n+# prompt_toolkit's normal interrupt unwind and surfaces as the EIO cascade\n+# from #13710.\n+#\n+# The fix: wrap the ``logger.debug`` call in the signal handler in a bare\n+# ``try/except Exception: pass`` so logging can never raise through it.\n+#\n+# These tests verify the contract: the handler must raise KeyboardInterrupt\n+# (and nothing else) regardless of whether logger.debug succeeds or blows up.\n+\n+\n+def _make_signal_handler(logger, agent_state):\n+    \"\"\"Build a standalone copy of ``_signal_handler``.\n+\n+    The real handler is defined as a closure inside ``CLI._run_interactive``;\n+    we reconstruct an equivalent here so the unit tests don't need a full\n+    CLI instance.  Mirrors cli.py:_signal_handler as of #13710 regression\n+    fix — guarded logger.debug + agent interrupt + KeyboardInterrupt.\n+    \"\"\"\n+    def _signal_handler(signum, frame):\n+        # Guarded: logging must never raise through a signal handler.\n+        try:\n+            logger.debug(\"Received signal %s, triggering graceful shutdown\", signum)\n+        except Exception:\n+            pass  # never let logging raise from a signal handler (#13710 regression)\n+        try:\n+            if agent_state.get(\"agent\") and agent_state.get(\"running\"):\n+                agent_state[\"agent\"].interrupt(f\"received signal {signum}\")\n+        except Exception:\n+            pass  # never block signal handling\n+        raise KeyboardInterrupt()\n+    return _signal_handler\n+\n+\n+class TestSignalHandlerLoggingRace:\n+    \"\"\"#13710 regression — logger.debug in signal handler must not escape.\n+\n+    If the DEBUG-level ``logging._cache`` lookup races with a concurrent\n+    ``_clear_cache`` (e.g. from another thread reconfiguring logging during\n+    shutdown), ``logger.debug`` can raise ``KeyError: 10``.  The signal\n+    handler must swallow that and still raise KeyboardInterrupt.\n+    \"\"\"\n+\n+    def test_keyboard_interrupt_raised_on_normal_path(self):\n+        \"\"\"Sanity: handler raises KeyboardInterrupt when logging works.\"\"\"\n+        logger = MagicMock()\n+        handler = _make_signal_handler(logger, {})\n+        with pytest.raises(KeyboardInterrupt):\n+            handler(15, None)  # SIGTERM\n+        logger.debug.assert_called_once()\n+\n+    def test_keyboard_interrupt_raised_when_logger_raises_keyerror(self):\n+        \"\"\"logger.debug raising KeyError(10) must not escape — KeyboardInterrupt wins.\n+\n+        This is the exact failure signature from the #13710 regression: the\n+        CPython 3.11 ``Logger._cache[level]`` race surfaces as KeyError on\n+        the integer level value, and previously propagated out of the\n+        signal handler before the ``raise KeyboardInterrupt()`` could fire.\n+        \"\"\"\n+        logger = MagicMock()\n+        logger.debug.side_effect = KeyError(10)  # DEBUG level int\n+        handler = _make_signal_handler(logger, {})\n+        # Must still raise KeyboardInterrupt, NOT KeyError.\n+        with pytest.raises(KeyboardInterrupt):\n+            handler(15, None)\n+\n+    def test_keyboard_interrupt_raised_when_logger_raises_generic(self):\n+        \"\"\"Any Exception from logger.debug must be swallowed by the guard.\"\"\"\n+        logger = MagicMock()\n+        logger.debug.side_effect = RuntimeError(\"logging is shutting down\")\n+        handler = _make_signal_handler(logger, {})\n+        with pytest.raises(KeyboardInterrupt):\n+            handler(15, None)\n+\n+    def test_agent_interrupt_still_fires_when_logger_raises(self):\n+        \"\"\"Even if logger.debug blows up, the agent interrupt must still run.\n+\n+        The whole point of the grace window is cleaning up the agent's\n+        subprocess group.  A logging race must not skip that step.\n+        \"\"\"\n+        logger = MagicMock()\n+        logger.debug.side_effect = KeyError(10)\n+        agent = MagicMock()\n+        handler = _make_signal_handler(logger, {\"agent\": agent, \"running\": True})\n+        with pytest.raises(KeyboardInterrupt):\n+            handler(15, None)\n+        agent.interrupt.assert_called_once_with(\"received signal 15\")\n+\n+    def test_agent_interrupt_failure_also_does_not_escape(self):\n+        \"\"\"Defense-in-depth: agent.interrupt() raising must not escape either.\"\"\"\n+        logger = MagicMock()\n+        agent = MagicMock()\n+        agent.interrupt.side_effect = RuntimeError(\"agent already torn down\")\n+        handler = _make_signal_handler(logger, {\"agent\": agent, \"running\": True})\n+        with pytest.raises(KeyboardInterrupt):\n+            handler(15, None)\n+\n+    def test_base_exception_from_logger_is_not_swallowed(self):\n+        \"\"\"BaseException (e.g. SystemExit) must still propagate — only Exception is caught.\n+\n+        The guard uses ``except Exception`` deliberately; BaseException\n+        subclasses like SystemExit or a nested KeyboardInterrupt should\n+        still be honored so we don't mask real shutdown signals.\n+        \"\"\"\n+        logger = MagicMock()\n+        logger.debug.side_effect = SystemExit(1)\n+        handler = _make_signal_handler(logger, {})\n+        with pytest.raises(SystemExit):\n+            handler(15, None)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}