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 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-25 18:01:59 -06:00
parent 74e6afdc0f
commit e46555bf2c
3 changed files with 27 additions and 4 deletions
+7 -1
View File
@@ -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:
+5 -3
View File
@@ -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
+15
View File
@@ -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