From e5a1f7e6831307cab699115b7722c06e9290ff9e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:56:38 +0000 Subject: [PATCH] fix: guard isatty() against ValueError on MCP stdio shutdown When the MCP server runs over stdio transport, stdin/stdout are closed before the call_on_close promo callback fires. sys.stdin.isatty() then raises ValueError: I/O operation on closed file. Wrap the isatty() calls in a try/except ValueError in _is_interactive_session() so the promo is silently skipped rather than crashing the shutdown path. Fixes #607 Co-authored-by: bm-clawd Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- src/basic_memory/cli/promo.py | 9 ++++++++- tests/cli/test_cloud_promo.py | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/cli/promo.py b/src/basic_memory/cli/promo.py index 7139500d..e8e87e59 100644 --- a/src/basic_memory/cli/promo.py +++ b/src/basic_memory/cli/promo.py @@ -24,7 +24,14 @@ def _promos_disabled_by_env() -> bool: def _is_interactive_session() -> bool: """Return whether stdin/stdout are interactive terminals.""" - return sys.stdin.isatty() and sys.stdout.isatty() + try: + return sys.stdin.isatty() and sys.stdout.isatty() + except ValueError: + # Trigger: stdin/stdout already closed (e.g., MCP stdio transport shutdown). + # Why: isatty() raises ValueError on closed file objects; treat as non-interactive + # so the promo is suppressed rather than crashing the shutdown path. + # Outcome: promo skipped for this invocation. + return False def _build_cloud_promo_message() -> str: diff --git a/tests/cli/test_cloud_promo.py b/tests/cli/test_cloud_promo.py index 5451dc19..776ec5c7 100644 --- a/tests/cli/test_cloud_promo.py +++ b/tests/cli/test_cloud_promo.py @@ -20,6 +20,43 @@ def _capture_console() -> tuple[Console, StringIO]: return Console(file=buf, force_terminal=True), buf +# --- _is_interactive_session tests --- + + +def test_is_interactive_session_returns_false_on_closed_stdin(monkeypatch): + """isatty() raises ValueError when stdio is closed (e.g., MCP shutdown).""" + import sys + + from basic_memory.cli.promo import _is_interactive_session + + class _ClosedStdin: + def isatty(self): + raise ValueError("I/O operation on closed file") + + monkeypatch.setattr(sys, "stdin", _ClosedStdin()) + assert _is_interactive_session() is False + + +def test_is_interactive_session_returns_false_on_closed_stdout(monkeypatch): + """isatty() raises ValueError on closed stdout (e.g., MCP shutdown).""" + import sys + + from basic_memory.cli.promo import _is_interactive_session + + class _ClosedStdout: + def isatty(self): + raise ValueError("I/O operation on closed file") + + # stdin reports interactive, but stdout is closed + class _InteractiveStdin: + def isatty(self): + return True + + monkeypatch.setattr(sys, "stdin", _InteractiveStdin()) + monkeypatch.setattr(sys, "stdout", _ClosedStdout()) + assert _is_interactive_session() is False + + # --- maybe_show_init_line tests ---