diff --git a/mempalace/smoke_upgrade.py b/mempalace/smoke_upgrade.py index 8e86a87b48..13f3f548ea 100644 --- a/mempalace/smoke_upgrade.py +++ b/mempalace/smoke_upgrade.py @@ -31,6 +31,7 @@ import argparse import json import os +import re import shutil import subprocess import sys @@ -38,8 +39,6 @@ from dataclasses import dataclass, field from typing import Iterable -from .version import __version__ # noqa: F401 (kept for parity / introspection) - MIN_VERSION = "3.4.0" # The full set of tools the server is expected to advertise. Pinned explicitly @@ -99,8 +98,10 @@ def _parse_version(v: str) -> list[int]: parts: list[int] = [] for component in str(v).split("."): - digits = "".join(ch for ch in component if ch.isdigit()) - parts.append(int(digits) if digits else 0) + # Take only LEADING digits so a pre-release suffix is the base number, + # not a misread patch: "3.4.0rc1" -> [3, 4, 0], not [3, 4, 1]. + m = re.match(r"\d+", component) + parts.append(int(m.group()) if m else 0) return parts @@ -175,14 +176,19 @@ def _mcp_session( if env: proc_env.update(env) payload = "".join(json.dumps(r) + "\n" for r in requests) - proc = subprocess.run( - server_cmd, - input=payload, - capture_output=True, - text=True, - env=proc_env, - timeout=timeout, - ) + try: + proc = subprocess.run( + server_cmd, + input=payload, + capture_output=True, + text=True, + env=proc_env, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + # A hung server must produce a clean FAIL report (no responses), not + # crash the gate — the gate exists to detect a broken server. + return {} responses: dict = {} for line in proc.stdout.splitlines(): line = line.strip() diff --git a/tests/test_smoke_upgrade.py b/tests/test_smoke_upgrade.py index e94b6356af..1debc66f3e 100644 --- a/tests/test_smoke_upgrade.py +++ b/tests/test_smoke_upgrade.py @@ -17,9 +17,11 @@ (MEMPALACE_PALACE_PATH=tmpdir); the configured palace gains no drawer. """ +import subprocess + import pytest -# RED: this import fails until mempalace/smoke_upgrade.py exists. +import mempalace.smoke_upgrade as su from mempalace.smoke_upgrade import ( EXPECTED_TOOLS, SmokeReport, @@ -54,6 +56,35 @@ def test_version_meets_two_component_is_padded(): assert version_meets("3.3", "3.4.0") is False +def test_version_parse_prerelease_suffix_is_not_mis_read_as_patch(): + # Polish note (3): a pre-release like "3.4.0rc1" must parse the base + # version, NOT read "0rc1" as patch 1. Leading-digits-per-component. + assert su._parse_version("3.4.0rc1") == [3, 4, 0] + assert version_meets("3.4.0rc1", "3.4.0") is True # rc of 3.4.0 satisfies the floor + assert version_meets("3.3.0rc5", "3.4.0") is False # 3.3.x rc is still below + + +def test_mcp_session_timeout_yields_empty_not_crash(monkeypatch): + # Polish note (2): a hung server must produce a clean FAIL report, not crash + # the gate. _mcp_session catches subprocess.TimeoutExpired and returns {}. + def _raise_timeout(*a, **k): + raise subprocess.TimeoutExpired(cmd="server", timeout=1) + + monkeypatch.setattr(su.subprocess, "run", _raise_timeout) + responses = su._mcp_session([{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}]) + assert responses == {} # no responses, no exception + + +def test_run_smoke_reports_fail_when_server_hangs(monkeypatch): + def _raise_timeout(*a, **k): + raise subprocess.TimeoutExpired(cmd="server", timeout=1) + + monkeypatch.setattr(su.subprocess, "run", _raise_timeout) + report = run_smoke(palace_path="/tmp/does-not-matter") + assert isinstance(report, SmokeReport) + assert report.ok is False # FAIL, not a crash + + # ── Pure logic: advertised-tool gate (the staleness catch) ───────────────