From 66ce3a4e58e028277880ca3f350f82ebd861b300 Mon Sep 17 00:00:00 2001 From: cdeust Date: Fri, 16 Oct 2026 09:14:47 +0200 Subject: [PATCH] test(launcher): no hook launch installs the ML set in-band (#747) Issue #747: tests_py/hooks/test_entry.py hung in communicate() on the launcher subprocess (test_entry.py:98) until the 300 s pytest timeout. Cause, reproduced on the issue's base 02c3cf00 with a recording uv shim under the test's own hermetic env: scripts/launcher.py ran ensure_all_deps for mcp_server.hooks.session_start, i.e. a synchronous `uv pip install` of the ML set (70 packages, torch and sentence-transformers) inside the hook process, ignoring CORTEX_EMBEDDING_ZERO_DOWNLOAD. ADR-1104 (#749) moved that install to the detached launcher_ml job; on origin/main the same launch requests the base set (45 packages) only. Nothing pinned that: reverting the launcher to the in-band install failed no fast test, only test_entry.py by hanging. This test runs the real launcher.main() for every hook .claude-plugin/plugin.json wires, with the install seams recorded, and requires the base set alone, plus the background job for SessionStart. Against the reverted launcher it fails in 3 s (['base+ml'] == ['base']) instead of hanging for 300 s. Fixes #747 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VH9JM8qvKiJysP16BmAxAU --- tests_py/scripts/test_launcher_hook_deps.py | 109 ++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests_py/scripts/test_launcher_hook_deps.py diff --git a/tests_py/scripts/test_launcher_hook_deps.py b/tests_py/scripts/test_launcher_hook_deps.py new file mode 100644 index 00000000..104fa073 --- /dev/null +++ b/tests_py/scripts/test_launcher_hook_deps.py @@ -0,0 +1,109 @@ +"""No hook launch installs the ML set in-band (issue #747). + +Before ADR-1104, ``scripts/launcher.py`` ran ``ensure_all_deps`` for +``mcp_server.hooks.session_start``: a synchronous ``uv pip install`` of the ML +set (torch, sentence-transformers, about 1 GB) inside the hook process. When +the deps directory's ML stamp did not name the current uv.lock, the +SessionStart launch blocked on that install, and +``tests_py/hooks/test_entry.py`` hung in ``communicate()`` on the launcher +subprocess until pytest's 300 s timeout aborted the suite. ADR-1104 moved the +ML set to one detached job (``launcher_ml.start``); this test pins that every +hook ``.claude-plugin/plugin.json`` wires installs the base set and nothing +else before it runs, so the in-band install cannot come back unnoticed. + +source: ADR-1104 +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] # source: tests_py/scripts/ +# source: .claude-plugin/plugin.json -- the hook commands the host runs +_PLUGIN_MANIFEST = REPO_ROOT / ".claude-plugin" / "plugin.json" +# source: plugin.json hook command shape, "/scripts/launcher.py" +_LAUNCHED_HOOK = re.compile(r'launcher\.py"\s+(mcp_server\.hooks\.\w+)') + +# Runs the real launcher.main() with the install seams and the dispatch +# replaced by recorders, then prints what the launch asked for. +_CHILD = r""" +import json, runpy, sys +from pathlib import Path +sys.path.insert(0, str(Path.cwd() / "scripts")) +import launcher +calls = {"sets": [], "jobs": []} +launcher.launcher_deps.ensure_deps = lambda d: calls["sets"].append("base") +launcher.launcher_deps.ensure_all_deps = lambda d: calls["sets"].append("base+ml") +def start(deps_dir, target): + calls["jobs"].append(deps_dir) + return "installing", "" +launcher.launcher_ml.start = start +runpy.run_module = lambda module, **kwargs: None +sys.argv = ["launcher.py", sys.argv[1]] +launcher.main() +print(json.dumps(calls)) +""" + + +def _hook_commands() -> list[str]: + manifest = json.loads(_PLUGIN_MANIFEST.read_text(encoding="utf-8")) + return [ + hook["command"] + for entries in manifest["hooks"].values() + for entry in entries + for hook in entry["hooks"] + ] + + +def _wired_hook_modules() -> list[str]: + found = (_LAUNCHED_HOOK.search(command) for command in _hook_commands()) + return sorted({match.group(1) for match in found if match}) + + +def _launch(module: str, tmp_path: Path) -> dict: + home = tmp_path / "home" + home.mkdir() + env = dict(os.environ) + env.update( + HOME=str(home), + CORTEX_CLAUDE_DIR=str(tmp_path / "claude-dir"), + CLAUDE_PLUGIN_DATA=str(tmp_path / "plugin-data"), + CLAUDE_PLUGIN_ROOT=str(REPO_ROOT), + CORTEX_MEMORY_STORE_BACKEND="sqlite", + ) + env.pop("DATABASE_URL", None) + done = subprocess.run( + [sys.executable, "-c", _CHILD, module], + cwd=REPO_ROOT, + input="{}", + capture_output=True, + encoding="utf-8", + env=env, + check=False, + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout.strip().splitlines()[-1]) + + +def test_every_manifest_hook_command_names_the_module_it_launches() -> None: + unparsed = [c for c in _hook_commands() if not _LAUNCHED_HOOK.search(c)] + assert unparsed == [] + assert "mcp_server.hooks.session_start" in _wired_hook_modules() + + +@pytest.mark.parametrize("module", _wired_hook_modules()) +def test_a_hook_launch_installs_only_the_base_set(module: str, tmp_path) -> None: + assert _launch(module, tmp_path)["sets"] == ["base"] + + +def test_session_start_hands_the_ml_set_to_the_background_job(tmp_path) -> None: + calls = _launch("mcp_server.hooks.session_start", tmp_path) + assert calls["sets"] == ["base"] + assert calls["jobs"] == [str(tmp_path / "plugin-data" / "deps")]