From 67ee6afb3ade5dcaf5e00f2a93f68a633d6069a0 Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 3 Oct 2026 07:58:44 +0200 Subject: [PATCH 1/4] Exit mcp server immediately after sigterm --- src/semble/cli.py | 12 +++++++++++- tests/test_cli.py | 19 ++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/semble/cli.py b/src/semble/cli.py index 2770236b..8c4c15f2 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -3,7 +3,9 @@ import io import json import logging +import os import re +import signal import sys import warnings from collections.abc import Iterator @@ -112,7 +114,15 @@ def _mcp_main() -> None: from semble.mcp import serve content = _resolve_content(args.content, args.include_text_files) - asyncio.run(serve(content)) + + async def _serve_and_exit() -> None: + await serve(content) + # Exit without joining worker threads: an in-flight model load or index build would otherwise + # keep the process alive after the client closes stdin. + os._exit(0) + + signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) + asyncio.run(_serve_and_exit()) def _resolve_content(content: list[str], include_text_files: bool) -> list[ContentType]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0c23d1aa..89c2ddd0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,11 @@ import hashlib import json +import signal import sys import warnings from importlib.resources import files from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -21,12 +22,20 @@ ], ) def test_main_calls_asyncio_run(argv: list[str], monkeypatch: pytest.MonkeyPatch) -> None: - """main() delegates to asyncio.run(serve(...)) when no CLI subcommand is given.""" + """main() serves MCP, then hard-exits with 0 on stdin EOF or SIGTERM instead of joining worker threads.""" monkeypatch.setattr(sys, "argv", argv) - with patch("asyncio.run") as mock_run: - mock_run.side_effect = lambda coro: coro.close() + with ( + patch("semble.mcp.serve", new=AsyncMock()) as mock_serve, + patch("semble.cli.signal.signal") as mock_signal, + patch("semble.cli.os._exit") as mock_exit, + ): main() - mock_run.assert_called_once() + mock_serve.assert_awaited_once() + mock_exit.assert_called_once_with(0) + signum, handler = mock_signal.call_args.args + assert signum == signal.SIGTERM + handler(signum, None) + mock_exit.assert_called_with(0) @pytest.mark.parametrize( From fa0d13dcae25c8acc54c8a99b826b7bc7ebe77e5 Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 3 Oct 2026 08:02:17 +0200 Subject: [PATCH 2/4] Simplify function --- src/semble/cli.py | 11 +++-------- tests/test_cli.py | 4 ++++ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/semble/cli.py b/src/semble/cli.py index 8c4c15f2..7aa002a6 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -114,15 +114,10 @@ def _mcp_main() -> None: from semble.mcp import serve content = _resolve_content(args.content, args.include_text_files) - - async def _serve_and_exit() -> None: - await serve(content) - # Exit without joining worker threads: an in-flight model load or index build would otherwise - # keep the process alive after the client closes stdin. - os._exit(0) - signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) - asyncio.run(_serve_and_exit()) + asyncio.new_event_loop().run_until_complete(serve(content)) + # Skip asyncio.run's shutdown, which waits for threads still loading the model or building an index. + os._exit(0) def _resolve_content(content: list[str], include_text_files: bool) -> list[ContentType]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 89c2ddd0..d6cd0507 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import json import signal @@ -24,7 +25,9 @@ def test_main_calls_asyncio_run(argv: list[str], monkeypatch: pytest.MonkeyPatch) -> None: """main() serves MCP, then hard-exits with 0 on stdin EOF or SIGTERM instead of joining worker threads.""" monkeypatch.setattr(sys, "argv", argv) + loop = asyncio.new_event_loop() with ( + patch("semble.cli.asyncio.new_event_loop", return_value=loop), patch("semble.mcp.serve", new=AsyncMock()) as mock_serve, patch("semble.cli.signal.signal") as mock_signal, patch("semble.cli.os._exit") as mock_exit, @@ -36,6 +39,7 @@ def test_main_calls_asyncio_run(argv: list[str], monkeypatch: pytest.MonkeyPatch assert signum == signal.SIGTERM handler(signum, None) mock_exit.assert_called_with(0) + loop.close() @pytest.mark.parametrize( From 814dca55af4f845170f2512673564e8a89bfdcac Mon Sep 17 00:00:00 2001 From: Pringled Date: Sat, 3 Oct 2026 08:12:13 +0200 Subject: [PATCH 3/4] Register SIGTERM handler earlier and treat unreadable cache metadata as a miss --- src/semble/cache.py | 5 ++++- src/semble/cli.py | 2 +- tests/index/test_create.py | 5 +---- tests/test_cache.py | 8 +++++++- tests/test_cli.py | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/semble/cache.py b/src/semble/cache.py index f28eca51..bf763367 100644 --- a/src/semble/cache.py +++ b/src/semble/cache.py @@ -117,7 +117,10 @@ def _load_matching_metadata( persistence_path = PersistencePath.from_path(find_index_from_cache_folder(path, content)) if persistence_path.non_existing(): return None - metadata = json.loads(persistence_path.metadata.read_text(encoding="utf-8")) + try: + metadata = json.loads(persistence_path.metadata.read_text(encoding="utf-8")) + except ValueError: # Truncated by a process killed mid-save; treat as a miss so the index is rebuilt. + return None if model_path is None: model_path = resolve_model_name() if not _metadata_matches(metadata, model_path, content): diff --git a/src/semble/cli.py b/src/semble/cli.py index 7aa002a6..faa094e8 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -102,6 +102,7 @@ def main() -> None: def _mcp_main() -> None: + signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) parser = argparse.ArgumentParser( prog="semble", description="Instant local code search for agents.", @@ -114,7 +115,6 @@ def _mcp_main() -> None: from semble.mcp import serve content = _resolve_content(args.content, args.include_text_files) - signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) asyncio.new_event_loop().run_until_complete(serve(content)) # Skip asyncio.run's shutdown, which waits for threads still loading the model or building an index. os._exit(0) diff --git a/tests/index/test_create.py b/tests/index/test_create.py index 7d745bc5..4f3f3827 100644 --- a/tests/index/test_create.py +++ b/tests/index/test_create.py @@ -135,10 +135,7 @@ def test_load_previous_for_incremental_fails_closed(corrupt: str, tmp_path: Path bm25["doc_order"].reverse() bm25_path.write_bytes(orjson.dumps(bm25)) elif corrupt == "corrupt_json": - (index_path / "metadata.json").write_bytes(b"{not json") - with patch("semble.cache.find_index_from_cache_folder", return_value=index_path): - assert load_previous_for_incremental("/some/path", "my/model", [ContentType.CODE]) is None - return + (index_path / "chunks.json").write_bytes(b"{not json") (index_path / "metadata.json").write_bytes(orjson.dumps(metadata)) with patch("semble.cache.find_index_from_cache_folder", return_value=index_path): diff --git a/tests/test_cache.py b/tests/test_cache.py index 076b382a..bd7dad67 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -148,7 +148,7 @@ def _write_metadata( def test_get_validated_cache_invalid_index(tmp_path: Path) -> None: - """Returns None when the index directory is missing or incomplete.""" + """Returns None when the index directory is missing, incomplete, or has truncated metadata.""" with patch("semble.cache.find_index_from_cache_folder", return_value=tmp_path / "missing"): assert get_validated_cache("/path", None, [ContentType.CODE]) is None @@ -157,6 +157,12 @@ def test_get_validated_cache_invalid_index(tmp_path: Path) -> None: with patch("semble.cache.find_index_from_cache_folder", return_value=index_path): assert get_validated_cache("/path", None, [ContentType.CODE]) is None + _write_metadata(index_path, "my/model", ["code"], 0.0) + metadata = index_path / "metadata.json" + metadata.write_text(metadata.read_text()[:20]) + with patch("semble.cache.find_index_from_cache_folder", return_value=index_path): + assert get_validated_cache("/path", "my/model", [ContentType.CODE]) is None + @pytest.mark.parametrize( ("stored_model", "stored_content", "req_model", "req_content"), diff --git a/tests/test_cli.py b/tests/test_cli.py index d6cd0507..2b6a9ac3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,7 +22,7 @@ ["semble"], ], ) -def test_main_calls_asyncio_run(argv: list[str], monkeypatch: pytest.MonkeyPatch) -> None: +def test_mcp_main_serves_then_exits(argv: list[str], monkeypatch: pytest.MonkeyPatch) -> None: """main() serves MCP, then hard-exits with 0 on stdin EOF or SIGTERM instead of joining worker threads.""" monkeypatch.setattr(sys, "argv", argv) loop = asyncio.new_event_loop() From ba6913f9b267b53a652fb82b1d430b83addbdc5d Mon Sep 17 00:00:00 2001 From: Pringled Date: Mon, 5 Oct 2026 10:34:07 +0200 Subject: [PATCH 4/4] chore: Bump version --- src/semble/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/semble/version.py b/src/semble/version.py index dc745b2b..0a1957c5 100644 --- a/src/semble/version.py +++ b/src/semble/version.py @@ -1,2 +1,2 @@ -__version_triple__ = (0, 6, 1) +__version_triple__ = (0, 6, 2) __version__ = ".".join(map(str, __version_triple__))