From e46555bf2cbcd9eadf58d036b0a67f6abab2b4e5 Mon Sep 17 00:00:00 2001 From: phernandez Date: Wed, 25 Feb 2026 18:01:59 -0600 Subject: [PATCH] fix: guard against closed streams in promo and missing vector tables (#579, #607) - Wrap isatty() in _is_interactive_session() with try/except ValueError so MCP stdio transport shutdown no longer produces noisy tracebacks - Check both search_vector_chunks AND search_vector_embeddings exist before running JOIN queries in get_embedding_status(), fixing OperationalError when only the chunks table is present - Add test for closed-stream scenario Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- src/basic_memory/cli/promo.py | 8 +++++++- src/basic_memory/services/project_service.py | 8 +++++--- tests/cli/test_cloud_promo.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/cli/promo.py b/src/basic_memory/cli/promo.py index 7139500d..b5965fc8 100644 --- a/src/basic_memory/cli/promo.py +++ b/src/basic_memory/cli/promo.py @@ -24,7 +24,13 @@ 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 descriptors + # Outcome: treat as non-interactive, suppressing promo output + return False def _build_cloud_promo_message() -> str: diff --git a/src/basic_memory/services/project_service.py b/src/basic_memory/services/project_service.py index a5358405..97244e0f 100644 --- a/src/basic_memory/services/project_service.py +++ b/src/basic_memory/services/project_service.py @@ -942,19 +942,21 @@ class ProjectService: is_postgres = config.database_backend == DatabaseBackend.POSTGRES # --- Check vector table existence --- + # Both search_vector_chunks and search_vector_embeddings must exist + # for the detailed stats queries (JOINs between them) to work. if is_postgres: table_check_sql = text( "SELECT COUNT(*) FROM information_schema.tables " - "WHERE table_name = 'search_vector_chunks'" + "WHERE table_name IN ('search_vector_chunks', 'search_vector_embeddings')" ) else: table_check_sql = text( "SELECT COUNT(*) FROM sqlite_master " - "WHERE type = 'table' AND name = 'search_vector_chunks'" + "WHERE type = 'table' AND name IN ('search_vector_chunks', 'search_vector_embeddings')" ) table_result = await self.repository.execute_query(table_check_sql, {}) - vector_tables_exist = (table_result.scalar() or 0) > 0 + vector_tables_exist = (table_result.scalar() or 0) == 2 if not vector_tables_exist: # Count distinct entities in search index for the recommendation message diff --git a/tests/cli/test_cloud_promo.py b/tests/cli/test_cloud_promo.py index 5451dc19..823703d7 100644 --- a/tests/cli/test_cloud_promo.py +++ b/tests/cli/test_cloud_promo.py @@ -8,6 +8,7 @@ from typer.testing import CliRunner from basic_memory.cli.app import app import basic_memory from basic_memory.cli.promo import ( + _is_interactive_session, maybe_show_cloud_promo, maybe_show_init_line, ) @@ -294,3 +295,17 @@ def test_cloud_promo_command_on_clears_opt_out(monkeypatch): assert "Cloud promo messages enabled" in result.stdout assert len(instances) == 1 assert instances[0].saved_config.cloud_promo_opt_out is False + + +# --- _is_interactive_session tests --- + + +def test_is_interactive_session_returns_false_when_streams_closed(monkeypatch): + """isatty() raises ValueError on closed file descriptors (e.g., MCP shutdown).""" + + class ClosedStream: + def isatty(self): + raise ValueError("I/O operation on closed file") + + monkeypatch.setattr("sys.stdin", ClosedStream()) + assert _is_interactive_session() is False