From b71a85f01355cd0bf53152e07ac178f603b72a79 Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:25:44 -0400 Subject: [PATCH 001/109] Run public REPL calls in disposable processes --- servers/README.md | 13 +-- servers/lean_runtime.py | 13 +-- servers/repl/core.py | 148 +++++++++++++++++++++++++++++- servers/repl/pool.py | 55 +++++------ servers/repl/server.py | 2 +- tests/test_repl_core_protocol.py | 92 +++++++++++++++++++ tests/test_repl_pool_lifecycle.py | 91 +++++++++++++----- tests/test_shared_lean_runtime.py | 6 +- tests/test_skill_examples.py | 7 ++ 9 files changed, 356 insertions(+), 71 deletions(-) diff --git a/servers/README.md b/servers/README.md index e138c07a3..99c5374c0 100644 --- a/servers/README.md +++ b/servers/README.md @@ -10,16 +10,17 @@ when useful. Plugin hosts start the two stdio MCP processes automatically. They are lightweight adapters: the first Lean tool call race-safely starts a detached runtime for the current AutoformBot installation, Unix user, and compute node. -That runtime owns one resident REPL pool and LSP session per active Lean -project, so sessions using that installation reuse the same warmed processes. +That runtime owns one REPL admission pool and LSP session per active Lean +project. LSP sessions stay warm; each public REPL call gets a fresh child and +no process-owned environment or proof-state handle survives the response. Closing the session that started it does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an in-place upgrade gracefully replaces the older build. -REPL and LSP processes remain lazy. A cold tool call stays pending while Lean -warms up, so no `/repl-start`, `/lsp-start`, or model-side sleep is needed. Idle -project processes are closed after 30 minutes by default, while the small -runtime remains available. Its lifecycle is also explicit: +REPL children and LSP processes remain lazy. A cold tool call stays pending +while Lean warms up, so no `/repl-start`, `/lsp-start`, or model-side sleep is +needed. Idle project state is closed after 30 minutes by default, while the +small runtime remains available. Its lifecycle is also explicit: ```bash uv run autoform-lean-runtime start diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index b328c797d..5d6c359ee 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -46,10 +46,9 @@ LspProtocolError, format_lsp_diagnostics, ) -from servers.repl.core import DEFAULT_REPL_STARTUP_TIMEOUT, format_repl_response +from servers.repl.core import format_repl_response from servers.repl.pool import ( DEFAULT_RAM_FRACTION, - DEFAULT_STARTUP_STAGGER_SECONDS, LeanReplPool, LeanReplPoolConfig, ) @@ -80,12 +79,8 @@ class ProjectResourceBusyError(TimeoutError): def _repl_creation_budget(worker_count: int) -> float: - """Bound victim cleanup, cold startup, and failed-start cleanup.""" - return ( - worker_count * DEFAULT_REPL_STARTUP_TIMEOUT - + max(0, worker_count - 1) * DEFAULT_STARTUP_STAGGER_SECONDS - + 2 * worker_count * REPL_WORKER_CLOSE_BUDGET - ) + """Bound cleanup of an evicted pool; cold slots spawn no subprocesses.""" + return worker_count * REPL_WORKER_CLOSE_BUDGET def _positive_int(name: str, default: int) -> int: @@ -218,7 +213,7 @@ def from_environment(cls) -> "LeanRuntimeConfig": ): raise ValueError( "AUTOFORM_RUNTIME_RESPONSE_TIMEOUT is too small for the configured " - "REPL worker startup and request limits" + "REPL pool replacement and request limits" ) if ( LSP_CLOSE_BUDGET diff --git a/servers/repl/core.py b/servers/repl/core.py index 7d188017d..cdedf2ed2 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -530,6 +530,14 @@ def __init__(self, message: str, response: Any) -> None: self.response = response +class ReplCleanupError(RuntimeError): + """A disposable call produced a result but its process was not reaped.""" + + def __init__(self, message: str, result: dict[str, Any]) -> None: + super().__init__(message) + self.result = result + + class LeanRepl: """Lean REPL process manager. @@ -566,6 +574,8 @@ def __init__(self, config: LeanReplConfig) -> None: def start( self, startup_timeout: float | None = None, + *, + warmup_imports: frozenset[str] | tuple[str, ...] | None = None, ) -> None: """Start and warm the Lean REPL within one startup deadline.""" if os.name != "posix": @@ -600,13 +610,18 @@ def remaining() -> float: self._stderr_bytes = 0 self._stderr_tail.clear() - if self.config.warmup_imports: + startup_imports = ( + self.config.warmup_imports + if warmup_imports is None + else warmup_imports + ) + if startup_imports: header = "\n".join( - f"import {root}" for root in self.config.warmup_imports + f"import {root}" for root in startup_imports ) logger.info( "Loading imports at startup: %s", - self.config.warmup_imports, + startup_imports, ) resp = self._run(code=header, env_id=None, timeout=remaining()) environment, messages = _validate_command_response( @@ -713,6 +728,133 @@ def get_memory_usage(self) -> float: """Return memory usage in GB.""" return _get_process_memory_gb(self.process) + def is_clean(self) -> bool: + """Return whether this wrapper owns no process generation.""" + return self.process is None and self._process_group_id is None + + def run_disposable( + self, + code: str, + timeout: float | None = None, + ) -> dict[str, Any]: + """Run one public call as the only request frame on a fresh process.""" + timeout = self.request_timeout if timeout is None else timeout + deadline = time.monotonic() + timeout + + def remaining() -> float: + value = deadline - time.monotonic() + if value <= 0: + raise TimeoutError( + f"REPL command timed out after {timeout:g} seconds" + ) + return value + + with self._process_lock: + result: dict[str, Any] | None = None + request_error: BaseException | None = None + added_imports: tuple[str, ...] = () + try: + # A previous failed cleanup must settle before another process + # generation can be created from this slot. + self.close(deadline=deadline) + imports, _, _ = _split_imports_and_body(code) + submitted_roots = {statement.split(".")[0] for statement in imports} + if ( + self.config.validate_imports + and self._allowed_import_roots is not None + ): + disallowed = submitted_roots - self._allowed_import_roots + if disallowed: + result = { + "repl_error": ( + f"Disallowed imports: {', '.join(sorted(disallowed))}. " + "Allowed roots: " + f"{', '.join(sorted(self._allowed_import_roots))}." + ) + } + if result is None: + added_imports = tuple( + root + for root in sorted(self.config.warmup_imports) + if root not in submitted_roots + ) + prefix = "\n".join( + f"import {root}" for root in added_imports + ) + command = f"{prefix}\n{code}" if prefix else code + # Do not send startup import or smoke-test frames. The + # submitted command is the generation's only request. + self.start( + startup_timeout=remaining(), + warmup_imports=(), + ) + response = self._run( + code=command, + env_id=None, + timeout=remaining(), + ) + _validate_command_response( + response, + context="the requested command", + require_environment=True, + ) + _adjust_line_numbers(response, -len(added_imports)) + result = _without_process_handles(response) + except ReplStderrBacklog as error: + try: + _validate_command_response( + error.response, + context="the requested command", + require_environment=True, + ) + except ReplCommandError as command_error: + result = {"repl_error": str(command_error)} + except ReplProtocolError as protocol_error: + result = { + "repl_error": str(protocol_error), + "outcome_unknown": True, + } + else: + response = _without_process_handles(error.response) + _adjust_line_numbers(response, -len(added_imports)) + result = response + except ReplCommandError as error: + result = {"repl_error": str(error)} + except (ReplProtocolError, ReplOutcomeUnknown) as error: + result = {"repl_error": str(error), "outcome_unknown": True} + except (ReplProcessExited, TimeoutError, RuntimeError) as error: + result = {"repl_error": str(error)} + except BaseException as error: + request_error = error + finally: + try: + self.close( + deadline=time.monotonic() + DEFAULT_REPL_CLEANUP_SECONDS + ) + except BaseException as cleanup_error: + logger.exception( + "failed to retire disposable Lean REPL process" + ) + if request_error is not None: + note = f"Lean REPL process cleanup also failed: {cleanup_error}" + add_note = getattr(request_error, "add_note", None) + if add_note is not None: + add_note(note) + elif not isinstance(cleanup_error, Exception): + raise + elif result is not None: + raise ReplCleanupError( + "Disposable Lean REPL cleanup failed after a result " + f"was produced; the result was not returned: {cleanup_error}", + result, + ) from cleanup_error + + if request_error is not None: + raise request_error.with_traceback(request_error.__traceback__) + if result is None: + raise RuntimeError("disposable Lean REPL call produced no result") + return result + def run(self, code: str, env_id: int | None = None, timeout: float | None = None) -> dict[str, Any]: """Send code to the REPL within one deadline across recovery attempts.""" timeout = self.request_timeout if timeout is None else timeout diff --git a/servers/repl/pool.py b/servers/repl/pool.py index ff543a5f6..736876e18 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -15,7 +15,6 @@ DEFAULT_PORT = 8990 DEFAULT_RAM_FRACTION = 0.5 -DEFAULT_STARTUP_STAGGER_SECONDS = 2.0 @dataclass @@ -23,7 +22,6 @@ class LeanReplPoolConfig(LeanReplConfig): """Configuration for a pool of Lean REPL instances.""" num_repls: int | None = None - startup_stagger: float = DEFAULT_STARTUP_STAGGER_SECONDS def __post_init__(self) -> None: if self.num_repls is None: @@ -37,10 +35,9 @@ def __post_init__(self) -> None: class LeanReplPool: - """Pool of Lean REPL instances with queue-based load balancing. + """Pool of cold Lean REPL slots with queue-based load balancing. - Each worker thread owns its own LeanRepl subprocess. Tasks are - distributed to idle workers via a FIFO queue. + Slots retain Python wrappers, never Lean subprocesses, between calls. """ def __init__(self, config: LeanReplPoolConfig) -> None: @@ -53,23 +50,8 @@ def __init__(self, config: LeanReplPoolConfig) -> None: self._lock = threading.Lock() try: - for i in range(self.capacity): - if i > 0: - import time - - time.sleep(config.startup_stagger) + for _ in range(self.capacity): repl = LeanRepl(config) - try: - repl.start() - except BaseException: - # LeanRepl.start() currently cleans up its own process, but - # keep the pool transaction safe for alternate/test workers - # and future implementations too. - try: - repl.close() - except Exception: - logger.exception("failed to close REPL after startup error") - raise self._workers.append(repl) self._idle.put(repl) except BaseException: @@ -78,20 +60,29 @@ def __init__(self, config: LeanReplPoolConfig) -> None: def _close_workers(self) -> None: """Close every constructed worker, preserving cleanup after one failure.""" + failed_workers = [] + first_error: BaseException | None = None for worker in reversed(self._workers): try: worker.close() - except Exception: + except BaseException as error: logger.exception("failed to close REPL worker") - self._workers.clear() + failed_workers.append(worker) + if first_error is None: + first_error = error + self._workers = list(reversed(failed_workers)) while True: try: self._idle.get_nowait() except queue.Empty: break + if first_error is not None: + raise first_error def run(self, code: str, **kwargs: Any) -> dict[str, Any]: """Run code on an idle REPL within one queue-and-execution timeout.""" + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") timeout = kwargs.pop("timeout", None) deadline = time.monotonic() + timeout if timeout is not None else None try: @@ -102,20 +93,30 @@ def run(self, code: str, **kwargs: Any) -> dict[str, Any]: ) from error def run_once() -> dict[str, Any]: - call_kwargs = dict(kwargs) + if kwargs: + names = ", ".join(sorted(kwargs)) + raise TypeError(f"unsupported Lean REPL pool arguments: {names}") if deadline is not None: remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError( f"timed out after {timeout:g}s waiting for an idle Lean REPL" ) - call_kwargs["timeout"] = remaining - return repl.run(code, **call_kwargs) + return repl.run_disposable(code, timeout=remaining) + return repl.run_disposable(code) try: + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") return run_once() finally: - self._idle.put(repl) + if repl.is_clean() and not self._shutdown: + self._idle.put(repl) + else: + # A cleanup failure keeps its wrapper (and process handle) in + # this pool but permanently stops new admission. Runtime + # shutdown will retry cleanup; no replacement generation starts. + self._shutdown = True def get_memory_usage(self) -> float: """Total memory usage across all REPL instances in GB.""" diff --git a/servers/repl/server.py b/servers/repl/server.py index d0a72c3d0..59ea88936 100644 --- a/servers/repl/server.py +++ b/servers/repl/server.py @@ -15,7 +15,7 @@ def create_repl_server(runtime: LeanRuntimeClient) -> FastMCP: @server.tool def run_lean_code(project_dir: str, code: str, timeout: float | None = None) -> str: - """Compile a Lean snippet in a project's persistent REPL. + """Compile a Lean snippet in a fresh project-scoped REPL process. Args: project_dir: Absolute path to the Lake project root. diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 06622e4d9..1f8bb829c 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -737,6 +737,98 @@ def fake_select(readable, writable, exceptional, timeout=None): monkeypatch.setattr(repl_core.select, "select", fake_select) +def test_disposable_call_uses_one_frame_and_removes_process_handles(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + validate_imports=False, + warmup_imports=frozenset({"Mathlib"}), + ) + ) + starts = [] + calls = [] + closes = [] + + def close(**kwargs): + closes.append(kwargs) + repl.process = None + repl._process_group_id = None + + def start(**kwargs): + starts.append(kwargs) + repl.process = object() + repl._process_group_id = 7 + + monkeypatch.setattr(repl, "close", close) + monkeypatch.setattr(repl, "start", start) + + def run(code, env_id, timeout): + calls.append((code, env_id, timeout)) + return { + "env": 4, + "messages": [], + "sorries": [{"goal": "False", "proofState": 8}], + } + + monkeypatch.setattr(repl, "_run", run) + + assert repl.run_disposable("#check Nat", timeout=3) == { + "messages": [], + "sorries": [{"goal": "False"}], + } + assert len(starts) == 1 + assert starts[0]["warmup_imports"] == () + assert 0 < starts[0]["startup_timeout"] <= 3 + assert len(calls) == 1 + assert calls[0][0] == "import Mathlib\n#check Nat" + assert calls[0][1] is None + assert len(closes) == 2 + + +def test_disposable_call_does_not_return_a_result_before_verified_cleanup(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) + ) + close_calls = 0 + + def close(**kwargs): + nonlocal close_calls + close_calls += 1 + if close_calls == 2: + raise RuntimeError("cleanup failed") + repl.process = None + repl._process_group_id = None + + monkeypatch.setattr(repl, "close", close) + monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr( + repl, + "_run", + lambda **kwargs: {"env": 1, "messages": []}, + ) + + with pytest.raises(repl_core.ReplCleanupError, match="was not returned") as error: + repl.run_disposable("#check Nat", timeout=1) + + assert error.value.result == {"messages": []} + + +def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset(), + ) + ) + starts = [] + monkeypatch.setattr(repl, "start", lambda **kwargs: starts.append(kwargs)) + + result = repl.run_disposable("import Unsafe\n#check Nat", timeout=1) + + assert "Disallowed imports: Unsafe" in result["repl_error"] + assert starts == [] + assert repl.is_clean() + + def test_response_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 81f36a62d..6a39b0910 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -10,34 +10,28 @@ from servers.repl import pool as repl_pool -def test_partial_pool_startup_closes_all_constructed_workers(monkeypatch): +def test_partial_pool_construction_closes_all_constructed_workers(monkeypatch): workers = [] class FakeRepl: def __init__(self, config): self.number = len(workers) + 1 - self.started = False self.closed = False workers.append(self) - - def start(self): - self.started = True if self.number == 2: - raise RuntimeError("second worker failed") + raise RuntimeError("second slot failed") def close(self): self.closed = True monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - config = repl_pool.LeanReplPoolConfig(num_repls=3, startup_stagger=0) + config = repl_pool.LeanReplPoolConfig(num_repls=3) - with pytest.raises(RuntimeError, match="second worker failed"): + with pytest.raises(RuntimeError, match="second slot failed"): repl_pool.LeanReplPool(config) assert len(workers) == 2 - assert workers[0].started is True assert workers[0].closed is True - assert workers[1].closed is True def test_shutdown_closes_every_worker_and_drains_idle_queue(monkeypatch): @@ -48,16 +42,11 @@ def __init__(self, config): self.closed = False workers.append(self) - def start(self): - pass - def close(self): self.closed = True monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - pool = repl_pool.LeanReplPool( - repl_pool.LeanReplPoolConfig(num_repls=2, startup_stagger=0) - ) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=2)) pool.shutdown() @@ -71,16 +60,11 @@ class FakeRepl: def __init__(self, config): pass - def start(self): - pass - def close(self): pass monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - pool = repl_pool.LeanReplPool( - repl_pool.LeanReplPoolConfig(num_repls=1, startup_stagger=0) - ) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) borrowed = pool._idle.get_nowait() try: with pytest.raises(TimeoutError, match="waiting for an idle Lean REPL"): @@ -90,6 +74,69 @@ def close(self): pool.shutdown() +def test_pool_runs_each_call_disposably_and_reuses_only_a_clean_slot(monkeypatch): + workers = [] + + class FakeRepl: + def __init__(self, config): + self.calls = [] + workers.append(self) + + def run_disposable(self, code, **kwargs): + self.calls.append((code, kwargs)) + return {"messages": []} + + def is_clean(self): + return True + + def close(self): + pass + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + try: + assert pool.run("#check Nat", timeout=2) == {"messages": []} + assert pool.run("#check Int", timeout=2) == {"messages": []} + finally: + pool.shutdown() + + assert len(workers) == 1 + assert [call[0] for call in workers[0].calls] == ["#check Nat", "#check Int"] + + +def test_pool_quarantines_every_slot_after_unverified_cleanup(monkeypatch): + class FakeRepl: + def __init__(self, config): + self.dirty = False + + def run_disposable(self, code, **kwargs): + self.dirty = True + raise repl_core.ReplCleanupError("cleanup failed", {"messages": []}) + + def is_clean(self): + return not self.dirty + + def close(self): + self.dirty = False + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=2)) + + with pytest.raises(repl_core.ReplCleanupError, match="cleanup failed"): + pool.run("#check Nat", timeout=1) + with pytest.raises(RuntimeError, match="pool is shut down"): + pool.run("#check Int", timeout=1) + + assert pool._idle.qsize() == 1 + pool.shutdown() + + def test_repl_retry_recovery_uses_the_original_deadline(monkeypatch): clock = {"now": 0.0} repl = repl_core.LeanRepl( diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 8b61bec55..38ad8fccc 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -589,11 +589,11 @@ def test_per_project_workers_cannot_exceed_node_budget(monkeypatch): LeanRuntimeConfig.from_environment() -def test_response_budget_includes_replacement_and_failed_pool_cleanup(monkeypatch): +def test_response_budget_includes_pool_replacement_cleanup(monkeypatch): monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "3") monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "3") - monkeypatch.setenv("AUTOFORM_RUNTIME_RESPONSE_TIMEOUT", "860") - with pytest.raises(ValueError, match="REPL worker startup"): + monkeypatch.setenv("AUTOFORM_RUNTIME_RESPONSE_TIMEOUT", "299") + with pytest.raises(ValueError, match="REPL pool replacement"): LeanRuntimeConfig.from_environment() diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 223d64c46..375593bb6 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -105,6 +105,13 @@ def test_quick_start_keeps_the_cli_agent_facing(repo_root: Path) -> None: assert "uv run autoform" not in quick_start +def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) -> None: + servers = (repo_root / "servers/README.md").read_text(encoding="utf-8") + + assert "each public REPL call gets a fresh child" in servers + assert "no process-owned environment or proof-state handle survives" in servers + + def test_setup_asset_is_a_repo_shaped_thesis_vault(repo_root: Path) -> None: example = repo_root / _EXAMPLE blueprint = example / "blueprint" From c0bae84e6c659d145cf622e34d493ada9fe097cd Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:36:01 -0400 Subject: [PATCH 002/109] Validate REPL imports with the selected Lean parser --- servers/README.md | 3 + servers/repl/core.py | 302 ++++++++++++++++++++++++++++++- tests/test_repl_core_protocol.py | 160 ++++++++++++++++ tests/test_skill_examples.py | 2 + 4 files changed, 464 insertions(+), 3 deletions(-) diff --git a/servers/README.md b/servers/README.md index 99c5374c0..6622388c9 100644 --- a/servers/README.md +++ b/servers/README.md @@ -13,6 +13,9 @@ runtime for the current AutoformBot installation, Unix user, and compute node. That runtime owns one REPL admission pool and LSP session per active Lean project. LSP sessions stay warm; each public REPL call gets a fresh child and no process-owned environment or proof-state handle survives the response. +Before that child starts, the selected Lake toolchain's `lean --deps-json` +parser validates the submitted header and its complete import set; a rejected +or unrecognized parser response fails closed. Closing the session that started it does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an in-place upgrade gracefully replaces the older build. diff --git a/servers/repl/core.py b/servers/repl/core.py index cdedf2ed2..b5b923d53 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -13,6 +13,7 @@ import select import signal import subprocess +import sys import threading import time from dataclasses import dataclass, field @@ -34,6 +35,11 @@ _STDERR_TAIL_BYTES = 200 _PUBLIC_DIAGNOSTIC_FIELDS = frozenset({"severity", "data", "pos", "endPos"}) _PUBLIC_SORRY_FIELDS = frozenset({"goal", "pos", "endPos"}) +_LEAN_HEADER_LAUNCHER = ( + "import os; " + "lean = os.path.join(os.environ['LEAN_SYSROOT'], 'bin', 'lean'); " + "os.execv(lean, [lean, '--deps-json', '/dev/stdin'])" +) # --------------------------------------------------------------------------- @@ -290,6 +296,258 @@ def _validate_command_response( return environment, messages +class _HeaderProcessCleanupError(RuntimeError): + """Header validation ended without verified process cleanup.""" + + def __init__( + self, + message: str, + process: subprocess.Popen[bytes], + original_error: BaseException | None, + cleanup_error: BaseException, + ) -> None: + super().__init__(message) + self.process = process + self.original_error = original_error + self.cleanup_error = cleanup_error + + +def _communicate_bounded( + process: subprocess.Popen[bytes], + input_bytes: bytes, + *, + deadline: float, + max_output_bytes: int, +) -> tuple[bytes, bytes]: + """Exchange bytes with a child without unbounded output or time.""" + if max_output_bytes < 1: + raise ValueError("header parser output limit must be positive") + if process.stdin is None or process.stdout is None or process.stderr is None: + raise RuntimeError("header parser pipes are unavailable") + + stdin_fd = process.stdin.fileno() + stdout_fd = process.stdout.fileno() + stderr_fd = process.stderr.fileno() + for fd in (stdin_fd, stdout_fd, stderr_fd): + os.set_blocking(fd, False) + + pending = memoryview(input_bytes) + offset = 0 + stdin_open = True + stdout_open = True + stderr_open = True + stdout = bytearray() + stderr = bytearray() + + def close_stdin() -> None: + nonlocal stdin_open + if not stdin_open: + return + stdin_open = False + try: + process.stdin.close() + except OSError: + pass + + while stdin_open or stdout_open or stderr_open: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("timed out checking the Lean header") + readable_fds: list[int] = [] + if stdout_open: + readable_fds.append(stdout_fd) + if stderr_open: + readable_fds.append(stderr_fd) + writable_fds = [stdin_fd] if stdin_open else [] + try: + readable, writable, _ = select.select( + readable_fds, + writable_fds, + [], + remaining, + ) + except InterruptedError: + continue + if not readable and not writable: + raise TimeoutError("timed out checking the Lean header") + + for fd, target in ((stdout_fd, stdout), (stderr_fd, stderr)): + if fd not in readable: + continue + try: + chunk = os.read(fd, 65536) + except BlockingIOError: + continue + if not chunk: + if fd == stdout_fd: + stdout_open = False + else: + stderr_open = False + continue + if len(stdout) + len(stderr) + len(chunk) > max_output_bytes: + raise ValueError( + f"Lean header parser output exceeded {max_output_bytes} bytes" + ) + target.extend(chunk) + + if stdin_fd in writable: + try: + written = os.write(stdin_fd, pending[offset : offset + 65536]) + except BlockingIOError: + continue + except BrokenPipeError: + close_stdin() + else: + if written <= 0: + close_stdin() + else: + offset += written + if offset == len(pending): + close_stdin() + + close_stdin() + if process.poll() is None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("timed out checking the Lean header") + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + raise TimeoutError("timed out checking the Lean header") from None + return bytes(stdout), bytes(stderr) + + +def _decode_header_modules(stdout: bytes) -> list[str]: + """Decode the strict schemas emitted by Lean's fast import parser.""" + + def reject_constant(value: str) -> None: + raise ValueError(f"nonstandard JSON constant {value!r}") + + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key {key!r}") + result[key] = value + return result + + try: + payload = json.loads( + stdout.decode("utf-8"), + parse_constant=reject_constant, + object_pairs_hook=reject_duplicate_keys, + ) + if not isinstance(payload, dict) or set(payload) != {"imports"}: + raise ValueError + entries = payload["imports"] + if not isinstance(entries, list) or len(entries) != 1: + raise ValueError + entry = entries[0] + if not isinstance(entry, dict): + raise ValueError + errors = entry.get("errors") + if not isinstance(errors, list) or not all( + isinstance(error, str) and error for error in errors + ): + raise ValueError + if errors: + raise ValueError(errors[0]) + + has_result = "result" in entry + has_imports = "imports" in entry + if has_result == has_imports: + raise ValueError + if has_result: + result = entry["result"] + if not isinstance(result, dict): + raise ValueError + imports = result.get("imports") + else: + imports = entry["imports"] + if not isinstance(imports, list): + raise ValueError + + modules: list[str] = [] + for item in imports: + if not isinstance(item, dict): + raise ValueError + module = item.get("module") + if not isinstance(module, str) or not module: + raise ValueError + if module != "Init": + modules.append(module) + return modules + except UnicodeDecodeError: + raise ValueError("unrecognized output from lean --deps-json") from None + except (TypeError, KeyError, json.JSONDecodeError): + raise ValueError("unrecognized output from lean --deps-json") from None + except ValueError as error: + if str(error): + raise + raise ValueError("unrecognized output from lean --deps-json") from None + + +def _lean_header_modules( + command: list[str], + code: str, + *, + cwd: str | None, + env: dict[str, str], + deadline: float, + max_output_bytes: int, +) -> list[str]: + """Return imports parsed from ``code`` by the selected Lean toolchain.""" + if deadline - time.monotonic() <= 0: + raise TimeoutError("timed out before checking the Lean header") + process = subprocess.Popen( + command, + cwd=cwd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=True, + ) + original_error: BaseException | None = None + stdout = b"" + stderr = b"" + try: + stdout, stderr = _communicate_bounded( + process, + code.encode(), + deadline=deadline, + max_output_bytes=max_output_bytes, + ) + except BaseException as error: + original_error = error + try: + _kill_subprocesses( + process, + process.pid, + time.monotonic() + DEFAULT_REPL_CLEANUP_SECONDS, + ) + except BaseException as cleanup_error: + raise _HeaderProcessCleanupError( + f"Lean header parser cleanup failed: {cleanup_error}", + process, + original_error, + cleanup_error, + ) from cleanup_error + finally: + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None and not stream.closed: + try: + stream.close() + except OSError: + pass + if original_error is not None: + raise original_error.with_traceback(original_error.__traceback__) + if process.returncode != 0: + detail = stderr.decode(errors="replace").strip().splitlines() + raise ValueError(detail[0] if detail else f"exit status {process.returncode}") + return _decode_header_modules(stdout) + + def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: """Split Lean code into import statements and body. @@ -338,6 +596,15 @@ class LeanReplConfig: warmup_imports: frozenset[str] = WARMUP_IMPORTS repl_command: list[str] = field(default_factory=lambda: ["lake", "exe", "repl"]) + header_deps_command: list[str] = field( + default_factory=lambda: [ + "lake", + "env", + sys.executable, + "-c", + _LEAN_HEADER_LAUNCHER, + ] + ) # stdout is capped per response. stderr has no protocol framing, so its # ceiling applies to the entire process generation and resets on restart. @@ -757,11 +1024,40 @@ def remaining() -> float: # A previous failed cleanup must settle before another process # generation can be created from this slot. self.close(deadline=deadline) - imports, _, _ = _split_imports_and_body(code) - submitted_roots = {statement.split(".")[0] for statement in imports} + lexical_imports, _, _ = _split_imports_and_body(code) + header_modules = lexical_imports if ( self.config.validate_imports and self._allowed_import_roots is not None + ): + env = _inherit_clean_env() + env.update(self.config.env) + try: + header_modules = _lean_header_modules( + self.config.header_deps_command, + code, + cwd=self.cwd, + env=env, + deadline=deadline, + max_output_bytes=self.config.max_buffer_bytes, + ) + except _HeaderProcessCleanupError as error: + # Preserve ownership so the normal disposable cleanup + # retries before this slot can ever be reused. + self.process = error.process + self._process_group_id = error.process.pid + self._retire_pending = True + raise + except ValueError as error: + result = {"repl_error": f"Rejected Lean header: {error}"} + header_modules = [] + submitted_roots = { + module.split(".")[0] for module in header_modules + } + if ( + result is None + and self.config.validate_imports + and self._allowed_import_roots is not None ): disallowed = submitted_roots - self._allowed_import_roots if disallowed: @@ -822,7 +1118,7 @@ def remaining() -> float: result = {"repl_error": str(error)} except (ReplProtocolError, ReplOutcomeUnknown) as error: result = {"repl_error": str(error), "outcome_unknown": True} - except (ReplProcessExited, TimeoutError, RuntimeError) as error: + except (ReplProcessExited, TimeoutError, RuntimeError, OSError) as error: result = {"repl_error": str(error)} except BaseException as error: request_error = error diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 1f8bb829c..0bc161413 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -817,6 +817,7 @@ def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): repl_core.LeanReplConfig( allowed_imports=frozenset({"Mathlib"}), warmup_imports=frozenset(), + header_deps_command=_fake_header_deps(_deps_json("Unsafe")), ) ) starts = [] @@ -829,6 +830,165 @@ def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): assert repl.is_clean() +def _fake_header_deps(stdout: str, returncode: int = 0, stderr: str = "") -> list[str]: + script = ( + "import sys; sys.stdin.read(); " + f"sys.stdout.write({stdout!r}); sys.stderr.write({stderr!r}); " + f"sys.exit({returncode})" + ) + return [sys.executable, "-c", script] + + +def _deps_json(*modules: str, errors: tuple[str, ...] = ()) -> str: + entry: dict = {"errors": list(errors)} + if not errors: + entry["result"] = { + "imports": [ + {"module": module, "importAll": False} for module in modules + ], + "isModule": False, + } + return json.dumps({"imports": [entry]}) + + +def _header_modules(command: list[str]) -> list[str]: + return repl_core._lean_header_modules( + command, + "import Mathlib", + cwd=None, + env=dict(os.environ), + deadline=time.monotonic() + 10, + max_output_bytes=1024 * 1024, + ) + + +def test_header_check_accepts_current_and_legacy_lean_schemas(): + current = _fake_header_deps( + _deps_json("Init", "Mathlib.Tactic", "«Mathlib.X»") + ) + legacy = _fake_header_deps( + json.dumps( + { + "imports": [ + { + "errors": [], + "imports": [ + {"module": "Init", "importAll": False}, + {"module": "Mathlib.Tactic", "importAll": False}, + ], + } + ] + } + ) + ) + + assert _header_modules(current) == ["Mathlib.Tactic", "«Mathlib.X»"] + assert _header_modules(legacy) == ["Mathlib.Tactic"] + + +def test_default_header_parser_selects_lean_from_the_lake_toolchain(): + command = repl_core.LeanReplConfig().header_deps_command + + assert command[:3] == ["lake", "env", sys.executable] + assert "LEAN_SYSROOT" in command[-1] + + +def test_header_parser_launcher_ignores_a_path_shadow(tmp_path): + trusted_bin = tmp_path / "toolchain" / "bin" + shadow_bin = tmp_path / "shadow" + trusted_bin.mkdir(parents=True) + shadow_bin.mkdir() + + def write_parser(path, module): + path.write_text( + f"#!{sys.executable}\n" + "import sys\n" + "sys.stdin.read()\n" + f"sys.stdout.write({_deps_json(module)!r})\n" + ) + path.chmod(0o755) + + write_parser(trusted_bin / "lean", "Mathlib") + write_parser(shadow_bin / "lean", "Unsafe") + env = dict(os.environ) + env["LEAN_SYSROOT"] = str(trusted_bin.parent) + env["PATH"] = f"{shadow_bin}{os.pathsep}{env.get('PATH', '')}" + + modules = repl_core._lean_header_modules( + [sys.executable, "-c", repl_core._LEAN_HEADER_LAUNCHER], + "import Mathlib", + cwd=None, + env=env, + deadline=time.monotonic() + 10, + max_output_bytes=1024 * 1024, + ) + + assert modules == ["Mathlib"] + + +@pytest.mark.parametrize( + ("output", "message"), + [ + (_deps_json(errors=("bad header",)), "bad header"), + ("not json", "unrecognized output"), + ('{"imports": [], "imports": []}', "duplicate JSON key"), + ('{"imports": NaN}', "nonstandard JSON constant"), + ], +) +def test_header_check_fails_closed_on_rejected_or_unknown_output(output, message): + with pytest.raises(ValueError, match=message): + _header_modules(_fake_header_deps(output)) + + +@pytest.mark.parametrize( + ("deps_output", "expected_error"), + [ + (_deps_json("Mathlib", "Unsafe.Mod"), "Disallowed imports: Unsafe"), + (_deps_json(errors=("bad header",)), "Rejected Lean header: bad header"), + ], +) +def test_disposable_call_rejects_what_lean_reports_before_starting( + monkeypatch, deps_output, expected_error +): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset(), + header_deps_command=_fake_header_deps(deps_output), + ) + ) + monkeypatch.setattr( + repl, + "start", + lambda *args, **kwargs: pytest.fail("invalid input must not start Lean"), + ) + + response = repl.run_disposable("/- hidden -/ import Unsafe\n#check Nat") + + assert expected_error in response["repl_error"] + assert repl.is_clean() + + +def test_disposable_call_checks_submitted_header_before_warmup_prefix(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset({"Mathlib"}), + ) + ) + checked = [] + + def header_modules(command, code, **kwargs): + checked.append(code) + raise ValueError("stop") + + monkeypatch.setattr(repl_core, "_lean_header_modules", header_modules) + + repl.run_disposable("/- note -/ import Unsafe\n#check Nat") + + assert checked == ["/- note -/ import Unsafe\n#check Nat"] + + def test_response_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 375593bb6..4b2bc5a1e 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -110,6 +110,8 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "each public REPL call gets a fresh child" in servers assert "no process-owned environment or proof-state handle survives" in servers + assert "toolchain's `lean --deps-json`" in servers + assert "unrecognized parser response fails closed" in servers def test_setup_asset_is_a_repo_shaped_thesis_vault(repo_root: Path) -> None: From 02f3c682cfc96bec609a71cf59165515fbc2ed21 Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:37:32 -0400 Subject: [PATCH 003/109] Qualify the selected REPL package target --- servers/README.md | 3 ++- servers/lean_runtime.py | 4 +++- servers/repl/core.py | 4 +++- tests/test_shared_lean_runtime.py | 10 ++++++++++ tests/test_skill_examples.py | 1 + 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/servers/README.md b/servers/README.md index 6622388c9..3f5bc29bd 100644 --- a/servers/README.md +++ b/servers/README.md @@ -15,7 +15,8 @@ project. LSP sessions stay warm; each public REPL call gets a fresh child and no process-owned environment or proof-state handle survives the response. Before that child starts, the selected Lake toolchain's `lean --deps-json` parser validates the submitted header and its complete import set; a rejected -or unrecognized parser response fails closed. +or unrecognized parser response fails closed. Execution names the +package-qualified `@repl/repl` target so a project target cannot shadow it. Closing the session that started it does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an in-place upgrade gracefully replaces the older build. diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 5d6c359ee..538fbbe3f 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -172,7 +172,9 @@ def from_environment(cls) -> "LeanRuntimeConfig": "AUTOFORM_REPL_TOTAL_WORKERS" ) repl_project_limit = min(max_projects, total_workers // workers_per_project) - repl_command = tuple(shlex.split(os.environ.get("LEAN_REPL_CMD", "lake exe repl"))) + repl_command = tuple( + shlex.split(os.environ.get("LEAN_REPL_CMD", "lake exe @repl/repl")) + ) lsp_command = tuple(shlex.split(os.environ.get("LEAN_LSP_CMD", "lake serve"))) if not repl_command: raise ValueError("LEAN_REPL_CMD must not be empty") diff --git a/servers/repl/core.py b/servers/repl/core.py index b5b923d53..a4df78739 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -595,7 +595,9 @@ class LeanReplConfig: allowed_imports: frozenset[str] = ALLOWED_IMPORTS warmup_imports: frozenset[str] = WARMUP_IMPORTS - repl_command: list[str] = field(default_factory=lambda: ["lake", "exe", "repl"]) + repl_command: list[str] = field( + default_factory=lambda: ["lake", "exe", "@repl/repl"] + ) header_deps_command: list[str] = field( default_factory=lambda: [ "lake", diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 38ad8fccc..8a239594a 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -589,6 +589,16 @@ def test_per_project_workers_cannot_exceed_node_budget(monkeypatch): LeanRuntimeConfig.from_environment() +def test_default_repl_command_is_package_qualified(monkeypatch): + monkeypatch.delenv("LEAN_REPL_CMD", raising=False) + + assert LeanRuntimeConfig.from_environment().repl_command == ( + "lake", + "exe", + "@repl/repl", + ) + + def test_response_budget_includes_pool_replacement_cleanup(monkeypatch): monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "3") monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "3") diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 4b2bc5a1e..4c64923aa 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -112,6 +112,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain's `lean --deps-json`" in servers assert "unrecognized parser response fails closed" in servers + assert "package-qualified `@repl/repl` target" in servers def test_setup_asset_is_a_repo_shaped_thesis_vault(repo_root: Path) -> None: From c04c2c22b12163844826ec4dadf02a46ef3e73e1 Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:41:02 -0400 Subject: [PATCH 004/109] Pin the consumer REPL dependency in setup --- skills/setup/SKILL.md | 6 +- .../assets/cabannes-thesis-project/README.md | 2 +- .../lake-manifest.json | 106 ++++++++++++++++++ .../cabannes-thesis-project/lakefile.toml | 5 + tests/test_skill_examples.py | 7 ++ 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 skills/setup/assets/cabannes-thesis-project/lake-manifest.json diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 1a3f69fe3..4d85fdfd2 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -42,7 +42,10 @@ bash "/scripts/make_project.sh" \ For a new or incomplete repository: - create or repair a buildable Lean project with matching `lean-toolchain` and - Mathlib revisions; and + Mathlib revisions; +- declare `leanprover-community/repl` at an immutable revision tested against + that exact Lean toolchain, then verify `lake build @repl/repl`; never guess a + compatible revision; and - write the blueprint vault, site configuration, and CI with `autoform init`. `autoform init` is the whole vault: `blueprint/` with its landing page, @@ -91,6 +94,7 @@ then run the publication sequence: ```bash lake exe cache get # skip only when the project has no Mathlib dependency lake build +lake build @repl/repl ``` Then validate, visualize, render, and strict-build the site, keeping diff --git a/skills/setup/assets/cabannes-thesis-project/README.md b/skills/setup/assets/cabannes-thesis-project/README.md index 2d9dfed7b..d5c101c87 100644 --- a/skills/setup/assets/cabannes-thesis-project/README.md +++ b/skills/setup/assets/cabannes-thesis-project/README.md @@ -9,7 +9,7 @@ slice as a handoff example. Developed with [AutoformBot](https://github.com/facebookresearch/autoform-bot). - `lean-toolchain`, `lakefile.toml`, and `CabannesThesis/` pin matching stable - Lean and Mathlib `v4.32.2` releases. + Lean and Mathlib `v4.32.2` releases plus the compatible upstream REPL commit. - `blueprint/` is an Obsidian-compatible Markdown vault with roadmap, coverage, sources, and a seven-node theorem DAG spanning two formalization chapters. - `mkdocs.yml` builds the `autoform render` output as a leanblueprint-styled diff --git a/skills/setup/assets/cabannes-thesis-project/lake-manifest.json b/skills/setup/assets/cabannes-thesis-project/lake-manifest.json new file mode 100644 index 000000000..b4799cf98 --- /dev/null +++ b/skills/setup/assets/cabannes-thesis-project/lake-manifest.json @@ -0,0 +1,106 @@ +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": + [{"url": "https://github.com/leanprover-community/repl.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "68a3b3a059787a7db44fb1e6281e4a657efee470", + "name": "repl", + "manifestFile": "lake-manifest.json", + "inputRev": "68a3b3a059787a7db44fb1e6281e4a657efee470", + "inherited": false, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/mathlib4.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "905b95818eb32af7874a58b427f50c1711a5e96c", + "name": "mathlib", + "manifestFile": "lake-manifest.json", + "inputRev": "v4.32.2", + "inherited": false, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover-community/plausible", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "e12c1910fe855cbfc38803cd4e55543906d5fa62", + "name": "plausible", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/LeanSearchClient", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "c5d5b8fe6e5158def25cd28eb94e4141ad97c843", + "name": "LeanSearchClient", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/import-graph", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "7e9612bf0b9ee66db3cb5b9988a35afc706f5a12", + "name": "importGraph", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/ProofWidgets4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "6e311e2a844da9b2cc3971187df2fe0066947b93", + "name": "proofwidgets", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover-community/aesop", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "a7dbf0c63b694e47f425f3dcddbc0e178bb432d3", + "name": "aesop", + "manifestFile": "lake-manifest.json", + "inputRev": "master", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/quote4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "38d591e778f100aec9762bb582f9c7f55f50e9dc", + "name": "Qq", + "manifestFile": "lake-manifest.json", + "inputRev": "master", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/batteries", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "023ce7d62a0531e22a5331e20b587817a80d49ff", + "name": "batteries", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover/lean4-cli", + "type": "git", + "subDir": null, + "scope": "leanprover", + "rev": "88679d088c9720c27ebdf2ba4dafe17341747f94", + "name": "Cli", + "manifestFile": "lake-manifest.json", + "inputRev": "v4.32.0", + "inherited": true, + "configFile": "lakefile.toml"}], + "name": "CabannesThesis", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/skills/setup/assets/cabannes-thesis-project/lakefile.toml b/skills/setup/assets/cabannes-thesis-project/lakefile.toml index bc5fe5f24..1d0052972 100644 --- a/skills/setup/assets/cabannes-thesis-project/lakefile.toml +++ b/skills/setup/assets/cabannes-thesis-project/lakefile.toml @@ -7,6 +7,11 @@ name = "mathlib" git = "https://github.com/leanprover-community/mathlib4.git" rev = "v4.32.2" +[[require]] +name = "repl" +git = "https://github.com/leanprover-community/repl.git" +rev = "68a3b3a059787a7db44fb1e6281e4a657efee470" + [[lean_lib]] name = "CabannesThesis" srcDir = "src" diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 4c64923aa..272a641eb 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -107,12 +107,19 @@ def test_quick_start_keeps_the_cli_agent_facing(repo_root: Path) -> None: def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) -> None: servers = (repo_root / "servers/README.md").read_text(encoding="utf-8") + setup = (repo_root / "skills/setup/SKILL.md").read_text(encoding="utf-8") + lakefile = ( + repo_root / "skills/setup/assets/cabannes-thesis-project/lakefile.toml" + ).read_text(encoding="utf-8") assert "each public REPL call gets a fresh child" in servers assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain's `lean --deps-json`" in servers assert "unrecognized parser response fails closed" in servers assert "package-qualified `@repl/repl` target" in servers + assert "`leanprover-community/repl` at an immutable revision" in setup + assert "lake build @repl/repl" in setup + assert "68a3b3a059787a7db44fb1e6281e4a657efee470" in lakefile def test_setup_asset_is_a_repo_shaped_thesis_vault(repo_root: Path) -> None: From bcd8ebdca24899f5aa968ee90ccd75cc9970c015 Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:42:26 -0400 Subject: [PATCH 005/109] Add REPL fault injection and pinned integration CI --- .github/workflows/tests.yml | 22 ++++ tests/fixtures/repl-smoke/.gitignore | 1 + tests/fixtures/repl-smoke/Mathlib.lean | 1 + tests/fixtures/repl-smoke/lake-manifest.json | 16 +++ tests/fixtures/repl-smoke/lakefile.toml | 10 ++ tests/fixtures/repl-smoke/lean-toolchain | 1 + tests/test_real_repl.py | 100 +++++++++++++++++++ tests/test_repl_core_protocol.py | 80 ++++++++++++++- 8 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/repl-smoke/.gitignore create mode 100644 tests/fixtures/repl-smoke/Mathlib.lean create mode 100644 tests/fixtures/repl-smoke/lake-manifest.json create mode 100644 tests/fixtures/repl-smoke/lakefile.toml create mode 100644 tests/fixtures/repl-smoke/lean-toolchain create mode 100644 tests/test_real_repl.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ceff42dd1..6a6572ce8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,6 +64,28 @@ jobs: env: AUTOFORM_REQUIRE_REAL_LEAN_TESTS: "1" + real-repl: + name: real REPL (fixture toolchain) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.12.1" + python-version: "3.13" + enable-cache: true + - uses: leanprover/lean-action@50fcf42d2e460296f1a34b402e990d1b24f8b596 # v1 + with: + auto-config: false + build: true + build-args: "Mathlib @repl/repl" + lake-package-directory: tests/fixtures/repl-smoke + - run: uv sync --frozen --extra dev --extra repl + - run: uv run pytest -q tests/test_real_repl.py + env: + AUTOFORM_RUN_REAL_REPL_TESTS: "1" + windows-hardening: name: Windows inspection, subprocess, and publication hardening runs-on: windows-latest diff --git a/tests/fixtures/repl-smoke/.gitignore b/tests/fixtures/repl-smoke/.gitignore new file mode 100644 index 000000000..01f8cdb63 --- /dev/null +++ b/tests/fixtures/repl-smoke/.gitignore @@ -0,0 +1 @@ +.lake/ diff --git a/tests/fixtures/repl-smoke/Mathlib.lean b/tests/fixtures/repl-smoke/Mathlib.lean new file mode 100644 index 000000000..f335d1c64 --- /dev/null +++ b/tests/fixtures/repl-smoke/Mathlib.lean @@ -0,0 +1 @@ +/- A lightweight stand-in for the runtime's default warmup import. -/ diff --git a/tests/fixtures/repl-smoke/lake-manifest.json b/tests/fixtures/repl-smoke/lake-manifest.json new file mode 100644 index 000000000..59becf302 --- /dev/null +++ b/tests/fixtures/repl-smoke/lake-manifest.json @@ -0,0 +1,16 @@ +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": + [{"url": "https://github.com/leanprover-community/repl.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "68a3b3a059787a7db44fb1e6281e4a657efee470", + "name": "repl", + "manifestFile": "lake-manifest.json", + "inputRev": "68a3b3a059787a7db44fb1e6281e4a657efee470", + "inherited": false, + "configFile": "lakefile.toml"}], + "name": "AutoformReplSmoke", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/tests/fixtures/repl-smoke/lakefile.toml b/tests/fixtures/repl-smoke/lakefile.toml new file mode 100644 index 000000000..037657c99 --- /dev/null +++ b/tests/fixtures/repl-smoke/lakefile.toml @@ -0,0 +1,10 @@ +name = "AutoformReplSmoke" +version = "0.1.0" + +[[require]] +name = "repl" +git = "https://github.com/leanprover-community/repl.git" +rev = "68a3b3a059787a7db44fb1e6281e4a657efee470" + +[[lean_lib]] +name = "Mathlib" diff --git a/tests/fixtures/repl-smoke/lean-toolchain b/tests/fixtures/repl-smoke/lean-toolchain new file mode 100644 index 000000000..0ec5999cc --- /dev/null +++ b/tests/fixtures/repl-smoke/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.32.2 diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py new file mode 100644 index 000000000..5a2da274d --- /dev/null +++ b/tests/test_real_repl.py @@ -0,0 +1,100 @@ +"""Opt-in integration tests against the pinned upstream Lean REPL.""" + +from __future__ import annotations + +import os +from contextlib import suppress +from pathlib import Path + +import pytest + +from servers.lean_client import LeanRuntimeClient, LeanRuntimeUnavailable +from servers.repl.core import LeanRepl, LeanReplConfig + + +REPL_FIXTURE = Path(__file__).parent / "fixtures" / "repl-smoke" +pytestmark = pytest.mark.skipif( + os.environ.get("AUTOFORM_RUN_REAL_REPL_TESTS") != "1", + reason="set AUTOFORM_RUN_REAL_REPL_TESTS=1 to run pinned REPL integration", +) + + +def test_disposable_call_matches_the_pinned_repl_protocol(): + repl = LeanRepl( + LeanReplConfig( + cwd=str(REPL_FIXTURE), + warmup_imports=frozenset(), + validate_imports=False, + ) + ) + + response = repl.run_disposable( + "theorem autoform_repl_probe : True := by sorry", + timeout=180, + ) + + assert response.get("sorries") + assert "env" not in response + assert all("proofState" not in sorry for sorry in response["sorries"]) + assert repl.is_clean() + + +@pytest.mark.parametrize( + ("warmup", "code", "expected_error"), + [ + ((), "/- note -/\nimport Init.Data\n#check Nat", "Disallowed imports: Init"), + ((), "module\npublic import Init.Data\n", "Disallowed imports: Init"), + (("REPL",), "/- note -/ import Init.Data\n#check Nat", "Disallowed imports: Init"), + ((), "import NotAllowlisted.Mod\n", "Disallowed imports: NotAllowlisted"), + ((), "import «REPL\n", "Rejected Lean header"), + ((), "import REPL.Frontend\n#check Nat", None), + ], +) +def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): + repl = LeanRepl( + LeanReplConfig( + cwd=str(REPL_FIXTURE), + allowed_imports=frozenset({"REPL"}), + warmup_imports=frozenset(warmup), + ) + ) + + response = repl.run_disposable(code, timeout=180) + + if expected_error is None: + assert "repl_error" not in response + assert not any( + message["severity"] == "error" + for message in response.get("messages", []) + ) + else: + assert expected_error in response["repl_error"] + assert repl.is_clean() + + +def test_runtime_calls_do_not_share_lean_state(runtime_dir, monkeypatch): + monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "1") + monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "1") + client = LeanRuntimeClient( + socket_path=runtime_dir / "real-repl.sock", + response_timeout=300, + startup_timeout=30, + ) + declaration = "theorem autoform_isolation_probe : True := True.intro" + try: + responses = [ + client.request( + "repl.run", + { + "project_dir": str(REPL_FIXTURE), + "code": declaration, + "timeout": 180, + }, + ) + for _ in range(2) + ] + finally: + with suppress(LeanRuntimeUnavailable): + client.stop() + + assert responses == ["Compiles successfully", "Compiles successfully"] diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 0bc161413..11b2da7a9 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -851,14 +851,19 @@ def _deps_json(*modules: str, errors: tuple[str, ...] = ()) -> str: return json.dumps({"imports": [entry]}) -def _header_modules(command: list[str]) -> list[str]: +def _header_modules( + command: list[str], + *, + deadline: float | None = None, + max_output_bytes: int = 1024 * 1024, +) -> list[str]: return repl_core._lean_header_modules( command, "import Mathlib", cwd=None, env=dict(os.environ), - deadline=time.monotonic() + 10, - max_output_bytes=1024 * 1024, + deadline=time.monotonic() + 10 if deadline is None else deadline, + max_output_bytes=max_output_bytes, ) @@ -989,6 +994,75 @@ def header_modules(command, code, **kwargs): assert checked == ["/- note -/ import Unsafe\n#check Nat"] +def test_header_check_kills_a_command_that_outlives_the_deadline(): + started = time.monotonic() + + with pytest.raises(TimeoutError): + _header_modules( + [sys.executable, "-c", "import time; time.sleep(30)"], + deadline=started + 0.2, + ) + + assert time.monotonic() - started < 5 + + +def test_header_check_rejects_output_over_the_combined_limit(): + with pytest.raises(ValueError, match="output exceeded 1024 bytes"): + _header_modules( + _fake_header_deps("x" * 1025), + max_output_bytes=1024, + ) + + +def test_header_check_reaps_descendants_after_success(tmp_path): + process_record = tmp_path / "processes.json" + script = ( + "import json, os, subprocess, sys; " + "child = subprocess.Popen([sys.executable, '-c', " + "'import time; time.sleep(30)'], stdout=subprocess.DEVNULL, " + "stderr=subprocess.DEVNULL); " + f"open({str(process_record)!r}, 'w').write(json.dumps(" + "{'child': child.pid, 'group': os.getpgrp()})); " + "sys.stdin.read(); " + f"sys.stdout.write({_deps_json('Mathlib')!r})" + ) + + assert _header_modules([sys.executable, "-c", script]) == ["Mathlib"] + process_ids = json.loads(process_record.read_text()) + assert not repl_core._process_group_has_live_members(process_ids["group"]) + + +def test_disposable_call_retries_header_cleanup_before_reuse(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + warmup_imports=frozenset(), + header_deps_command=_fake_header_deps(_deps_json("Mathlib")), + ) + ) + real_kill = repl_core._kill_subprocesses + cleanup_calls = 0 + + def fail_once(process, process_group_id, deadline=None): + nonlocal cleanup_calls + cleanup_calls += 1 + if cleanup_calls == 1: + raise RuntimeError("injected cleanup failure") + return real_kill(process, process_group_id, deadline) + + monkeypatch.setattr(repl_core, "_kill_subprocesses", fail_once) + monkeypatch.setattr( + repl, + "start", + lambda *args, **kwargs: pytest.fail("unclean header must not start Lean"), + ) + + response = repl.run_disposable("import Mathlib\n#check Nat") + + assert "header parser cleanup failed" in response["repl_error"] + assert cleanup_calls == 2 + assert repl.is_clean() + + def test_response_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( From 132d636d135e99045dd14017cc930b80f906528f Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:46:28 -0400 Subject: [PATCH 006/109] Preserve cancellation and validate warmup imports --- servers/repl/core.py | 23 ++++++++++++++++++++++- tests/test_repl_core_protocol.py | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index a4df78739..9d8517bc2 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -1049,6 +1049,21 @@ def remaining() -> float: self.process = error.process self._process_group_id = error.process.pid self._retire_pending = True + original_error = error.original_error + cleanup_error = error.cleanup_error + if original_error is not None and not isinstance( + original_error, Exception + ): + add_note = getattr(original_error, "add_note", None) + if add_note is not None: + add_note(str(error)) + raise original_error.with_traceback( + original_error.__traceback__ + ) + if not isinstance(cleanup_error, Exception): + raise cleanup_error.with_traceback( + cleanup_error.__traceback__ + ) raise except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} @@ -1061,7 +1076,13 @@ def remaining() -> float: and self.config.validate_imports and self._allowed_import_roots is not None ): - disallowed = submitted_roots - self._allowed_import_roots + warmup_roots = { + module.split(".")[0] + for module in self.config.warmup_imports + } + disallowed = ( + submitted_roots | warmup_roots + ) - self._allowed_import_roots if disallowed: result = { "repl_error": ( diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 0bc161413..5abf7bd82 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -989,6 +989,25 @@ def header_modules(command, code, **kwargs): assert checked == ["/- note -/ import Unsafe\n#check Nat"] +def test_disposable_call_rejects_disallowed_warmup_import(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset({"Unsafe.Prelude"}), + header_deps_command=_fake_header_deps(_deps_json()), + ) + ) + monkeypatch.setattr( + repl, + "start", + lambda *args, **kwargs: pytest.fail("disallowed warmup must not start Lean"), + ) + + response = repl.run_disposable("#check Nat") + + assert "Disallowed imports: Unsafe" in response["repl_error"] + + def test_response_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( From f90e053f796432a361c77e898b2e340206d7bcd5 Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:46:53 -0400 Subject: [PATCH 007/109] Inject header cancellation and cleanup failures --- tests/test_repl_core_protocol.py | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 11b2da7a9..8521c25e1 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1063,6 +1063,70 @@ def fail_once(process, process_group_id, deadline=None): assert repl.is_clean() +def test_disposable_call_preserves_header_cleanup_cancellation(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + warmup_imports=frozenset(), + header_deps_command=_fake_header_deps(_deps_json("Mathlib")), + ) + ) + real_kill = repl_core._kill_subprocesses + cleanup_calls = 0 + + def cancel_once(process, process_group_id, deadline=None): + nonlocal cleanup_calls + cleanup_calls += 1 + if cleanup_calls == 1: + raise asyncio.CancelledError("cancel header cleanup") + return real_kill(process, process_group_id, deadline) + + monkeypatch.setattr(repl_core, "_kill_subprocesses", cancel_once) + + with pytest.raises(asyncio.CancelledError, match="cancel header cleanup"): + repl.run_disposable("import Mathlib\n#check Nat") + + assert cleanup_calls == 2 + assert repl.is_clean() + + +def test_disposable_call_preserves_header_request_cancellation(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + warmup_imports=frozenset(), + header_deps_command=[sys.executable, "-c", "import time; time.sleep(30)"], + ) + ) + real_kill = repl_core._kill_subprocesses + cleanup_calls = 0 + + monkeypatch.setattr( + repl_core, + "_communicate_bounded", + lambda *args, **kwargs: (_ for _ in ()).throw( + KeyboardInterrupt("cancel header request") + ), + ) + + def fail_once(process, process_group_id, deadline=None): + nonlocal cleanup_calls + cleanup_calls += 1 + if cleanup_calls == 1: + raise RuntimeError("header cleanup failed") + return real_kill(process, process_group_id, deadline) + + monkeypatch.setattr(repl_core, "_kill_subprocesses", fail_once) + + with pytest.raises(KeyboardInterrupt, match="cancel header request") as raised: + repl.run_disposable("import Mathlib\n#check Nat") + + if hasattr(raised.value, "add_note"): + assert raised.value.__notes__ == [ + "Lean header parser cleanup failed: header cleanup failed" + ] + assert cleanup_calls == 2 + assert repl.is_clean() + + def test_response_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( From e50637e513a8c32cfad4a5ef8cfa8378dd7b3e9d Mon Sep 17 00:00:00 2001 From: Jack McCarthy <37917934+Deicyde@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:52:13 -0400 Subject: [PATCH 008/109] Unblock waiting REPL calls during shutdown --- servers/repl/pool.py | 76 +++++++++++++++++++++++-------- tests/test_repl_pool_lifecycle.py | 41 +++++++++++++++++ 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 736876e18..6996d4bcb 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -48,6 +48,10 @@ def __init__(self, config: LeanReplPoolConfig) -> None: self._workers: list[LeanRepl] = [] self._idle: queue.Queue[LeanRepl] = queue.Queue() self._lock = threading.Lock() + self._condition = threading.Condition(self._lock) + self._active_calls = 0 + self._closing = False + self._closed = False try: for _ in range(self.capacity): @@ -81,16 +85,13 @@ def _close_workers(self) -> None: def run(self, code: str, **kwargs: Any) -> dict[str, Any]: """Run code on an idle REPL within one queue-and-execution timeout.""" - if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") timeout = kwargs.pop("timeout", None) deadline = time.monotonic() + timeout if timeout is not None else None - try: - repl = self._idle.get(timeout=timeout) - except queue.Empty as error: - raise TimeoutError( - f"timed out after {timeout:g}s waiting for an idle Lean REPL" - ) from error + with self._condition: + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") + self._active_calls += 1 + repl: LeanRepl | None = None def run_once() -> dict[str, Any]: if kwargs: @@ -102,21 +103,43 @@ def run_once() -> dict[str, Any]: raise TimeoutError( f"timed out after {timeout:g}s waiting for an idle Lean REPL" ) + assert repl is not None return repl.run_disposable(code, timeout=remaining) + assert repl is not None return repl.run_disposable(code) try: - if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") + while repl is None: + with self._condition: + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") + wait = 0.1 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"timed out after {timeout:g}s waiting for an idle Lean REPL" + ) + wait = min(wait, remaining) + try: + repl = self._idle.get(timeout=wait) + except queue.Empty: + continue + with self._condition: + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") return run_once() finally: - if repl.is_clean() and not self._shutdown: - self._idle.put(repl) - else: - # A cleanup failure keeps its wrapper (and process handle) in - # this pool but permanently stops new admission. Runtime - # shutdown will retry cleanup; no replacement generation starts. - self._shutdown = True + with self._condition: + if repl is not None: + if repl.is_clean() and not self._shutdown: + self._idle.put(repl) + elif not repl.is_clean(): + # Preserve the dirty wrapper for shutdown cleanup, but + # stop every subsequent admission and replacement. + self._shutdown = True + self._active_calls -= 1 + self._condition.notify_all() def get_memory_usage(self) -> float: """Total memory usage across all REPL instances in GB.""" @@ -124,5 +147,20 @@ def get_memory_usage(self) -> float: def shutdown(self) -> None: """Shut down all REPL instances.""" - self._shutdown = True - self._close_workers() + with self._condition: + self._shutdown = True + self._condition.notify_all() + while self._active_calls: + self._condition.wait() + while self._closing: + self._condition.wait() + if self._closed: + return + self._closing = True + try: + self._close_workers() + finally: + with self._condition: + self._closing = False + self._closed = not self._workers + self._condition.notify_all() diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 6a39b0910..9c9cb51e2 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -3,6 +3,8 @@ from __future__ import annotations import os +import threading +import time import pytest @@ -137,6 +139,45 @@ def get_memory_usage(self): pool.shutdown() +def test_shutdown_unblocks_a_call_waiting_for_a_cold_slot(monkeypatch): + class FakeRepl: + def __init__(self, config): + pass + + def is_clean(self): + return True + + def close(self): + pass + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + pool._idle.get_nowait() + errors = [] + waiter = threading.Thread( + target=lambda: errors.append( + pytest.raises(RuntimeError, pool.run, "#check Nat").value + ) + ) + waiter.start() + deadline = time.monotonic() + 2 + while pool._active_calls == 0 and time.monotonic() < deadline: + time.sleep(0.01) + + stopper = threading.Thread(target=pool.shutdown) + stopper.start() + waiter.join(timeout=2) + stopper.join(timeout=2) + + assert not waiter.is_alive() + assert not stopper.is_alive() + assert len(errors) == 1 + assert "pool is shut down" in str(errors[0]) + + def test_repl_retry_recovery_uses_the_original_deadline(monkeypatch): clock = {"now": 0.0} repl = repl_core.LeanRepl( From 4e1f9ec632c63b6f135a8235813caae2b35aad53 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 04:09:43 -0400 Subject: [PATCH 009/109] Preserve Lean module and prelude headers --- servers/repl/core.py | 83 ++++++++++++++++-- tests/test_repl_core_protocol.py | 146 +++++++++++++++++++++++++++++-- 2 files changed, 214 insertions(+), 15 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 9d8517bc2..8aa9b94d9 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -417,7 +417,15 @@ def close_stdin() -> None: return bytes(stdout), bytes(stderr) -def _decode_header_modules(stdout: bytes) -> list[str]: +@dataclass(frozen=True) +class _LeanHeaderAnalysis: + """Lean-owned facts needed to validate and compose one submitted header.""" + + modules: tuple[str, ...] + accepts_leading_imports: bool + + +def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: """Decode the strict schemas emitted by Lean's fast import parser.""" def reject_constant(value: str) -> None: @@ -457,26 +465,55 @@ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: has_imports = "imports" in entry if has_result == has_imports: raise ValueError + is_module: bool | None = None if has_result: result = entry["result"] if not isinstance(result, dict): raise ValueError imports = result.get("imports") + candidate_is_module = result.get("isModule") + if ( + candidate_is_module is not None + and type(candidate_is_module) is not bool + ): + raise ValueError + is_module = candidate_is_module else: imports = entry["imports"] if not isinstance(imports, list): raise ValueError modules: list[str] = [] + has_implicit_init = False + has_composition_metadata = has_result and is_module is not None for item in imports: if not isinstance(item, dict): raise ValueError module = item.get("module") if not isinstance(module, str) or not module: raise ValueError + if has_result: + is_meta = item.get("isMeta") + if is_meta is not None and type(is_meta) is not bool: + raise ValueError + if is_meta is None: + has_composition_metadata = False + if module == "Init" and is_meta: + has_implicit_init = True if module != "Init": modules.append(module) - return modules + # Only the current schema exposes enough parser state to distinguish + # Lean's implicit Init imports from an explicit ``prelude``/``module`` + # header. The legacy schema remains valid for allowlist checks, but + # fails closed on source rewriting and sends the validated frame exact. + return _LeanHeaderAnalysis( + modules=tuple(modules), + accepts_leading_imports=( + has_composition_metadata + and has_implicit_init + and is_module is False + ), + ) except UnicodeDecodeError: raise ValueError("unrecognized output from lean --deps-json") from None except (TypeError, KeyError, json.JSONDecodeError): @@ -487,7 +524,7 @@ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: raise ValueError("unrecognized output from lean --deps-json") from None -def _lean_header_modules( +def _lean_header_analysis( command: list[str], code: str, *, @@ -495,8 +532,8 @@ def _lean_header_modules( env: dict[str, str], deadline: float, max_output_bytes: int, -) -> list[str]: - """Return imports parsed from ``code`` by the selected Lean toolchain.""" +) -> _LeanHeaderAnalysis: + """Return Lean-owned composition facts for one submitted header.""" if deadline - time.monotonic() <= 0: raise TimeoutError("timed out before checking the Lean header") process = subprocess.Popen( @@ -545,7 +582,28 @@ def _lean_header_modules( if process.returncode != 0: detail = stderr.decode(errors="replace").strip().splitlines() raise ValueError(detail[0] if detail else f"exit status {process.returncode}") - return _decode_header_modules(stdout) + return _decode_header_analysis(stdout) + + +def _lean_header_modules( + command: list[str], + code: str, + *, + cwd: str | None, + env: dict[str, str], + deadline: float, + max_output_bytes: int, +) -> list[str]: + """Compatibility wrapper returning only Lean's direct imported modules.""" + analysis = _lean_header_analysis( + command, + code, + cwd=cwd, + env=env, + deadline=deadline, + max_output_bytes=max_output_bytes, + ) + return list(analysis.modules) def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: @@ -1028,6 +1086,7 @@ def remaining() -> float: self.close(deadline=deadline) lexical_imports, _, _ = _split_imports_and_body(code) header_modules = lexical_imports + accepts_leading_imports = True if ( self.config.validate_imports and self._allowed_import_roots is not None @@ -1035,7 +1094,7 @@ def remaining() -> float: env = _inherit_clean_env() env.update(self.config.env) try: - header_modules = _lean_header_modules( + header = _lean_header_analysis( self.config.header_deps_command, code, cwd=self.cwd, @@ -1043,6 +1102,8 @@ def remaining() -> float: deadline=deadline, max_output_bytes=self.config.max_buffer_bytes, ) + header_modules = list(header.modules) + accepts_leading_imports = header.accepts_leading_imports except _HeaderProcessCleanupError as error: # Preserve ownership so the normal disposable cleanup # retries before this slot can ever be reused. @@ -1068,6 +1129,7 @@ def remaining() -> float: except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} header_modules = [] + accepts_leading_imports = False submitted_roots = { module.split(".")[0] for module in header_modules } @@ -1092,11 +1154,16 @@ def remaining() -> float: ) } if result is None: - added_imports = tuple( + missing_warmup_imports = tuple( root for root in sorted(self.config.warmup_imports) if root not in submitted_roots ) + added_imports = ( + missing_warmup_imports + if accepts_leading_imports + else () + ) prefix = "\n".join( f"import {root}" for root in added_imports ) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 5abf7bd82..189e414a1 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -839,14 +839,44 @@ def _fake_header_deps(stdout: str, returncode: int = 0, stderr: str = "") -> lis return [sys.executable, "-c", script] -def _deps_json(*modules: str, errors: tuple[str, ...] = ()) -> str: +def _deps_json( + *modules: str, + errors: tuple[str, ...] = (), + is_module: bool = False, + prelude: bool = False, +) -> str: entry: dict = {"errors": list(errors)} if not errors: + imports = [] + if not prelude: + imports.extend( + [ + { + "module": "Init", + "importAll": False, + "isExported": True, + "isMeta": False, + }, + { + "module": "Init", + "importAll": False, + "isExported": True, + "isMeta": True, + }, + ] + ) + imports.extend( + { + "module": module, + "importAll": False, + "isExported": True, + "isMeta": False, + } + for module in modules + ) entry["result"] = { - "imports": [ - {"module": module, "importAll": False} for module in modules - ], - "isModule": False, + "imports": imports, + "isModule": is_module, } return json.dumps({"imports": [entry]}) @@ -978,17 +1008,119 @@ def test_disposable_call_checks_submitted_header_before_warmup_prefix(monkeypatc ) checked = [] - def header_modules(command, code, **kwargs): + def header_analysis(command, code, **kwargs): checked.append(code) raise ValueError("stop") - monkeypatch.setattr(repl_core, "_lean_header_modules", header_modules) + monkeypatch.setattr(repl_core, "_lean_header_analysis", header_analysis) repl.run_disposable("/- note -/ import Unsafe\n#check Nat") assert checked == ["/- note -/ import Unsafe\n#check Nat"] +@pytest.mark.parametrize( + ("deps_output", "code"), + [ + ( + _deps_json("REPL.Frontend", is_module=True), + "module\npublic import REPL.Frontend\n", + ), + ( + _deps_json("REPL.Frontend", prelude=True), + "prelude\nimport REPL.Frontend\n#check Nat", + ), + ( + _deps_json("Init", prelude=True), + "prelude\nimport Init\n#check Nat", + ), + ], +) +def test_disposable_call_does_not_displace_lean_header_directives( + monkeypatch, + deps_output, + code, +): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib", "REPL", "Init"}), + warmup_imports=frozenset({"Mathlib"}), + header_deps_command=_fake_header_deps(deps_output), + ) + ) + frames = [] + monkeypatch.setattr(repl, "start", lambda *args, **kwargs: None) + + def run_frame(code, env_id, timeout): + frames.append(code) + return {"env": 1, "messages": [], "sorries": []} + + monkeypatch.setattr(repl, "_run", run_frame) + + assert repl.run_disposable(code) == {"messages": [], "sorries": []} + assert frames == [code] + + +def test_disposable_call_preserves_warmup_for_ordinary_headers(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset({"Mathlib"}), + header_deps_command=_fake_header_deps(_deps_json()), + ) + ) + frames = [] + monkeypatch.setattr(repl, "start", lambda *args, **kwargs: None) + + def run_frame(code, env_id, timeout): + frames.append(code) + return {"env": 1, "messages": [], "sorries": []} + + monkeypatch.setattr(repl, "_run", run_frame) + + assert repl.run_disposable("#check Nat") == { + "messages": [], + "sorries": [], + } + assert frames == ["import Mathlib\n#check Nat"] + + +def test_legacy_header_schema_fails_closed_on_warmup_composition(monkeypatch): + legacy_output = json.dumps( + { + "imports": [ + { + "errors": [], + "imports": [ + {"module": "Init", "importAll": False}, + ], + } + ] + } + ) + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset({"Mathlib"}), + header_deps_command=_fake_header_deps(legacy_output), + ) + ) + frames = [] + monkeypatch.setattr(repl, "start", lambda *args, **kwargs: None) + + def run_frame(code, env_id, timeout): + frames.append(code) + return {"env": 1, "messages": [], "sorries": []} + + monkeypatch.setattr(repl, "_run", run_frame) + + assert repl.run_disposable("#check Nat") == { + "messages": [], + "sorries": [], + } + assert frames == ["#check Nat"] + + def test_disposable_call_rejects_disallowed_warmup_import(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( From 048e88e1456025eba31d657dfb0dcd3ad18d6b4b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 04:12:39 -0400 Subject: [PATCH 010/109] Exercise directive-safe REPL composition --- tests/test_real_repl.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 5a2da274d..8fb2a38f5 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -48,13 +48,19 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): ((), "import NotAllowlisted.Mod\n", "Disallowed imports: NotAllowlisted"), ((), "import «REPL\n", "Rejected Lean header"), ((), "import REPL.Frontend\n#check Nat", None), + (("Mathlib",), "module\npublic import REPL.Frontend\n", None), + ( + ("Mathlib",), + "prelude\nimport REPL.Frontend\n#check Nat", + None, + ), ], ) def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): repl = LeanRepl( LeanReplConfig( cwd=str(REPL_FIXTURE), - allowed_imports=frozenset({"REPL"}), + allowed_imports=frozenset({"Mathlib", "REPL"}), warmup_imports=frozenset(warmup), ) ) @@ -80,7 +86,9 @@ def test_runtime_calls_do_not_share_lean_state(runtime_dir, monkeypatch): response_timeout=300, startup_timeout=30, ) - declaration = "theorem autoform_isolation_probe : True := True.intro" + declaration = ( + "theorem autoform_isolation_probe (P : Prop) (h : P) : P := h" + ) try: responses = [ client.request( From 8c9b70295ccab230c07e769def009f252040da91 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 04:48:25 -0400 Subject: [PATCH 011/109] Retry quarantined REPL cleanup before replacement --- servers/lean_runtime.py | 1 + servers/repl/pool.py | 160 +++++++++++++++++++++++-- tests/test_repl_pool_lifecycle.py | 190 +++++++++++++++++++++++++++++- tests/test_shared_lean_runtime.py | 91 ++++++++++++++ 4 files changed, 429 insertions(+), 13 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 5d6c359ee..e06652f2e 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -666,6 +666,7 @@ def default_lsp_factory(project_dir: Path) -> LeanLspSession: lambda pool: pool.shutdown(), max_entries=self.config.repl_project_limit, idle_seconds=self.config.idle_seconds, + is_valid=lambda pool: pool.is_usable(), start_sweeper=start_sweepers, ) self.lsp_projects = ProjectResourceCache( diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 6996d4bcb..0cf52eb84 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -9,12 +9,14 @@ from logging import getLogger from typing import Any -from .core import LeanRepl, LeanReplConfig +from .core import ReplCleanupError, LeanRepl, LeanReplConfig logger = getLogger(__name__) DEFAULT_PORT = 8990 DEFAULT_RAM_FRACTION = 0.5 +_CLEANUP_RETRY_INITIAL_SECONDS = 0.01 +_CLEANUP_RETRY_MAX_SECONDS = 1.0 @dataclass @@ -83,6 +85,100 @@ def _close_workers(self) -> None: if first_error is not None: raise first_error + def is_usable(self) -> bool: + """Return whether this pool may admit another public call.""" + with self._condition: + return not self._shutdown and not self._closed + + def _stop_admission(self) -> None: + with self._condition: + self._shutdown = True + self._condition.notify_all() + + @staticmethod + def _remember_cleanup_base_error( + remembered: BaseException | None, + error: BaseException, + ) -> BaseException | None: + """Retain cancellation-like cleanup failures until ownership is settled.""" + if isinstance(error, Exception): + return remembered + if remembered is None: + return error + add_note = getattr(remembered, "add_note", None) + if add_note is not None: + add_note(f"Lean REPL cleanup also raised: {error}") + return remembered + + def _settle_worker( + self, + worker: LeanRepl, + *, + quarantine: bool, + ) -> BaseException | None: + """Retain and retry a worker until it verifiably owns no process.""" + if quarantine: + self._stop_admission() + + cleanup_base_error: BaseException | None = None + delay = _CLEANUP_RETRY_INITIAL_SECONDS + while True: + try: + clean = worker.is_clean() + except BaseException as error: + logger.error( + "failed to verify Lean REPL worker cleanup", + exc_info=(type(error), error, error.__traceback__), + ) + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + clean = False + + if clean: + return cleanup_base_error + + if not quarantine: + self._stop_admission() + quarantine = True + + try: + worker.close() + except BaseException as error: + logger.error( + "failed to retire quarantined Lean REPL worker; retrying", + exc_info=(type(error), error, error.__traceback__), + ) + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + + try: + clean = worker.is_clean() + except BaseException as error: + logger.error( + "failed to verify quarantined Lean REPL worker cleanup", + exc_info=(type(error), error, error.__traceback__), + ) + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + clean = False + if clean: + return cleanup_base_error + + try: + time.sleep(delay) + except BaseException as error: + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) + def run(self, code: str, **kwargs: Any) -> dict[str, Any]: """Run code on an idle REPL within one queue-and-execution timeout.""" timeout = kwargs.pop("timeout", None) @@ -108,6 +204,7 @@ def run_once() -> dict[str, Any]: assert repl is not None return repl.run_disposable(code) + worker_settled = False try: while repl is None: with self._condition: @@ -125,21 +222,64 @@ def run_once() -> dict[str, Any]: repl = self._idle.get(timeout=wait) except queue.Empty: continue - with self._condition: - if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") - return run_once() + call_result: dict[str, Any] | None = None + call_error: BaseException | None = None + try: + with self._condition: + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") + call_result = run_once() + except BaseException as error: + call_error = error + + cleanup_base_error = self._settle_worker( + repl, + quarantine=isinstance(call_error, ReplCleanupError), + ) + worker_settled = True + + if call_error is not None and not isinstance(call_error, Exception): + if cleanup_base_error is not None and cleanup_base_error is not call_error: + add_note = getattr(call_error, "add_note", None) + if add_note is not None: + add_note( + "Lean REPL cleanup also raised: " + f"{cleanup_base_error}" + ) + raise call_error.with_traceback(call_error.__traceback__) + if cleanup_base_error is not None: + if call_error is not None: + add_note = getattr(cleanup_base_error, "add_note", None) + if add_note is not None: + add_note(f"Lean REPL call also failed: {call_error}") + raise cleanup_base_error.with_traceback( + cleanup_base_error.__traceback__ + ) + if isinstance(call_error, ReplCleanupError): + return call_error.result + if call_error is not None: + raise call_error.with_traceback(call_error.__traceback__) + if call_result is None: + raise RuntimeError("Lean REPL worker returned no result") + return call_result finally: + emergency_cleanup_error: BaseException | None = None + if repl is not None and not worker_settled: + emergency_cleanup_error = self._settle_worker( + repl, + quarantine=True, + ) + worker_settled = True with self._condition: if repl is not None: - if repl.is_clean() and not self._shutdown: + if worker_settled and not self._shutdown: self._idle.put(repl) - elif not repl.is_clean(): - # Preserve the dirty wrapper for shutdown cleanup, but - # stop every subsequent admission and replacement. - self._shutdown = True self._active_calls -= 1 self._condition.notify_all() + if emergency_cleanup_error is not None: + raise emergency_cleanup_error.with_traceback( + emergency_cleanup_error.__traceback__ + ) def get_memory_usage(self) -> float: """Total memory usage across all REPL instances in GB.""" diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 9c9cb51e2..06b701677 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -109,10 +109,14 @@ def get_memory_usage(self): assert [call[0] for call in workers[0].calls] == ["#check Nat", "#check Int"] -def test_pool_quarantines_every_slot_after_unverified_cleanup(monkeypatch): +def test_pool_retries_cleanup_and_returns_the_already_produced_result(monkeypatch): + workers = [] + class FakeRepl: def __init__(self, config): self.dirty = False + self.close_calls = 0 + workers.append(self) def run_disposable(self, code, **kwargs): self.dirty = True @@ -122,16 +126,22 @@ def is_clean(self): return not self.dirty def close(self): + self.close_calls += 1 + if self.dirty and self.close_calls == 1: + raise RuntimeError("transient cleanup failure") self.dirty = False def get_memory_usage(self): return 0.0 monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + monkeypatch.setattr(repl_pool.time, "sleep", lambda delay: None) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=2)) - with pytest.raises(repl_core.ReplCleanupError, match="cleanup failed"): - pool.run("#check Nat", timeout=1) + assert pool.run("#check Nat", timeout=1) == {"messages": []} + assert workers[0].close_calls == 2 + assert workers[0].is_clean() + assert not pool.is_usable() with pytest.raises(RuntimeError, match="pool is shut down"): pool.run("#check Int", timeout=1) @@ -139,6 +149,180 @@ def get_memory_usage(self): pool.shutdown() +def test_pool_stops_new_admission_while_cleanup_retry_owns_the_slot(monkeypatch): + workers = [] + cleanup_started = threading.Event() + allow_cleanup = threading.Event() + + class FakeRepl: + def __init__(self, config): + self.number = len(workers) + self.dirty = False + self.calls = 0 + workers.append(self) + + def run_disposable(self, code, **kwargs): + self.calls += 1 + self.dirty = True + raise repl_core.ReplCleanupError("cleanup failed", {"messages": []}) + + def is_clean(self): + return not self.dirty + + def close(self): + if self.dirty: + cleanup_started.set() + assert allow_cleanup.wait(timeout=2) + self.dirty = False + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=2)) + results = [] + errors = [] + + def run_first(): + try: + results.append(pool.run("#check Nat", timeout=1)) + except BaseException as error: + errors.append(error) + + first = threading.Thread(target=run_first) + first.start() + assert cleanup_started.wait(timeout=1) + assert pool._active_calls == 1 + assert not pool.is_usable() + + second_errors = [] + + def run_second(): + try: + pool.run("#check Int", timeout=1) + except BaseException as error: + second_errors.append(error) + + second = threading.Thread(target=run_second) + second.start() + second.join(timeout=1) + second_blocked = second.is_alive() + + shutdown_errors = [] + + def shut_down(): + try: + pool.shutdown() + except BaseException as error: + shutdown_errors.append(error) + + stopper = threading.Thread(target=shut_down) + stopper.start() + stopper.join(timeout=0.1) + shutdown_returned_early = not stopper.is_alive() + + allow_cleanup.set() + first.join(timeout=2) + second.join(timeout=2) + stopper.join(timeout=2) + try: + assert not second_blocked, "cleanup retry held the pool condition" + assert not shutdown_returned_early, "shutdown ignored the active cleanup" + assert not first.is_alive() + assert not second.is_alive() + assert not stopper.is_alive() + assert errors == [] + assert shutdown_errors == [] + assert results == [{"messages": []}] + assert len(second_errors) == 1 + assert "pool is shut down" in str(second_errors[0]) + assert workers[1].calls == 0 + assert pool._active_calls == 0 + finally: + allow_cleanup.set() + if stopper.is_alive(): + stopper.join(timeout=2) + + +def test_pool_preserves_cleanup_cancellation_after_verified_retry(monkeypatch): + workers = [] + + class FakeRepl: + def __init__(self, config): + self.dirty = False + self.close_calls = 0 + workers.append(self) + + def run_disposable(self, code, **kwargs): + self.dirty = True + raise repl_core.ReplCleanupError("cleanup failed", {"messages": []}) + + def is_clean(self): + return not self.dirty + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + raise KeyboardInterrupt("cleanup cancelled") + self.dirty = False + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + monkeypatch.setattr(repl_pool.time, "sleep", lambda delay: None) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + + with pytest.raises(KeyboardInterrupt, match="cleanup cancelled"): + pool.run("#check Nat", timeout=1) + + assert workers[0].close_calls == 2 + assert workers[0].is_clean() + assert pool._active_calls == 0 + assert not pool.is_usable() + pool.shutdown() + + +def test_pool_preserves_original_cancellation_until_cleanup_is_verified(monkeypatch): + workers = [] + + class FakeRepl: + def __init__(self, config): + self.dirty = False + self.close_calls = 0 + workers.append(self) + + def run_disposable(self, code, **kwargs): + self.dirty = True + raise KeyboardInterrupt("request cancelled") + + def is_clean(self): + return not self.dirty + + def close(self): + self.close_calls += 1 + if self.close_calls == 1: + raise SystemExit("cleanup cancelled") + self.dirty = False + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + monkeypatch.setattr(repl_pool.time, "sleep", lambda delay: None) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + + with pytest.raises(KeyboardInterrupt, match="request cancelled") as error: + pool.run("#check Nat", timeout=1) + + assert str(error.value) == "request cancelled" + assert workers[0].close_calls == 2 + assert workers[0].is_clean() + assert pool._active_calls == 0 + assert not pool.is_usable() + pool.shutdown() + + def test_shutdown_unblocks_a_call_waiting_for_a_cold_slot(monkeypatch): class FakeRepl: def __init__(self, config): diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 38ad8fccc..49a3339fe 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -68,6 +68,9 @@ def run(self, code, **kwargs): def get_memory_usage(self): return 0.25 + def is_usable(self): + return not self._shutdown + def shutdown(self): self._shutdown = True @@ -158,6 +161,94 @@ def __init__(self, config): services.close() +def test_quarantined_repl_pool_is_replaced_only_after_active_cleanup(tmp_path): + project = make_lake_project(tmp_path, "cleanup-before-replacement") + cleanup_started = threading.Event() + allow_cleanup = threading.Event() + second_started = threading.Event() + second_finished = threading.Event() + pools = [] + + class RetiringPool(FakePool): + def __init__(self, root): + super().__init__(root) + self.number = len(pools) + 1 + self.cleanup_complete = False + self.shutdown_calls = 0 + pools.append(self) + + def run(self, code, **kwargs): + self.calls.append((code, kwargs)) + if self.number == 1: + self._shutdown = True + cleanup_started.set() + assert allow_cleanup.wait(timeout=2) + self.cleanup_complete = True + return {"messages": []} + + def shutdown(self): + if self.number == 1: + assert self.cleanup_complete + self.shutdown_calls += 1 + self._shutdown = True + + services = LeanRuntimeServices( + runtime_config(), + repl_factory=RetiringPool, + lsp_factory=FakeLsp, + start_sweepers=False, + ) + results = [] + errors = [] + + def dispatch(code, *, started=None, finished=None): + if started is not None: + started.set() + try: + results.append( + services.dispatch( + "repl.run", + {"project_dir": str(project), "code": code, "timeout": 1}, + ) + ) + except BaseException as error: + errors.append(error) + finally: + if finished is not None: + finished.set() + + first = threading.Thread(target=dispatch, args=("#check Nat",)) + first.start() + assert cleanup_started.wait(timeout=1) + + second = threading.Thread( + target=dispatch, + args=("#check Int",), + kwargs={"started": second_started, "finished": second_finished}, + ) + second.start() + assert second_started.wait(timeout=1) + assert not second_finished.wait(timeout=0.1) + assert len(pools) == 1 + assert pools[0].shutdown_calls == 0 + + allow_cleanup.set() + first.join(timeout=2) + second.join(timeout=2) + try: + assert not first.is_alive() + assert not second.is_alive() + assert errors == [] + assert results == ["Compiles successfully", "Compiles successfully"] + assert len(pools) == 2 + assert pools[0].cleanup_complete + assert pools[0].shutdown_calls == 1 + assert pools[1].calls == [("#check Int", {"timeout": 1.0})] + finally: + allow_cleanup.set() + services.close() + + def test_lru_limit_never_evicts_an_active_project(tmp_path): first = make_lake_project(tmp_path, "first") second = make_lake_project(tmp_path, "second") From d16caf59872f65c0c9b12e1fec3db13a1a04bd67 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 05:13:26 -0400 Subject: [PATCH 012/109] Preserve cancellation through REPL quarantine --- servers/repl/pool.py | 93 +++++++++++++++++-------- tests/test_repl_pool_lifecycle.py | 110 ++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 28 deletions(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 0cf52eb84..e45b484a4 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -110,6 +110,36 @@ def _remember_cleanup_base_error( add_note(f"Lean REPL cleanup also raised: {error}") return remembered + def _stop_admission_with_retry( + self, + cleanup_base_error: BaseException | None, + delay: float, + ) -> tuple[BaseException | None, float]: + """Stop admission before cleanup despite cancellation-like interruptions.""" + while True: + try: + self._stop_admission() + except BaseException as error: + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + logger.error( + "failed to stop Lean REPL pool admission; retrying", + exc_info=(type(error), error, error.__traceback__), + ) + else: + return cleanup_base_error, delay + + try: + time.sleep(delay) + except BaseException as error: + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) + def _settle_worker( self, worker: LeanRepl, @@ -117,55 +147,60 @@ def _settle_worker( quarantine: bool, ) -> BaseException | None: """Retain and retry a worker until it verifiably owns no process.""" - if quarantine: - self._stop_admission() - cleanup_base_error: BaseException | None = None delay = _CLEANUP_RETRY_INITIAL_SECONDS + if quarantine: + cleanup_base_error, delay = self._stop_admission_with_retry( + cleanup_base_error, + delay, + ) while True: try: clean = worker.is_clean() except BaseException as error: - logger.error( - "failed to verify Lean REPL worker cleanup", - exc_info=(type(error), error, error.__traceback__), - ) cleanup_base_error = self._remember_cleanup_base_error( cleanup_base_error, error, ) + logger.error( + "failed to verify Lean REPL worker cleanup", + exc_info=(type(error), error, error.__traceback__), + ) clean = False if clean: return cleanup_base_error if not quarantine: - self._stop_admission() + cleanup_base_error, delay = self._stop_admission_with_retry( + cleanup_base_error, + delay, + ) quarantine = True try: worker.close() except BaseException as error: - logger.error( - "failed to retire quarantined Lean REPL worker; retrying", - exc_info=(type(error), error, error.__traceback__), - ) cleanup_base_error = self._remember_cleanup_base_error( cleanup_base_error, error, ) + logger.error( + "failed to retire quarantined Lean REPL worker; retrying", + exc_info=(type(error), error, error.__traceback__), + ) try: clean = worker.is_clean() except BaseException as error: - logger.error( - "failed to verify quarantined Lean REPL worker cleanup", - exc_info=(type(error), error, error.__traceback__), - ) cleanup_base_error = self._remember_cleanup_base_error( cleanup_base_error, error, ) + logger.error( + "failed to verify quarantined Lean REPL worker cleanup", + exc_info=(type(error), error, error.__traceback__), + ) clean = False if clean: return cleanup_base_error @@ -264,18 +299,20 @@ def run_once() -> dict[str, Any]: return call_result finally: emergency_cleanup_error: BaseException | None = None - if repl is not None and not worker_settled: - emergency_cleanup_error = self._settle_worker( - repl, - quarantine=True, - ) - worker_settled = True - with self._condition: - if repl is not None: - if worker_settled and not self._shutdown: - self._idle.put(repl) - self._active_calls -= 1 - self._condition.notify_all() + try: + if repl is not None and not worker_settled: + emergency_cleanup_error = self._settle_worker( + repl, + quarantine=True, + ) + worker_settled = True + finally: + with self._condition: + if repl is not None: + if worker_settled and not self._shutdown: + self._idle.put(repl) + self._active_calls -= 1 + self._condition.notify_all() if emergency_cleanup_error is not None: raise emergency_cleanup_error.with_traceback( emergency_cleanup_error.__traceback__ diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 06b701677..320b22959 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -12,6 +12,35 @@ from servers.repl import pool as repl_pool +def _dirty_result_repl(*close_errors): + workers = [] + + class FakeRepl: + def __init__(self, config): + self.dirty = False + self.close_calls = 0 + self.close_errors = list(close_errors) + workers.append(self) + + def run_disposable(self, code, **kwargs): + self.dirty = True + return {"messages": []} + + def is_clean(self): + return not self.dirty + + def close(self): + self.close_calls += 1 + self.dirty = False + if self.close_errors: + raise self.close_errors.pop(0) + + def get_memory_usage(self): + return 0.0 + + return FakeRepl, workers + + def test_partial_pool_construction_closes_all_constructed_workers(monkeypatch): workers = [] @@ -323,6 +352,87 @@ def get_memory_usage(self): pool.shutdown() +def test_pool_preserves_admission_cancellation_across_sleep_and_close(monkeypatch): + FakeRepl, workers = _dirty_result_repl(SystemExit("close cancelled")) + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + stop_admission = pool._stop_admission + stop_calls = 0 + + def cancel_first_stop(): + nonlocal stop_calls + stop_calls += 1 + if stop_calls == 1: + raise KeyboardInterrupt("admission cancelled") + stop_admission() + + sleep_calls = 0 + + def cancel_first_sleep(delay): + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls == 1: + raise SystemExit("sleep cancelled") + + monkeypatch.setattr(pool, "_stop_admission", cancel_first_stop) + monkeypatch.setattr(repl_pool.time, "sleep", cancel_first_sleep) + + with pytest.raises(KeyboardInterrupt, match="admission cancelled") as error: + pool.run("#check Nat", timeout=1) + + assert stop_calls == 2 + assert sleep_calls == 1 + assert workers[0].close_calls == 1 + assert workers[0].is_clean() + assert pool._active_calls == 0 + assert not pool.is_usable() + if hasattr(error.value, "add_note"): + assert error.value.__notes__ == [ + "Lean REPL cleanup also raised: sleep cancelled", + "Lean REPL cleanup also raised: close cancelled", + ] + pool.shutdown() + + +def test_repeated_admission_cancellation_cannot_skip_final_accounting(monkeypatch): + FakeRepl, workers = _dirty_result_repl() + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + monkeypatch.setattr(repl_pool.time, "sleep", lambda delay: None) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + stop_admission = pool._stop_admission + cancellations = [ + KeyboardInterrupt("first admission cancellation"), + SystemExit("second admission cancellation"), + ] + stop_calls = 0 + + def repeatedly_cancel_stop(): + nonlocal stop_calls + stop_calls += 1 + if cancellations: + raise cancellations.pop(0) + stop_admission() + + monkeypatch.setattr(pool, "_stop_admission", repeatedly_cancel_stop) + + with pytest.raises(KeyboardInterrupt, match="first admission cancellation") as error: + pool.run("#check Nat", timeout=1) + + assert stop_calls == 3 + assert workers[0].close_calls == 1 + assert workers[0].is_clean() + assert pool._active_calls == 0 + assert pool._idle.empty() + assert not pool.is_usable() + if hasattr(error.value, "add_note"): + assert error.value.__notes__ == [ + "Lean REPL cleanup also raised: second admission cancellation" + ] + pool.shutdown() + + def test_shutdown_unblocks_a_call_waiting_for_a_cold_slot(monkeypatch): class FakeRepl: def __init__(self, config): From 26d277ddfd8c7c35ba1b2f7417d127080fc4d6da Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 05:21:55 -0400 Subject: [PATCH 013/109] Retain cache lease through REPL settlement --- servers/repl/pool.py | 47 ++++++++++- tests/test_shared_lean_runtime.py | 124 ++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index e45b484a4..db3d66110 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -140,7 +140,7 @@ def _stop_admission_with_retry( ) delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) - def _settle_worker( + def _settle_worker_once( self, worker: LeanRepl, *, @@ -214,6 +214,43 @@ def _settle_worker( ) delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) + def _settle_worker( + self, + worker: LeanRepl, + *, + quarantine: bool, + ) -> BaseException | None: + """Retry the complete settlement protocol until cleanup is verified.""" + cleanup_base_error: BaseException | None = None + delay = _CLEANUP_RETRY_INITIAL_SECONDS + while True: + try: + settled_error = self._settle_worker_once( + worker, + quarantine=quarantine, + ) + except BaseException as error: + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + error, + ) + quarantine = True + try: + time.sleep(delay) + except BaseException as sleep_error: + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + sleep_error, + ) + delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) + continue + if settled_error is not None and settled_error is not cleanup_base_error: + cleanup_base_error = self._remember_cleanup_base_error( + cleanup_base_error, + settled_error, + ) + return cleanup_base_error + def run(self, code: str, **kwargs: Any) -> dict[str, Any]: """Run code on an idle REPL within one queue-and-execution timeout.""" timeout = kwargs.pop("timeout", None) @@ -308,10 +345,12 @@ def run_once() -> dict[str, Any]: worker_settled = True finally: with self._condition: - if repl is not None: - if worker_settled and not self._shutdown: + if repl is not None and not worker_settled: + self._shutdown = True + else: + if repl is not None and not self._shutdown: self._idle.put(repl) - self._active_calls -= 1 + self._active_calls -= 1 self._condition.notify_all() if emergency_cleanup_error is not None: raise emergency_cleanup_error.with_traceback( diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 49a3339fe..382991d6a 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -249,6 +249,130 @@ def dispatch(code, *, started=None, finished=None): services.close() +def test_unexpected_settle_escape_cannot_release_dirty_pool_ownership( + tmp_path, + monkeypatch, +): + from servers.repl import pool as repl_pool + + project = make_lake_project(tmp_path, "settlement-escape") + allow_cleanup = threading.Event() + settle_escaped = threading.Event() + close_failed = threading.Event() + second_started = threading.Event() + pools = [] + workers = [] + + class FakeRepl: + def __init__(self, config): + self.number = len(workers) + 1 + self.dirty = False + self.close_calls = 0 + workers.append(self) + + def run_disposable(self, code, **kwargs): + if self.number == 1: + self.dirty = True + return {"messages": []} + + def is_clean(self): + return not self.dirty + + def close(self): + self.close_calls += 1 + if self.dirty and not allow_cleanup.is_set(): + close_failed.set() + raise RuntimeError("persistent cleanup failure") + self.dirty = False + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + + def create_pool(root): + pool = repl_pool.LeanReplPool( + repl_pool.LeanReplPoolConfig(cwd=str(root), num_repls=1) + ) + pools.append(pool) + if len(pools) == 1: + settle_worker_once = pool._settle_worker_once + escapes_remaining = 2 + + def escape_before_cleanup(worker, *, quarantine): + nonlocal escapes_remaining + if escapes_remaining: + escapes_remaining -= 1 + pool._stop_admission() + if escapes_remaining == 0: + settle_escaped.set() + raise RuntimeError("unexpected settlement escape") + return settle_worker_once(worker, quarantine=quarantine) + + monkeypatch.setattr(pool, "_settle_worker_once", escape_before_cleanup) + return pool + + services = LeanRuntimeServices( + runtime_config(), + repl_factory=create_pool, + lsp_factory=FakeLsp, + start_sweepers=False, + ) + results = [] + errors = [] + + def dispatch(code, *, started=None): + if started is not None: + started.set() + try: + results.append( + services.dispatch( + "repl.run", + {"project_dir": str(project), "code": code, "timeout": 1}, + ) + ) + except BaseException as error: + errors.append(error) + + first = threading.Thread(target=dispatch, args=("#check Nat",)) + first.start() + assert settle_escaped.wait(timeout=1) + assert close_failed.wait(timeout=1) + + second = threading.Thread( + target=dispatch, + args=("#check Int",), + kwargs={"started": second_started}, + ) + second.start() + assert second_started.wait(timeout=1) + second.join(timeout=0.1) + + try: + assert first.is_alive() + assert second.is_alive() + assert len(pools) == 1 + assert pools[0]._active_calls == 1 + assert workers[0].dirty + assert workers[0].close_calls >= 1 + assert services.repl_projects.stats()["resident"][0]["active"] == 1 + finally: + allow_cleanup.set() + first.join(timeout=2) + second.join(timeout=2) + + try: + assert not first.is_alive() + assert not second.is_alive() + assert errors == [] + assert results == ["Compiles successfully", "Compiles successfully"] + assert len(pools) == 2 + assert workers[0].is_clean() + finally: + allow_cleanup.set() + services.close() + + def test_lru_limit_never_evicts_an_active_project(tmp_path): first = make_lake_project(tmp_path, "first") second = make_lake_project(tmp_path, "second") From c5cfd799753750d5a1850e9c4140720f964932b4 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 05:23:58 -0400 Subject: [PATCH 014/109] Log unexpected REPL settlement retries --- servers/repl/pool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index db3d66110..b37a7027f 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -234,6 +234,10 @@ def _settle_worker( cleanup_base_error, error, ) + logger.error( + "Lean REPL settlement failed unexpectedly; retrying", + exc_info=(type(error), error, error.__traceback__), + ) quarantine = True try: time.sleep(delay) From db02051fd1e05e3303ecf67dad53d7fd82661122 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 18:38:58 -0400 Subject: [PATCH 015/109] Collapse REPL pool settlement into one cleanup loop A dirty worker still stops pool admission and keeps its slot, active call, and cache lease until close() verifies that it owns no process. The first cancellation raised by cleanup is still re-raised after settlement, and a request cancellation still takes precedence. Remove the retry wrappers around setting the shutdown flag and around the settlement protocol itself, the closing/closed shutdown states, and the failed-worker retention in _close_workers. Workers are cold between calls and shutdown waits for every active call to settle, so the close in shutdown never owns a process. Runtime calls run on socketserver request threads, where KeyboardInterrupt and SystemExit cannot arrive asynchronously, so the tests that injected them into those private helpers covered no reachable path. Replace start()'s warmup_imports override with a warm flag, since the only caller passes an empty tuple, and drop an unreachable no-result check. --- servers/repl/core.py | 20 +- servers/repl/pool.py | 318 ++++-------------------------- tests/test_repl_core_protocol.py | 2 +- tests/test_repl_pool_lifecycle.py | 110 ----------- tests/test_shared_lean_runtime.py | 124 ------------ 5 files changed, 48 insertions(+), 526 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 9813217e0..b1bd5a620 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -574,7 +574,7 @@ def start( self, startup_timeout: float | None = None, *, - warmup_imports: frozenset[str] | tuple[str, ...] | None = None, + warm: bool = True, ) -> None: """Start and warm the Lean REPL within one startup deadline.""" if os.name != "posix": @@ -609,18 +609,13 @@ def remaining() -> float: self._stderr_bytes = 0 self._stderr_tail.clear() - startup_imports = ( - self.config.warmup_imports - if warmup_imports is None - else warmup_imports - ) - if startup_imports: + if warm and self.config.warmup_imports: header = "\n".join( - f"import {root}" for root in startup_imports + f"import {root}" for root in self.config.warmup_imports ) logger.info( "Loading imports at startup: %s", - startup_imports, + self.config.warmup_imports, ) resp = self._run(code=header, env_id=None, timeout=remaining()) environment, messages = _validate_command_response( @@ -783,10 +778,7 @@ def remaining() -> float: command = f"{prefix}\n{code}" if prefix else code # Do not send startup import or smoke-test frames. The # submitted command is the generation's only request. - self.start( - startup_timeout=remaining(), - warmup_imports=(), - ) + self.start(startup_timeout=remaining(), warm=False) response = self._run( code=command, env_id=None, @@ -850,8 +842,6 @@ def remaining() -> float: if request_error is not None: raise request_error.with_traceback(request_error.__traceback__) - if result is None: - raise RuntimeError("disposable Lean REPL call produced no result") return result def run(self, code: str, env_id: int | None = None, timeout: float | None = None) -> dict[str, Any]: diff --git a/servers/repl/pool.py b/servers/repl/pool.py index e887a9768..f2189f309 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -51,8 +51,6 @@ def __init__(self, config: LeanReplPoolConfig) -> None: self._lock = threading.Lock() self._condition = threading.Condition(self._lock) self._active_calls = 0 - self._closing = False - self._closed = False try: for _ in range(self.capacity): @@ -65,321 +63,89 @@ def __init__(self, config: LeanReplPoolConfig) -> None: def _close_workers(self) -> None: """Close every constructed worker, preserving cleanup after one failure.""" - failed_workers = [] - first_error: BaseException | None = None for worker in reversed(self._workers): try: worker.close() - except BaseException as error: + except Exception: logger.exception("failed to close REPL worker") - failed_workers.append(worker) - if first_error is None: - first_error = error - self._workers = list(reversed(failed_workers)) + self._workers.clear() while True: try: self._idle.get_nowait() except queue.Empty: break - if first_error is not None: - raise first_error def is_usable(self) -> bool: """Return whether this pool may admit another public call.""" - with self._condition: - return not self._shutdown and not self._closed + return not self._shutdown - def _stop_admission(self) -> None: - with self._condition: - self._shutdown = True - self._condition.notify_all() - - @staticmethod - def _remember_cleanup_base_error( - remembered: BaseException | None, - error: BaseException, - ) -> BaseException | None: - """Retain cancellation-like cleanup failures until ownership is settled.""" - if isinstance(error, Exception): - return remembered - if remembered is None: - return error - add_note = getattr(remembered, "add_note", None) - if add_note is not None: - add_note(f"Lean REPL cleanup also raised: {error}") - return remembered - - def _stop_admission_with_retry( - self, - cleanup_base_error: BaseException | None, - delay: float, - ) -> tuple[BaseException | None, float]: - """Stop admission before cleanup despite cancellation-like interruptions.""" - while True: - try: - self._stop_admission() - except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - logger.error( - "failed to stop Lean REPL pool admission; retrying", - exc_info=(type(error), error, error.__traceback__), - ) - else: - return cleanup_base_error, delay - - try: - time.sleep(delay) - except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) + def _settle(self, worker: LeanRepl) -> BaseException | None: + """Stop admission and retry cleanup until the worker owns no process. - def _settle_worker_once( - self, - worker: LeanRepl, - *, - quarantine: bool, - ) -> BaseException | None: - """Retain and retry a worker until it verifiably owns no process.""" - cleanup_base_error: BaseException | None = None + Returns the first cancellation-like cleanup error, for the caller to + raise once ownership is settled. + """ + cancelled: BaseException | None = None delay = _CLEANUP_RETRY_INITIAL_SECONDS - if quarantine: - cleanup_base_error, delay = self._stop_admission_with_retry( - cleanup_base_error, - delay, - ) - while True: - try: - clean = worker.is_clean() - except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - logger.error( - "failed to verify Lean REPL worker cleanup", - exc_info=(type(error), error, error.__traceback__), - ) - clean = False - - if clean: - return cleanup_base_error - - if not quarantine: - cleanup_base_error, delay = self._stop_admission_with_retry( - cleanup_base_error, - delay, - ) - quarantine = True - + while not worker.is_clean(): + self._shutdown = True try: worker.close() except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - logger.error( - "failed to retire quarantined Lean REPL worker; retrying", - exc_info=(type(error), error, error.__traceback__), - ) - - try: - clean = worker.is_clean() - except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - logger.error( - "failed to verify quarantined Lean REPL worker cleanup", - exc_info=(type(error), error, error.__traceback__), - ) - clean = False - if clean: - return cleanup_base_error - - try: + logger.exception("failed to retire Lean REPL worker; retrying") + if cancelled is None and not isinstance(error, Exception): + cancelled = error time.sleep(delay) - except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) - - def _settle_worker( - self, - worker: LeanRepl, - *, - quarantine: bool, - ) -> BaseException | None: - """Retry the complete settlement protocol until cleanup is verified.""" - cleanup_base_error: BaseException | None = None - delay = _CLEANUP_RETRY_INITIAL_SECONDS - while True: - try: - settled_error = self._settle_worker_once( - worker, - quarantine=quarantine, - ) - except BaseException as error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - error, - ) - logger.error( - "Lean REPL settlement failed unexpectedly; retrying", - exc_info=(type(error), error, error.__traceback__), - ) - quarantine = True - try: - time.sleep(delay) - except BaseException as sleep_error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - sleep_error, - ) delay = min(delay * 2, _CLEANUP_RETRY_MAX_SECONDS) - continue - if settled_error is not None and settled_error is not cleanup_base_error: - cleanup_base_error = self._remember_cleanup_base_error( - cleanup_base_error, - settled_error, - ) - return cleanup_base_error + return cancelled - def run(self, code: str, **kwargs: Any) -> dict[str, Any]: - """Run code on an idle REPL within one queue-and-execution timeout.""" - timeout = kwargs.pop("timeout", None) + def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: + """Run code on a cold slot within one queue-and-execution timeout.""" deadline = time.monotonic() + timeout if timeout is not None else None with self._condition: if self._shutdown: raise RuntimeError("Lean REPL pool is shut down") self._active_calls += 1 repl: LeanRepl | None = None - - def run_once() -> dict[str, Any]: - if kwargs: - names = ", ".join(sorted(kwargs)) - raise TypeError(f"unsupported Lean REPL pool arguments: {names}") - if deadline is not None: - remaining = deadline - time.monotonic() - if remaining <= 0: + try: + while True: + if self._shutdown: + raise RuntimeError("Lean REPL pool is shut down") + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: raise TimeoutError( f"timed out after {timeout:g}s waiting for an idle Lean REPL" ) - assert repl is not None - return repl.run_disposable(code, timeout=remaining) - assert repl is not None - return repl.run_disposable(code) - - worker_settled = False - try: - while repl is None: - with self._condition: - if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") - wait = 0.1 - if deadline is not None: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise TimeoutError( - f"timed out after {timeout:g}s waiting for an idle Lean REPL" - ) - wait = min(wait, remaining) + if repl is not None: + break try: - repl = self._idle.get(timeout=wait) + repl = self._idle.get(timeout=0.1 if remaining is None else min(0.1, remaining)) except queue.Empty: - continue - call_result: dict[str, Any] | None = None - call_error: BaseException | None = None - try: - with self._condition: - if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") - call_result = run_once() - except BaseException as error: - call_error = error - - cleanup_base_error = self._settle_worker( - repl, - quarantine=isinstance(call_error, ReplCleanupError), - ) - worker_settled = True - - if call_error is not None and not isinstance(call_error, Exception): - if cleanup_base_error is not None and cleanup_base_error is not call_error: - add_note = getattr(call_error, "add_note", None) - if add_note is not None: - add_note( - "Lean REPL cleanup also raised: " - f"{cleanup_base_error}" - ) - raise call_error.with_traceback(call_error.__traceback__) - if cleanup_base_error is not None: - if call_error is not None: - add_note = getattr(cleanup_base_error, "add_note", None) - if add_note is not None: - add_note(f"Lean REPL call also failed: {call_error}") - raise cleanup_base_error.with_traceback( - cleanup_base_error.__traceback__ - ) - if isinstance(call_error, ReplCleanupError): - return call_error.result - if call_error is not None: - raise call_error.with_traceback(call_error.__traceback__) - if call_result is None: - raise RuntimeError("Lean REPL worker returned no result") - return call_result - finally: - emergency_cleanup_error: BaseException | None = None + pass try: - if repl is not None and not worker_settled: - emergency_cleanup_error = self._settle_worker( - repl, - quarantine=True, - ) - worker_settled = True + result = repl.run_disposable(code, timeout=remaining) + except ReplCleanupError as error: + result = error.result finally: - with self._condition: - if repl is not None and not worker_settled: - self._shutdown = True - else: - if repl is not None and not self._shutdown: - self._idle.put(repl) - self._active_calls -= 1 - self._condition.notify_all() - if emergency_cleanup_error is not None: - raise emergency_cleanup_error.with_traceback( - emergency_cleanup_error.__traceback__ - ) + cancelled = self._settle(repl) + if cancelled is not None: + raise cancelled + return result + finally: + with self._condition: + if repl is not None and not self._shutdown: + self._idle.put(repl) + self._active_calls -= 1 + self._condition.notify_all() def get_memory_usage(self) -> float: """Total memory usage across all REPL instances in GB.""" return sum(w.get_memory_usage() for w in self._workers) def shutdown(self) -> None: - """Shut down all REPL instances.""" + """Stop admission, wait for active calls to settle, then close every slot.""" with self._condition: self._shutdown = True - self._condition.notify_all() while self._active_calls: self._condition.wait() - while self._closing: - self._condition.wait() - if self._closed: - return - self._closing = True - try: - self._close_workers() - finally: - with self._condition: - self._closing = False - self._closed = not self._workers - self._condition.notify_all() + self._close_workers() diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 1f8bb829c..19836e6cf 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -776,7 +776,7 @@ def run(code, env_id, timeout): "sorries": [{"goal": "False"}], } assert len(starts) == 1 - assert starts[0]["warmup_imports"] == () + assert starts[0]["warm"] is False assert 0 < starts[0]["startup_timeout"] <= 3 assert len(calls) == 1 assert calls[0][0] == "import Mathlib\n#check Nat" diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 320b22959..06b701677 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -12,35 +12,6 @@ from servers.repl import pool as repl_pool -def _dirty_result_repl(*close_errors): - workers = [] - - class FakeRepl: - def __init__(self, config): - self.dirty = False - self.close_calls = 0 - self.close_errors = list(close_errors) - workers.append(self) - - def run_disposable(self, code, **kwargs): - self.dirty = True - return {"messages": []} - - def is_clean(self): - return not self.dirty - - def close(self): - self.close_calls += 1 - self.dirty = False - if self.close_errors: - raise self.close_errors.pop(0) - - def get_memory_usage(self): - return 0.0 - - return FakeRepl, workers - - def test_partial_pool_construction_closes_all_constructed_workers(monkeypatch): workers = [] @@ -352,87 +323,6 @@ def get_memory_usage(self): pool.shutdown() -def test_pool_preserves_admission_cancellation_across_sleep_and_close(monkeypatch): - FakeRepl, workers = _dirty_result_repl(SystemExit("close cancelled")) - - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) - stop_admission = pool._stop_admission - stop_calls = 0 - - def cancel_first_stop(): - nonlocal stop_calls - stop_calls += 1 - if stop_calls == 1: - raise KeyboardInterrupt("admission cancelled") - stop_admission() - - sleep_calls = 0 - - def cancel_first_sleep(delay): - nonlocal sleep_calls - sleep_calls += 1 - if sleep_calls == 1: - raise SystemExit("sleep cancelled") - - monkeypatch.setattr(pool, "_stop_admission", cancel_first_stop) - monkeypatch.setattr(repl_pool.time, "sleep", cancel_first_sleep) - - with pytest.raises(KeyboardInterrupt, match="admission cancelled") as error: - pool.run("#check Nat", timeout=1) - - assert stop_calls == 2 - assert sleep_calls == 1 - assert workers[0].close_calls == 1 - assert workers[0].is_clean() - assert pool._active_calls == 0 - assert not pool.is_usable() - if hasattr(error.value, "add_note"): - assert error.value.__notes__ == [ - "Lean REPL cleanup also raised: sleep cancelled", - "Lean REPL cleanup also raised: close cancelled", - ] - pool.shutdown() - - -def test_repeated_admission_cancellation_cannot_skip_final_accounting(monkeypatch): - FakeRepl, workers = _dirty_result_repl() - - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - monkeypatch.setattr(repl_pool.time, "sleep", lambda delay: None) - pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) - stop_admission = pool._stop_admission - cancellations = [ - KeyboardInterrupt("first admission cancellation"), - SystemExit("second admission cancellation"), - ] - stop_calls = 0 - - def repeatedly_cancel_stop(): - nonlocal stop_calls - stop_calls += 1 - if cancellations: - raise cancellations.pop(0) - stop_admission() - - monkeypatch.setattr(pool, "_stop_admission", repeatedly_cancel_stop) - - with pytest.raises(KeyboardInterrupt, match="first admission cancellation") as error: - pool.run("#check Nat", timeout=1) - - assert stop_calls == 3 - assert workers[0].close_calls == 1 - assert workers[0].is_clean() - assert pool._active_calls == 0 - assert pool._idle.empty() - assert not pool.is_usable() - if hasattr(error.value, "add_note"): - assert error.value.__notes__ == [ - "Lean REPL cleanup also raised: second admission cancellation" - ] - pool.shutdown() - - def test_shutdown_unblocks_a_call_waiting_for_a_cold_slot(monkeypatch): class FakeRepl: def __init__(self, config): diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 382991d6a..49a3339fe 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -249,130 +249,6 @@ def dispatch(code, *, started=None, finished=None): services.close() -def test_unexpected_settle_escape_cannot_release_dirty_pool_ownership( - tmp_path, - monkeypatch, -): - from servers.repl import pool as repl_pool - - project = make_lake_project(tmp_path, "settlement-escape") - allow_cleanup = threading.Event() - settle_escaped = threading.Event() - close_failed = threading.Event() - second_started = threading.Event() - pools = [] - workers = [] - - class FakeRepl: - def __init__(self, config): - self.number = len(workers) + 1 - self.dirty = False - self.close_calls = 0 - workers.append(self) - - def run_disposable(self, code, **kwargs): - if self.number == 1: - self.dirty = True - return {"messages": []} - - def is_clean(self): - return not self.dirty - - def close(self): - self.close_calls += 1 - if self.dirty and not allow_cleanup.is_set(): - close_failed.set() - raise RuntimeError("persistent cleanup failure") - self.dirty = False - - def get_memory_usage(self): - return 0.0 - - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - - def create_pool(root): - pool = repl_pool.LeanReplPool( - repl_pool.LeanReplPoolConfig(cwd=str(root), num_repls=1) - ) - pools.append(pool) - if len(pools) == 1: - settle_worker_once = pool._settle_worker_once - escapes_remaining = 2 - - def escape_before_cleanup(worker, *, quarantine): - nonlocal escapes_remaining - if escapes_remaining: - escapes_remaining -= 1 - pool._stop_admission() - if escapes_remaining == 0: - settle_escaped.set() - raise RuntimeError("unexpected settlement escape") - return settle_worker_once(worker, quarantine=quarantine) - - monkeypatch.setattr(pool, "_settle_worker_once", escape_before_cleanup) - return pool - - services = LeanRuntimeServices( - runtime_config(), - repl_factory=create_pool, - lsp_factory=FakeLsp, - start_sweepers=False, - ) - results = [] - errors = [] - - def dispatch(code, *, started=None): - if started is not None: - started.set() - try: - results.append( - services.dispatch( - "repl.run", - {"project_dir": str(project), "code": code, "timeout": 1}, - ) - ) - except BaseException as error: - errors.append(error) - - first = threading.Thread(target=dispatch, args=("#check Nat",)) - first.start() - assert settle_escaped.wait(timeout=1) - assert close_failed.wait(timeout=1) - - second = threading.Thread( - target=dispatch, - args=("#check Int",), - kwargs={"started": second_started}, - ) - second.start() - assert second_started.wait(timeout=1) - second.join(timeout=0.1) - - try: - assert first.is_alive() - assert second.is_alive() - assert len(pools) == 1 - assert pools[0]._active_calls == 1 - assert workers[0].dirty - assert workers[0].close_calls >= 1 - assert services.repl_projects.stats()["resident"][0]["active"] == 1 - finally: - allow_cleanup.set() - first.join(timeout=2) - second.join(timeout=2) - - try: - assert not first.is_alive() - assert not second.is_alive() - assert errors == [] - assert results == ["Compiles successfully", "Compiles successfully"] - assert len(pools) == 2 - assert workers[0].is_clean() - finally: - allow_cleanup.set() - services.close() - - def test_lru_limit_never_evicts_an_active_project(tmp_path): first = make_lake_project(tmp_path, "first") second = make_lake_project(tmp_path, "second") From 45520419ce86c9f1f112bb999d55893be6205f59 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 19:29:56 -0400 Subject: [PATCH 016/109] Run the Lean header parser as the slot's process generation LeanRepl._check_header spawns `lean --deps-json` as the wrapper's own process and reaps it through close(). Disposable cleanup, cancellation precedence, and pool settlement now treat the parser exactly like the REPL child, so the separate kill path, _HeaderProcessCleanupError, and the ownership handoff in run_disposable are gone. A parser whose cleanup fails stays owned by the slot until close() verifies it exited. Reuse _decode_repl_json for strict JSON, drop the test-only _lean_header_modules wrapper and two guards for states the only caller cannot produce, and fold the leading-import gate into the existing warmup generator. --- servers/lean_runtime.py | 4 +- servers/repl/core.py | 241 ++++++------------------------- tests/test_repl_core_protocol.py | 39 ++--- 3 files changed, 63 insertions(+), 221 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 7a568130e..e7fdb32f3 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -172,9 +172,7 @@ def from_environment(cls) -> "LeanRuntimeConfig": "AUTOFORM_REPL_TOTAL_WORKERS" ) repl_project_limit = min(max_projects, total_workers // workers_per_project) - repl_command = tuple( - shlex.split(os.environ.get("LEAN_REPL_CMD", "lake exe @repl/repl")) - ) + repl_command = tuple(shlex.split(os.environ.get("LEAN_REPL_CMD", "lake exe @repl/repl"))) lsp_command = tuple(shlex.split(os.environ.get("LEAN_LSP_CMD", "lake serve"))) if not repl_command: raise ValueError("LEAN_REPL_CMD must not be empty") diff --git a/servers/repl/core.py b/servers/repl/core.py index 968d56232..ced9ec547 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -296,22 +296,6 @@ def _validate_command_response( return environment, messages -class _HeaderProcessCleanupError(RuntimeError): - """Header validation ended without verified process cleanup.""" - - def __init__( - self, - message: str, - process: subprocess.Popen[bytes], - original_error: BaseException | None, - cleanup_error: BaseException, - ) -> None: - super().__init__(message) - self.process = process - self.original_error = original_error - self.cleanup_error = cleanup_error - - def _communicate_bounded( process: subprocess.Popen[bytes], input_bytes: bytes, @@ -320,11 +304,6 @@ def _communicate_bounded( max_output_bytes: int, ) -> tuple[bytes, bytes]: """Exchange bytes with a child without unbounded output or time.""" - if max_output_bytes < 1: - raise ValueError("header parser output limit must be positive") - if process.stdin is None or process.stdout is None or process.stderr is None: - raise RuntimeError("header parser pipes are unavailable") - stdin_fd = process.stdin.fileno() stdout_fd = process.stdout.fileno() stderr_fd = process.stderr.fileno() @@ -427,24 +406,8 @@ class _LeanHeaderAnalysis: def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: """Decode the strict schemas emitted by Lean's fast import parser.""" - - def reject_constant(value: str) -> None: - raise ValueError(f"nonstandard JSON constant {value!r}") - - def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: - result: dict[str, Any] = {} - for key, value in pairs: - if key in result: - raise ValueError(f"duplicate JSON key {key!r}") - result[key] = value - return result - try: - payload = json.loads( - stdout.decode("utf-8"), - parse_constant=reject_constant, - object_pairs_hook=reject_duplicate_keys, - ) + payload = _decode_repl_json(stdout) if not isinstance(payload, dict) or set(payload) != {"imports"}: raise ValueError entries = payload["imports"] @@ -471,13 +434,9 @@ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: if not isinstance(result, dict): raise ValueError imports = result.get("imports") - candidate_is_module = result.get("isModule") - if ( - candidate_is_module is not None - and type(candidate_is_module) is not bool - ): + is_module = result.get("isModule") + if is_module is not None and type(is_module) is not bool: raise ValueError - is_module = candidate_is_module else: imports = entry["imports"] if not isinstance(imports, list): @@ -514,9 +473,7 @@ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: and is_module is False ), ) - except UnicodeDecodeError: - raise ValueError("unrecognized output from lean --deps-json") from None - except (TypeError, KeyError, json.JSONDecodeError): + except (ReplProtocolError, TypeError, KeyError, json.JSONDecodeError): raise ValueError("unrecognized output from lean --deps-json") from None except ValueError as error: if str(error): @@ -524,88 +481,6 @@ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: raise ValueError("unrecognized output from lean --deps-json") from None -def _lean_header_analysis( - command: list[str], - code: str, - *, - cwd: str | None, - env: dict[str, str], - deadline: float, - max_output_bytes: int, -) -> _LeanHeaderAnalysis: - """Return Lean-owned composition facts for one submitted header.""" - if deadline - time.monotonic() <= 0: - raise TimeoutError("timed out before checking the Lean header") - process = subprocess.Popen( - command, - cwd=cwd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - start_new_session=True, - ) - original_error: BaseException | None = None - stdout = b"" - stderr = b"" - try: - stdout, stderr = _communicate_bounded( - process, - code.encode(), - deadline=deadline, - max_output_bytes=max_output_bytes, - ) - except BaseException as error: - original_error = error - try: - _kill_subprocesses( - process, - process.pid, - time.monotonic() + DEFAULT_REPL_CLEANUP_SECONDS, - ) - except BaseException as cleanup_error: - raise _HeaderProcessCleanupError( - f"Lean header parser cleanup failed: {cleanup_error}", - process, - original_error, - cleanup_error, - ) from cleanup_error - finally: - for stream in (process.stdin, process.stdout, process.stderr): - if stream is not None and not stream.closed: - try: - stream.close() - except OSError: - pass - if original_error is not None: - raise original_error.with_traceback(original_error.__traceback__) - if process.returncode != 0: - detail = stderr.decode(errors="replace").strip().splitlines() - raise ValueError(detail[0] if detail else f"exit status {process.returncode}") - return _decode_header_analysis(stdout) - - -def _lean_header_modules( - command: list[str], - code: str, - *, - cwd: str | None, - env: dict[str, str], - deadline: float, - max_output_bytes: int, -) -> list[str]: - """Compatibility wrapper returning only Lean's direct imported modules.""" - analysis = _lean_header_analysis( - command, - code, - cwd=cwd, - env=env, - deadline=deadline, - max_output_bytes=max_output_bytes, - ) - return list(analysis.modules) - - def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: """Split Lean code into import statements and body. @@ -652,17 +527,9 @@ class LeanReplConfig: allowed_imports: frozenset[str] = ALLOWED_IMPORTS warmup_imports: frozenset[str] = WARMUP_IMPORTS - repl_command: list[str] = field( - default_factory=lambda: ["lake", "exe", "@repl/repl"] - ) + repl_command: list[str] = field(default_factory=lambda: ["lake", "exe", "@repl/repl"]) header_deps_command: list[str] = field( - default_factory=lambda: [ - "lake", - "env", - sys.executable, - "-c", - _LEAN_HEADER_LAUNCHER, - ] + default_factory=lambda: ["lake", "env", sys.executable, "-c", _LEAN_HEADER_LAUNCHER] ) # stdout is capped per response. stderr has no protocol framing, so its @@ -1053,6 +920,37 @@ def is_clean(self) -> bool: """Return whether this wrapper owns no process generation.""" return self.process is None and self._process_group_id is None + def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: + """Analyze the submitted header with the selected toolchain's Lean parser. + + The parser is this wrapper's process generation until close() verifies + that it exited, so a failed cleanup is retried before the slot is reused. + """ + env = _inherit_clean_env() + env.update(self.config.env) + self.process = subprocess.Popen( + self.config.header_deps_command, + cwd=self.cwd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=True, + ) + self._process_group_id = self.process.pid + stdout, stderr = _communicate_bounded( + self.process, + code.encode(), + deadline=deadline, + max_output_bytes=self.config.max_buffer_bytes, + ) + returncode = self.process.returncode + self.close(deadline=deadline) + if returncode != 0: + detail = stderr.decode(errors="replace").strip().splitlines() + raise ValueError(detail[0] if detail else f"exit status {returncode}") + return _decode_header_analysis(stdout) + def run_disposable( self, code: str, @@ -1078,67 +976,27 @@ def remaining() -> float: # A previous failed cleanup must settle before another process # generation can be created from this slot. self.close(deadline=deadline) - lexical_imports, _, _ = _split_imports_and_body(code) - header_modules = lexical_imports + imports, _, _ = _split_imports_and_body(code) accepts_leading_imports = True if ( self.config.validate_imports and self._allowed_import_roots is not None ): - env = _inherit_clean_env() - env.update(self.config.env) try: - header = _lean_header_analysis( - self.config.header_deps_command, - code, - cwd=self.cwd, - env=env, - deadline=deadline, - max_output_bytes=self.config.max_buffer_bytes, - ) - header_modules = list(header.modules) - accepts_leading_imports = header.accepts_leading_imports - except _HeaderProcessCleanupError as error: - # Preserve ownership so the normal disposable cleanup - # retries before this slot can ever be reused. - self.process = error.process - self._process_group_id = error.process.pid - self._retire_pending = True - original_error = error.original_error - cleanup_error = error.cleanup_error - if original_error is not None and not isinstance( - original_error, Exception - ): - add_note = getattr(original_error, "add_note", None) - if add_note is not None: - add_note(str(error)) - raise original_error.with_traceback( - original_error.__traceback__ - ) - if not isinstance(cleanup_error, Exception): - raise cleanup_error.with_traceback( - cleanup_error.__traceback__ - ) - raise + header = self._check_header(code, deadline) except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} - header_modules = [] - accepts_leading_imports = False - submitted_roots = { - module.split(".")[0] for module in header_modules - } + else: + imports = list(header.modules) + accepts_leading_imports = header.accepts_leading_imports + submitted_roots = {statement.split(".")[0] for statement in imports} if ( result is None and self.config.validate_imports and self._allowed_import_roots is not None ): - warmup_roots = { - module.split(".")[0] - for module in self.config.warmup_imports - } - disallowed = ( - submitted_roots | warmup_roots - ) - self._allowed_import_roots + warmup_roots = {root.split(".")[0] for root in self.config.warmup_imports} + disallowed = (submitted_roots | warmup_roots) - self._allowed_import_roots if disallowed: result = { "repl_error": ( @@ -1148,15 +1006,10 @@ def remaining() -> float: ) } if result is None: - missing_warmup_imports = tuple( + added_imports = tuple( root for root in sorted(self.config.warmup_imports) - if root not in submitted_roots - ) - added_imports = ( - missing_warmup_imports - if accepts_leading_imports - else () + if accepts_leading_imports and root not in submitted_roots ) prefix = "\n".join( f"import {root}" for root in added_imports diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index e3131e9f5..33501bdc5 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -881,15 +881,12 @@ def _deps_json( return json.dumps({"imports": [entry]}) -def _header_modules(command: list[str]) -> list[str]: - return repl_core._lean_header_modules( - command, - "import Mathlib", - cwd=None, - env=dict(os.environ), - deadline=time.monotonic() + 10, - max_output_bytes=1024 * 1024, - ) +def _header_modules(command: list[str], **config) -> list[str]: + repl = repl_core.LeanRepl(repl_core.LeanReplConfig(header_deps_command=command, **config)) + try: + return list(repl._check_header("import Mathlib", time.monotonic() + 10).modules) + finally: + repl.close() def test_header_check_accepts_current_and_legacy_lean_schemas(): @@ -940,18 +937,12 @@ def write_parser(path, module): write_parser(trusted_bin / "lean", "Mathlib") write_parser(shadow_bin / "lean", "Unsafe") - env = dict(os.environ) - env["LEAN_SYSROOT"] = str(trusted_bin.parent) - env["PATH"] = f"{shadow_bin}{os.pathsep}{env.get('PATH', '')}" + env = { + "LEAN_SYSROOT": str(trusted_bin.parent), + "PATH": f"{shadow_bin}{os.pathsep}{os.environ.get('PATH', '')}", + } - modules = repl_core._lean_header_modules( - [sys.executable, "-c", repl_core._LEAN_HEADER_LAUNCHER], - "import Mathlib", - cwd=None, - env=env, - deadline=time.monotonic() + 10, - max_output_bytes=1024 * 1024, - ) + modules = _header_modules([sys.executable, "-c", repl_core._LEAN_HEADER_LAUNCHER], env=env) assert modules == ["Mathlib"] @@ -961,8 +952,8 @@ def write_parser(path, module): [ (_deps_json(errors=("bad header",)), "bad header"), ("not json", "unrecognized output"), - ('{"imports": [], "imports": []}', "duplicate JSON key"), - ('{"imports": NaN}', "nonstandard JSON constant"), + ('{"imports": [], "imports": []}', "unrecognized output"), + ('{"imports": NaN}', "unrecognized output"), ], ) def test_header_check_fails_closed_on_rejected_or_unknown_output(output, message): @@ -1008,11 +999,11 @@ def test_disposable_call_checks_submitted_header_before_warmup_prefix(monkeypatc ) checked = [] - def header_analysis(command, code, **kwargs): + def check_header(code, deadline): checked.append(code) raise ValueError("stop") - monkeypatch.setattr(repl_core, "_lean_header_analysis", header_analysis) + monkeypatch.setattr(repl, "_check_header", check_header) repl.run_disposable("/- note -/ import Unsafe\n#check Nat") From f3da0581c7deb554b8fb111aedec3e1f999409be Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 19:34:47 -0400 Subject: [PATCH 017/109] Tighten Lean header validation tests Parametrize the three warmup-composition tests over one scaffold with an explicit expected frame per header, compress the deps-json fixture, and use the fake parser's exit status to pin the nonzero-exit rejection that no test exercised. Give the import-rejection test a budget that covers spawning the parser child it now runs. --- tests/test_repl_core_protocol.py | 139 +++++++------------------------ 1 file changed, 29 insertions(+), 110 deletions(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 33501bdc5..a0643f976 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -823,7 +823,7 @@ def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): starts = [] monkeypatch.setattr(repl, "start", lambda **kwargs: starts.append(kwargs)) - result = repl.run_disposable("import Unsafe\n#check Nat", timeout=1) + result = repl.run_disposable("import Unsafe\n#check Nat", timeout=10) assert "Disallowed imports: Unsafe" in result["repl_error"] assert starts == [] @@ -845,39 +845,14 @@ def _deps_json( is_module: bool = False, prelude: bool = False, ) -> str: + def item(module: str, is_meta: bool = False) -> dict: + return {"module": module, "importAll": False, "isExported": True, "isMeta": is_meta} + entry: dict = {"errors": list(errors)} if not errors: - imports = [] - if not prelude: - imports.extend( - [ - { - "module": "Init", - "importAll": False, - "isExported": True, - "isMeta": False, - }, - { - "module": "Init", - "importAll": False, - "isExported": True, - "isMeta": True, - }, - ] - ) - imports.extend( - { - "module": module, - "importAll": False, - "isExported": True, - "isMeta": False, - } - for module in modules - ) - entry["result"] = { - "imports": imports, - "isModule": is_module, - } + implicit = [] if prelude else [item("Init"), item("Init", is_meta=True)] + imports = implicit + [item(module) for module in modules] + entry["result"] = {"imports": imports, "isModule": is_module} return json.dumps({"imports": [entry]}) @@ -948,17 +923,18 @@ def write_parser(path, module): @pytest.mark.parametrize( - ("output", "message"), + ("command", "message"), [ - (_deps_json(errors=("bad header",)), "bad header"), - ("not json", "unrecognized output"), - ('{"imports": [], "imports": []}', "unrecognized output"), - ('{"imports": NaN}', "unrecognized output"), + (_fake_header_deps(_deps_json(errors=("bad header",))), "bad header"), + (_fake_header_deps("", returncode=1, stderr="unknown package\n"), "unknown package"), + (_fake_header_deps("not json"), "unrecognized output"), + (_fake_header_deps('{"imports": [], "imports": []}'), "unrecognized output"), + (_fake_header_deps('{"imports": NaN}'), "unrecognized output"), ], ) -def test_header_check_fails_closed_on_rejected_or_unknown_output(output, message): +def test_header_check_fails_closed_on_rejected_or_unknown_output(command, message): with pytest.raises(ValueError, match=message): - _header_modules(_fake_header_deps(output)) + _header_modules(command) @pytest.mark.parametrize( @@ -1010,28 +986,31 @@ def check_header(code, deadline): assert checked == ["/- note -/ import Unsafe\n#check Nat"] +_LEGACY_INIT_ONLY = json.dumps( + {"imports": [{"errors": [], "imports": [{"module": "Init", "importAll": False}]}]} +) + + @pytest.mark.parametrize( - ("deps_output", "code"), + ("deps_output", "code", "frame"), [ + (_deps_json(), "#check Nat", "import Mathlib\n#check Nat"), ( _deps_json("REPL.Frontend", is_module=True), "module\npublic import REPL.Frontend\n", + "module\npublic import REPL.Frontend\n", ), ( _deps_json("REPL.Frontend", prelude=True), "prelude\nimport REPL.Frontend\n#check Nat", + "prelude\nimport REPL.Frontend\n#check Nat", ), - ( - _deps_json("Init", prelude=True), - "prelude\nimport Init\n#check Nat", - ), + (_deps_json("Init", prelude=True), "prelude\nimport Init\n#check Nat", "prelude\nimport Init\n#check Nat"), + # The legacy schema cannot prove an ordinary header, so nothing is prefixed. + (_LEGACY_INIT_ONLY, "#check Nat", "#check Nat"), ], ) -def test_disposable_call_does_not_displace_lean_header_directives( - monkeypatch, - deps_output, - code, -): +def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monkeypatch, deps_output, code, frame): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( allowed_imports=frozenset({"Mathlib", "REPL", "Init"}), @@ -1049,67 +1028,7 @@ def run_frame(code, env_id, timeout): monkeypatch.setattr(repl, "_run", run_frame) assert repl.run_disposable(code) == {"messages": [], "sorries": []} - assert frames == [code] - - -def test_disposable_call_preserves_warmup_for_ordinary_headers(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib"}), - warmup_imports=frozenset({"Mathlib"}), - header_deps_command=_fake_header_deps(_deps_json()), - ) - ) - frames = [] - monkeypatch.setattr(repl, "start", lambda *args, **kwargs: None) - - def run_frame(code, env_id, timeout): - frames.append(code) - return {"env": 1, "messages": [], "sorries": []} - - monkeypatch.setattr(repl, "_run", run_frame) - - assert repl.run_disposable("#check Nat") == { - "messages": [], - "sorries": [], - } - assert frames == ["import Mathlib\n#check Nat"] - - -def test_legacy_header_schema_fails_closed_on_warmup_composition(monkeypatch): - legacy_output = json.dumps( - { - "imports": [ - { - "errors": [], - "imports": [ - {"module": "Init", "importAll": False}, - ], - } - ] - } - ) - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib"}), - warmup_imports=frozenset({"Mathlib"}), - header_deps_command=_fake_header_deps(legacy_output), - ) - ) - frames = [] - monkeypatch.setattr(repl, "start", lambda *args, **kwargs: None) - - def run_frame(code, env_id, timeout): - frames.append(code) - return {"env": 1, "messages": [], "sorries": []} - - monkeypatch.setattr(repl, "_run", run_frame) - - assert repl.run_disposable("#check Nat") == { - "messages": [], - "sorries": [], - } - assert frames == ["#check Nat"] + assert frames == [frame] def test_disposable_call_rejects_disallowed_warmup_import(monkeypatch): From 7ae93700aaf8e796a586b4b24ac341159d8f8aa5 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 20:03:24 -0400 Subject: [PATCH 018/109] Withhold a dirty REPL slot instead of quarantining the pool Cleanup failure no longer stops admission; the dirty slot is withheld until clean. Quarantine never bounded processes: ProjectResourceCache replaces a stale pool only after its active calls settle, which is after the dirty worker is clean. Its only effect was to fail queued calls and drop healthy slots. _settle keeps the slot out of the idle queue until is_clean(), and run() returns a slot only when it is clean, so an interrupted retry loses the slot rather than handing it out. Residual: if cleanup never succeeds, that slot is lost and its callers time out. --- servers/lean_runtime.py | 1 - servers/repl/pool.py | 14 +-- tests/test_repl_pool_lifecycle.py | 164 +++++++++++++++++++----------- tests/test_shared_lean_runtime.py | 91 ----------------- 4 files changed, 108 insertions(+), 162 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index e06652f2e..5d6c359ee 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -666,7 +666,6 @@ def default_lsp_factory(project_dir: Path) -> LeanLspSession: lambda pool: pool.shutdown(), max_entries=self.config.repl_project_limit, idle_seconds=self.config.idle_seconds, - is_valid=lambda pool: pool.is_usable(), start_sweeper=start_sweepers, ) self.lsp_projects = ProjectResourceCache( diff --git a/servers/repl/pool.py b/servers/repl/pool.py index f2189f309..460e092e8 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -75,20 +75,16 @@ def _close_workers(self) -> None: except queue.Empty: break - def is_usable(self) -> bool: - """Return whether this pool may admit another public call.""" - return not self._shutdown - def _settle(self, worker: LeanRepl) -> BaseException | None: - """Stop admission and retry cleanup until the worker owns no process. + """Retry cleanup until the worker owns no process. - Returns the first cancellation-like cleanup error, for the caller to - raise once ownership is settled. + Until then the slot stays out of the idle queue, so no other call can + receive it. Returns the first cancellation-like cleanup error, for the + caller to raise once ownership is settled. """ cancelled: BaseException | None = None delay = _CLEANUP_RETRY_INITIAL_SECONDS while not worker.is_clean(): - self._shutdown = True try: worker.close() except BaseException as error: @@ -133,7 +129,7 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: return result finally: with self._condition: - if repl is not None and not self._shutdown: + if repl is not None and not self._shutdown and repl.is_clean(): self._idle.put(repl) self._active_calls -= 1 self._condition.notify_all() diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 06b701677..341afb5d8 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -141,108 +141,150 @@ def get_memory_usage(self): assert pool.run("#check Nat", timeout=1) == {"messages": []} assert workers[0].close_calls == 2 assert workers[0].is_clean() - assert not pool.is_usable() - with pytest.raises(RuntimeError, match="pool is shut down"): - pool.run("#check Int", timeout=1) - - assert pool._idle.qsize() == 1 + assert pool._idle.qsize() == 2 pool.shutdown() -def test_pool_stops_new_admission_while_cleanup_retry_owns_the_slot(monkeypatch): +def test_pool_withholds_a_dirty_slot_until_a_cleanup_retry_succeeds(monkeypatch): workers = [] cleanup_started = threading.Event() allow_cleanup = threading.Event() class FakeRepl: def __init__(self, config): - self.number = len(workers) self.dirty = False - self.calls = 0 + self.close_calls = 0 + self.runs = [] workers.append(self) def run_disposable(self, code, **kwargs): - self.calls += 1 - self.dirty = True - raise repl_core.ReplCleanupError("cleanup failed", {"messages": []}) + self.runs.append((code, self.is_clean())) + if len(self.runs) == 1: + self.dirty = True + raise repl_core.ReplCleanupError("cleanup failed", {"messages": ["first"]}) + return {"messages": ["second"]} def is_clean(self): return not self.dirty def close(self): if self.dirty: - cleanup_started.set() - assert allow_cleanup.wait(timeout=2) + self.close_calls += 1 + if self.close_calls == 1: + cleanup_started.set() + assert allow_cleanup.wait(timeout=2) + raise RuntimeError("transient cleanup failure") self.dirty = False def get_memory_usage(self): return 0.0 monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=2)) - results = [] - errors = [] + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + outcomes = {} - def run_first(): + def call(name, code): try: - results.append(pool.run("#check Nat", timeout=1)) + outcomes[name] = pool.run(code, timeout=5) except BaseException as error: - errors.append(error) + outcomes[name] = error - first = threading.Thread(target=run_first) + first = threading.Thread(target=call, args=("first", "#check Nat")) first.start() - assert cleanup_started.wait(timeout=1) - assert pool._active_calls == 1 - assert not pool.is_usable() + try: + assert cleanup_started.wait(timeout=2) + second = threading.Thread(target=call, args=("second", "#check Int")) + second.start() + second.join(timeout=0.2) + assert len(workers[0].runs) == 1, "a dirty slot was handed to a queued call" + finally: + allow_cleanup.set() + first.join(timeout=2) + second.join(timeout=2) - second_errors = [] + assert outcomes == {"first": {"messages": ["first"]}, "second": {"messages": ["second"]}} + assert len(workers) == 1 + assert workers[0].runs == [("#check Nat", True), ("#check Int", True)] + assert workers[0].close_calls == 2 + assert pool._active_calls == 0 + pool.shutdown() - def run_second(): - try: - pool.run("#check Int", timeout=1) - except BaseException as error: - second_errors.append(error) - second = threading.Thread(target=run_second) - second.start() - second.join(timeout=1) - second_blocked = second.is_alive() +def test_pool_withholds_a_slot_whose_cleanup_retry_was_interrupted(monkeypatch): + class FakeRepl: + def __init__(self, config): + pass - shutdown_errors = [] + def run_disposable(self, code, **kwargs): + raise repl_core.ReplCleanupError("cleanup failed", {"messages": []}) - def shut_down(): - try: - pool.shutdown() - except BaseException as error: - shutdown_errors.append(error) + def is_clean(self): + return False - stopper = threading.Thread(target=shut_down) - stopper.start() - stopper.join(timeout=0.1) - shutdown_returned_early = not stopper.is_alive() + def close(self): + raise RuntimeError("cleanup failed") - allow_cleanup.set() - first.join(timeout=2) - second.join(timeout=2) - stopper.join(timeout=2) + def get_memory_usage(self): + return 0.0 + + def interrupt(delay): + raise KeyboardInterrupt("retry interrupted") + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + monkeypatch.setattr(repl_pool.time, "sleep", interrupt) + + with pytest.raises(KeyboardInterrupt, match="retry interrupted"): + pool.run("#check Nat", timeout=1) + + assert pool._idle.empty() + assert pool._active_calls == 0 + + +def test_shutdown_waits_for_an_active_cleanup_retry(monkeypatch): + cleanup_started = threading.Event() + allow_cleanup = threading.Event() + + class FakeRepl: + def __init__(self, config): + self.dirty = False + + def run_disposable(self, code, **kwargs): + self.dirty = True + raise repl_core.ReplCleanupError("cleanup failed", {"messages": []}) + + def is_clean(self): + return not self.dirty + + def close(self): + if self.dirty: + cleanup_started.set() + assert allow_cleanup.wait(timeout=2) + self.dirty = False + + def get_memory_usage(self): + return 0.0 + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + results = [] + first = threading.Thread(target=lambda: results.append(pool.run("#check Nat", timeout=1))) + first.start() + stopper = threading.Thread(target=pool.shutdown) try: - assert not second_blocked, "cleanup retry held the pool condition" - assert not shutdown_returned_early, "shutdown ignored the active cleanup" - assert not first.is_alive() - assert not second.is_alive() - assert not stopper.is_alive() - assert errors == [] - assert shutdown_errors == [] - assert results == [{"messages": []}] - assert len(second_errors) == 1 - assert "pool is shut down" in str(second_errors[0]) - assert workers[1].calls == 0 - assert pool._active_calls == 0 + assert cleanup_started.wait(timeout=1) + stopper.start() + stopper.join(timeout=0.1) + assert stopper.is_alive(), "shutdown ignored the active cleanup" finally: allow_cleanup.set() + first.join(timeout=2) if stopper.is_alive(): stopper.join(timeout=2) + assert results == [{"messages": []}] + assert not stopper.is_alive() + def test_pool_preserves_cleanup_cancellation_after_verified_retry(monkeypatch): workers = [] @@ -279,7 +321,7 @@ def get_memory_usage(self): assert workers[0].close_calls == 2 assert workers[0].is_clean() assert pool._active_calls == 0 - assert not pool.is_usable() + assert pool._idle.qsize() == 1 pool.shutdown() @@ -319,7 +361,7 @@ def get_memory_usage(self): assert workers[0].close_calls == 2 assert workers[0].is_clean() assert pool._active_calls == 0 - assert not pool.is_usable() + assert pool._idle.qsize() == 1 pool.shutdown() diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 49a3339fe..38ad8fccc 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -68,9 +68,6 @@ def run(self, code, **kwargs): def get_memory_usage(self): return 0.25 - def is_usable(self): - return not self._shutdown - def shutdown(self): self._shutdown = True @@ -161,94 +158,6 @@ def __init__(self, config): services.close() -def test_quarantined_repl_pool_is_replaced_only_after_active_cleanup(tmp_path): - project = make_lake_project(tmp_path, "cleanup-before-replacement") - cleanup_started = threading.Event() - allow_cleanup = threading.Event() - second_started = threading.Event() - second_finished = threading.Event() - pools = [] - - class RetiringPool(FakePool): - def __init__(self, root): - super().__init__(root) - self.number = len(pools) + 1 - self.cleanup_complete = False - self.shutdown_calls = 0 - pools.append(self) - - def run(self, code, **kwargs): - self.calls.append((code, kwargs)) - if self.number == 1: - self._shutdown = True - cleanup_started.set() - assert allow_cleanup.wait(timeout=2) - self.cleanup_complete = True - return {"messages": []} - - def shutdown(self): - if self.number == 1: - assert self.cleanup_complete - self.shutdown_calls += 1 - self._shutdown = True - - services = LeanRuntimeServices( - runtime_config(), - repl_factory=RetiringPool, - lsp_factory=FakeLsp, - start_sweepers=False, - ) - results = [] - errors = [] - - def dispatch(code, *, started=None, finished=None): - if started is not None: - started.set() - try: - results.append( - services.dispatch( - "repl.run", - {"project_dir": str(project), "code": code, "timeout": 1}, - ) - ) - except BaseException as error: - errors.append(error) - finally: - if finished is not None: - finished.set() - - first = threading.Thread(target=dispatch, args=("#check Nat",)) - first.start() - assert cleanup_started.wait(timeout=1) - - second = threading.Thread( - target=dispatch, - args=("#check Int",), - kwargs={"started": second_started, "finished": second_finished}, - ) - second.start() - assert second_started.wait(timeout=1) - assert not second_finished.wait(timeout=0.1) - assert len(pools) == 1 - assert pools[0].shutdown_calls == 0 - - allow_cleanup.set() - first.join(timeout=2) - second.join(timeout=2) - try: - assert not first.is_alive() - assert not second.is_alive() - assert errors == [] - assert results == ["Compiles successfully", "Compiles successfully"] - assert len(pools) == 2 - assert pools[0].cleanup_complete - assert pools[0].shutdown_calls == 1 - assert pools[1].calls == [("#check Int", {"timeout": 1.0})] - finally: - allow_cleanup.set() - services.close() - - def test_lru_limit_never_evicts_an_active_project(tmp_path): first = make_lake_project(tmp_path, "first") second = make_lake_project(tmp_path, "second") From fcfcabbaed4fb8340547bd4e9c9decb2ebde9d24 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 20:24:36 -0400 Subject: [PATCH 019/109] Share the header cleanup fault injector and compact REPL tests --- tests/test_real_repl.py | 47 +++------------- tests/test_repl_core_protocol.py | 95 +++++++++----------------------- 2 files changed, 34 insertions(+), 108 deletions(-) diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 8fb2a38f5..599819cec 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -20,18 +20,9 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): - repl = LeanRepl( - LeanReplConfig( - cwd=str(REPL_FIXTURE), - warmup_imports=frozenset(), - validate_imports=False, - ) - ) + repl = LeanRepl(LeanReplConfig(cwd=str(REPL_FIXTURE), warmup_imports=frozenset(), validate_imports=False)) - response = repl.run_disposable( - "theorem autoform_repl_probe : True := by sorry", - timeout=180, - ) + response = repl.run_disposable("theorem autoform_repl_probe : True := by sorry", timeout=180) assert response.get("sorries") assert "env" not in response @@ -49,11 +40,7 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): ((), "import «REPL\n", "Rejected Lean header"), ((), "import REPL.Frontend\n#check Nat", None), (("Mathlib",), "module\npublic import REPL.Frontend\n", None), - ( - ("Mathlib",), - "prelude\nimport REPL.Frontend\n#check Nat", - None, - ), + (("Mathlib",), "prelude\nimport REPL.Frontend\n#check Nat", None), ], ) def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): @@ -69,10 +56,7 @@ def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): if expected_error is None: assert "repl_error" not in response - assert not any( - message["severity"] == "error" - for message in response.get("messages", []) - ) + assert not any(message["severity"] == "error" for message in response.get("messages", [])) else: assert expected_error in response["repl_error"] assert repl.is_clean() @@ -81,26 +65,11 @@ def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): def test_runtime_calls_do_not_share_lean_state(runtime_dir, monkeypatch): monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "1") monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "1") - client = LeanRuntimeClient( - socket_path=runtime_dir / "real-repl.sock", - response_timeout=300, - startup_timeout=30, - ) - declaration = ( - "theorem autoform_isolation_probe (P : Prop) (h : P) : P := h" - ) + client = LeanRuntimeClient(socket_path=runtime_dir / "real-repl.sock", response_timeout=300, startup_timeout=30) + declaration = "theorem autoform_isolation_probe (P : Prop) (h : P) : P := h" + params = {"project_dir": str(REPL_FIXTURE), "code": declaration, "timeout": 180} try: - responses = [ - client.request( - "repl.run", - { - "project_dir": str(REPL_FIXTURE), - "code": declaration, - "timeout": 180, - }, - ) - for _ in range(2) - ] + responses = [client.request("repl.run", params) for _ in range(2)] finally: with suppress(LeanRuntimeUnavailable): client.stop() diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index fd1c03cbf..c654b1572 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -856,11 +856,10 @@ def item(module: str, is_meta: bool = False) -> dict: return json.dumps({"imports": [entry]}) -def _header_modules(command: list[str], *, deadline: float | None = None, **config) -> list[str]: +def _header_modules(command: list[str], *, timeout: float = 10, **config) -> list[str]: repl = repl_core.LeanRepl(repl_core.LeanReplConfig(header_deps_command=command, **config)) try: - deadline = time.monotonic() + 10 if deadline is None else deadline - return list(repl._check_header("import Mathlib", deadline).modules) + return list(repl._check_header("import Mathlib", time.monotonic() + timeout).modules) finally: repl.close() @@ -1055,20 +1054,14 @@ def test_header_check_kills_a_command_that_outlives_the_deadline(): started = time.monotonic() with pytest.raises(TimeoutError): - _header_modules( - [sys.executable, "-c", "import time; time.sleep(30)"], - deadline=started + 0.2, - ) + _header_modules([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.2) assert time.monotonic() - started < 5 def test_header_check_rejects_output_over_the_combined_limit(): with pytest.raises(ValueError, match="output exceeded 1024 bytes"): - _header_modules( - _fake_header_deps("x" * 1025), - max_buffer_bytes=1024, - ) + _header_modules(_fake_header_deps("x" * 1025), max_buffer_bytes=1024) def test_header_check_reaps_descendants_after_success(tmp_path): @@ -1089,101 +1082,65 @@ def test_header_check_reaps_descendants_after_success(tmp_path): assert not repl_core._process_group_has_live_members(process_ids["group"]) -def test_disposable_call_retries_header_cleanup_before_reuse(monkeypatch): +def _repl_failing_first_header_cleanup(monkeypatch, error: BaseException): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - warmup_imports=frozenset(), - header_deps_command=_fake_header_deps(_deps_json("Mathlib")), + warmup_imports=frozenset(), header_deps_command=_fake_header_deps(_deps_json("Mathlib")) ) ) real_kill = repl_core._kill_subprocesses - cleanup_calls = 0 + cleanup_calls = [] def fail_once(process, process_group_id, deadline=None): - nonlocal cleanup_calls - cleanup_calls += 1 - if cleanup_calls == 1: - raise RuntimeError("injected cleanup failure") + cleanup_calls.append(process_group_id) + if len(cleanup_calls) == 1: + raise error return real_kill(process, process_group_id, deadline) monkeypatch.setattr(repl_core, "_kill_subprocesses", fail_once) - monkeypatch.setattr( - repl, - "start", - lambda *args, **kwargs: pytest.fail("unclean header must not start Lean"), - ) + return repl, cleanup_calls + + +def test_disposable_call_retries_header_cleanup_before_reuse(monkeypatch): + repl, cleanup_calls = _repl_failing_first_header_cleanup(monkeypatch, RuntimeError("injected cleanup failure")) + monkeypatch.setattr(repl, "start", lambda *args, **kwargs: pytest.fail("unclean header must not start Lean")) response = repl.run_disposable("import Mathlib\n#check Nat") assert "injected cleanup failure" in response["repl_error"] - assert cleanup_calls == 2 + assert len(cleanup_calls) == 2 assert repl.is_clean() def test_disposable_call_preserves_header_cleanup_cancellation(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - header_deps_command=_fake_header_deps(_deps_json("Mathlib")), - ) + repl, cleanup_calls = _repl_failing_first_header_cleanup( + monkeypatch, asyncio.CancelledError("cancel header cleanup") ) - real_kill = repl_core._kill_subprocesses - cleanup_calls = 0 - - def cancel_once(process, process_group_id, deadline=None): - nonlocal cleanup_calls - cleanup_calls += 1 - if cleanup_calls == 1: - raise asyncio.CancelledError("cancel header cleanup") - return real_kill(process, process_group_id, deadline) - - monkeypatch.setattr(repl_core, "_kill_subprocesses", cancel_once) with pytest.raises(asyncio.CancelledError, match="cancel header cleanup"): repl.run_disposable("import Mathlib\n#check Nat") - assert cleanup_calls == 2 + assert len(cleanup_calls) == 2 assert repl.is_clean() def test_disposable_call_preserves_header_request_cancellation(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - header_deps_command=[sys.executable, "-c", "import time; time.sleep(30)"], - ) - ) - real_kill = repl_core._kill_subprocesses - cleanup_calls = 0 - - monkeypatch.setattr( - repl_core, - "_communicate_bounded", - lambda *args, **kwargs: (_ for _ in ()).throw( - KeyboardInterrupt("cancel header request") - ), - ) + repl, cleanup_calls = _repl_failing_first_header_cleanup(monkeypatch, RuntimeError("header cleanup failed")) - def fail_once(process, process_group_id, deadline=None): - nonlocal cleanup_calls - cleanup_calls += 1 - if cleanup_calls == 1: - raise RuntimeError("header cleanup failed") - return real_kill(process, process_group_id, deadline) + def cancel(*args, **kwargs): + raise KeyboardInterrupt("cancel header request") - monkeypatch.setattr(repl_core, "_kill_subprocesses", fail_once) + monkeypatch.setattr(repl_core, "_communicate_bounded", cancel) with pytest.raises(KeyboardInterrupt, match="cancel header request") as raised: repl.run_disposable("import Mathlib\n#check Nat") if hasattr(raised.value, "add_note"): - assert raised.value.__notes__ == [ - "Lean REPL process cleanup also failed: header cleanup failed" - ] + assert raised.value.__notes__ == ["Lean REPL process cleanup also failed: header cleanup failed"] # The slot keeps the parser until a later close() verifies that it exited. assert not repl.is_clean() repl.close() - assert cleanup_calls == 2 + assert len(cleanup_calls) == 2 assert repl.is_clean() From 20cbba494323d4be80ba7bcc0ad3ab0a6f9f5c6f Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 5 Oct 2026 20:49:26 -0400 Subject: [PATCH 020/109] Document the real REPL CI job --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 275d6c8d8..86e9f510d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,9 @@ CI (`.github/workflows/tests.yml`) runs the same four steps on Python 3.10 and 3.13 for every push and pull request. A separate job runs `tests/test_skeleton.py` and one `tests/test_project_inspect.py` test against a real Lean toolchain; another runs the inspection, bounded-subprocess, and -transactional-output tests on Windows. Locally, tests that need Lean skip when +transactional-output tests on Windows; a third builds `tests/fixtures/repl-smoke` +and runs `tests/test_real_repl.py` against the pinned upstream REPL. That module +skips unless `AUTOFORM_RUN_REAL_REPL_TESTS=1`. Locally, tests that need Lean skip when `lake` is not on `PATH`. The `tests/test_skeleton.py` ones also skip when the toolchain pinned in `tests/fixtures/skeleton-project/lean-toolchain` is missing; the others let elan download it. Run `lake build` in From 3db313a24728f722dae120e9496a59f7bf0636c9 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 6 Oct 2026 15:44:09 -0400 Subject: [PATCH 021/109] Constrain the legacy header parser guard --- servers/README.md | 6 ++- servers/repl/core.py | 74 +++++++++++++++++++++++++++----- tests/test_real_repl.py | 3 ++ tests/test_repl_core_protocol.py | 12 ++++++ 4 files changed, 82 insertions(+), 13 deletions(-) diff --git a/servers/README.md b/servers/README.md index 1f8a6cc82..c9ae2b88a 100644 --- a/servers/README.md +++ b/servers/README.md @@ -48,6 +48,8 @@ process to start the runtime supplies those settings until it is stopped. `LEAN_REPL_CMD` selects the disposable child command. An absolute `lake` executable is reused for header validation; a container or other wrapper must also set `LEAN_REPL_HEADER_CMD` to the matching toolchain-owned parser command. -`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, retained cleanup, -the tool operation, and a safety margin. A `warm` REPL project means its cold +`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, the tool +operation, and a safety margin. Failed cleanup transfers to a retained +background reaper and may continue past that response budget rather than +allowing an overlapping replacement. A `warm` REPL project means its cold wrapper pool is cached; it does not mean a REPL subprocess remains resident. diff --git a/servers/repl/core.py b/servers/repl/core.py index f5ff6f1bf..da489a7e1 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -516,17 +516,69 @@ def _reject_legacy_deps_json_comment_bypass(code: str) -> None: reconstructed safely in Python. """ - dashes = 0 - for character in code: - if character == "-": - dashes += 1 - continue - if character == "/" and dashes > 0 and dashes % 2 == 0: - raise ValueError( - "Lean header contains a block-comment close spelling that " - "lean --deps-json cannot validate safely" - ) - dashes = 0 + block_depth = 0 + header_prefixes = ("import ", "public import ", "private import ", "meta import ") + + for line in code.splitlines(): + stripped = line.lstrip() + if block_depth == 0: + if not stripped or stripped.startswith("--"): + continue + if not ( + stripped.startswith("/-") + or stripped == "prelude" + or stripped == "module" + or stripped.startswith("module ") + or stripped.startswith(header_prefixes) + ): + # The fast parser stops at the first body command. A block + # comment after that point cannot hide a header import. + break + + index = 0 + while index < len(line): + if block_depth == 0: + if line.startswith("--", index): + break + if line.startswith("/-", index): + block_depth = 1 + index += 2 + continue + if line[index] == '"': + index += 1 + while index < len(line): + if line[index] == "\\": + index += 2 + elif line[index] == '"': + index += 1 + break + else: + index += 1 + continue + index += 1 + continue + + if line.startswith("/-", index): + block_depth += 1 + index += 2 + continue + if line[index] == "-": + end = index + while end < len(line) and line[end] == "-": + end += 1 + if end < len(line) and line[end] == "/": + dash_count = end - index + if dash_count % 2 == 0: + raise ValueError( + "Lean header contains a block-comment close spelling that " + "lean --deps-json cannot validate safely" + ) + block_depth -= 1 + index = end + 1 + continue + index = end + continue + index += 1 def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index d86daff95..cf234befd 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -45,6 +45,9 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): "cannot validate safely", ), ((), "import REPL.Frontend\n#check Nat", None), + ((), '#eval "--/"', None), + ((), "--/ harmless line comment\n#check Nat", None), + ((), "#check Nat\n/- body --/", None), (("Mathlib",), "#check autoformWarmupMarker", None), (("Mathlib",), "module\npublic import REPL.Frontend\n", None), (("Mathlib",), "prelude\nimport REPL.Frontend\n#check Nat", None), diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index d66657e2e..75a4bf946 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1229,6 +1229,18 @@ def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass(monkeypatch assert repl.is_clean() +@pytest.mark.parametrize( + "code", + [ + '#eval "--/"', + "--/ harmless line comment\n#check Nat", + "#check Nat\n/- body --/", + ], +) +def test_legacy_deps_json_guard_ignores_non_header_context(code): + repl_core._reject_legacy_deps_json_comment_bypass(code) + + def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeypatch): repl = repl_core.LeanRepl(repl_core.LeanReplConfig()) monkeypatch.setattr(repl_core.os, "name", "nt") From bbd67cc5a5574ebe7fc9a4d36b1f7bea3bf6bebc Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 6 Oct 2026 15:49:40 -0400 Subject: [PATCH 022/109] Compare legacy header parser interpretations --- servers/repl/core.py | 118 ++++++++++++++----------------- tests/test_real_repl.py | 9 +++ tests/test_repl_core_protocol.py | 21 ++++-- 3 files changed, 81 insertions(+), 67 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index da489a7e1..d30719c7b 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -506,79 +506,64 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: raise ValueError("unrecognized output from lean --deps-json") from None -def _reject_legacy_deps_json_comment_bypass(code: str) -> None: - """Refuse close spellings misparsed by Lean 4.30--4.32 deps-json. +def _normalize_legacy_deps_json_comment_closes(code: str) -> str: + """Normalize only block-comment closes misparsed by Lean 4.30--4.32. Those releases skip one character too many when an even run of dashes - precedes ``/`` inside a block comment, so an import the real parser sees - can disappear from the fast parser's result. The byte pattern is rare; - failing closed also avoids pretending that quoted/comment context can be - reconstructed safely in Python. + precedes ``/`` inside a block comment. Removing one dash gives their fast + parser the same close point as the real parser. Callers compare Lean's + dependency facts for both byte strings and fail closed if they differ, + avoiding any Python reimplementation of Lean's header grammar. """ block_depth = 0 - header_prefixes = ("import ", "public import ", "private import ", "meta import ") - - for line in code.splitlines(): - stripped = line.lstrip() + index = 0 + remove: set[int] = set() + while index < len(code): if block_depth == 0: - if not stripped or stripped.startswith("--"): + if code.startswith("--", index): + newline = code.find("\n", index + 2) + index = len(code) if newline < 0 else newline + 1 continue - if not ( - stripped.startswith("/-") - or stripped == "prelude" - or stripped == "module" - or stripped.startswith("module ") - or stripped.startswith(header_prefixes) - ): - # The fast parser stops at the first body command. A block - # comment after that point cannot hide a header import. - break - - index = 0 - while index < len(line): - if block_depth == 0: - if line.startswith("--", index): - break - if line.startswith("/-", index): - block_depth = 1 - index += 2 - continue - if line[index] == '"': - index += 1 - while index < len(line): - if line[index] == "\\": - index += 2 - elif line[index] == '"': - index += 1 - break - else: - index += 1 - continue - index += 1 - continue - - if line.startswith("/-", index): - block_depth += 1 + if code.startswith("/-", index): + block_depth = 1 index += 2 continue - if line[index] == "-": - end = index - while end < len(line) and line[end] == "-": - end += 1 - if end < len(line) and line[end] == "/": - dash_count = end - index - if dash_count % 2 == 0: - raise ValueError( - "Lean header contains a block-comment close spelling that " - "lean --deps-json cannot validate safely" - ) - block_depth -= 1 - index = end + 1 - continue - index = end + if code[index] == '"': + index += 1 + while index < len(code): + if code[index] == "\\": + index += 2 + elif code[index] == '"': + index += 1 + break + else: + index += 1 continue index += 1 + continue + + if code.startswith("/-", index): + block_depth += 1 + index += 2 + continue + if code[index] == "-": + end = index + while end < len(code) and code[end] == "-": + end += 1 + if end < len(code) and code[end] == "/": + if (end - index) % 2 == 0: + remove.add(index) + block_depth -= 1 + index = end + 1 + continue + index = end + continue + index += 1 + + if not remove: + return code + return "".join(character for position, character in enumerate(code) if position not in remove) def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: @@ -1086,8 +1071,15 @@ def remaining(phase: str) -> float: and self._allowed_import_roots is not None ): try: - _reject_legacy_deps_json_comment_bypass(code) header = self._check_header(code, deadline) + normalized = _normalize_legacy_deps_json_comment_closes(code) + if normalized != code: + normalized_header = self._check_header(normalized, deadline) + if normalized_header != header: + raise ValueError( + "Lean header contains a block-comment close " + "spelling that lean --deps-json cannot validate safely" + ) except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} else: diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index cf234befd..967da84a0 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -44,6 +44,15 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): "/- a --/\nimport REPL.Frontend\n-- -/\n#check Nat", "cannot validate safely", ), + ( + (), + "prelude -- ordinary comment\n" + "import Init /- a --/\n" + "import Lean\n" + "-- -/\n" + "#check Lean.Name", + "cannot validate safely", + ), ((), "import REPL.Frontend\n#check Nat", None), ((), '#eval "--/"', None), ((), "--/ harmless line comment\n#check Nat", None), diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 75a4bf946..b6393855c 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1210,11 +1210,16 @@ def check_header(code, deadline): def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib"}), + allowed_imports=frozenset({"Init"}), warmup_imports=frozenset(), - header_deps_command=_fake_header_deps(_deps_json()), ) ) + + def check_header(code, deadline): + modules = ("Init",) if "--/" in code else ("Init", "Lean") + return repl_core._LeanHeaderAnalysis(modules, True) + + monkeypatch.setattr(repl, "_check_header", check_header) monkeypatch.setattr( repl, "start", @@ -1222,7 +1227,11 @@ def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass(monkeypatch ) response = repl.run_disposable( - "/- a --/\nimport Unsafe\n-- -/\n#check Nat", + "prelude -- ordinary comment\n" + "import Init /- a --/\n" + "import Lean\n" + "-- -/\n" + "#check Lean.Name", ) assert "lean --deps-json cannot validate safely" in response["repl_error"] @@ -1238,7 +1247,11 @@ def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass(monkeypatch ], ) def test_legacy_deps_json_guard_ignores_non_header_context(code): - repl_core._reject_legacy_deps_json_comment_bypass(code) + normalized = repl_core._normalize_legacy_deps_json_comment_closes(code) + if code.startswith("#check Nat"): + assert normalized != code + else: + assert normalized == code def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeypatch): From 6ec70b0ac162af01f0e77048cf3ebad2897f9fb2 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 6 Oct 2026 15:54:13 -0400 Subject: [PATCH 023/109] Compare all suspect legacy header closes --- servers/repl/core.py | 57 +++++--------------- tests/fixtures/repl-smoke/Mathlib/Foo--.lean | 1 + tests/test_real_repl.py | 8 +++ tests/test_repl_core_protocol.py | 37 ++++++++----- 4 files changed, 46 insertions(+), 57 deletions(-) create mode 100644 tests/fixtures/repl-smoke/Mathlib/Foo--.lean diff --git a/servers/repl/core.py b/servers/repl/core.py index d30719c7b..0229927e9 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -507,59 +507,28 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: def _normalize_legacy_deps_json_comment_closes(code: str) -> str: - """Normalize only block-comment closes misparsed by Lean 4.30--4.32. + """Normalize suspect closes for a differential Lean 4.30--4.32 parse. Those releases skip one character too many when an even run of dashes - precedes ``/`` inside a block comment. Removing one dash gives their fast - parser the same close point as the real parser. Callers compare Lean's - dependency facts for both byte strings and fail closed if they differ, - avoiding any Python reimplementation of Lean's header grammar. + precedes ``/`` inside a block comment. Removing one dash globally is + intentionally context-free: callers compare Lean's authoritative header + facts for both byte strings and reject only when they differ. Strings, + line comments, body text, and quoted identifiers therefore need no Python + lexer or duplicated Lean grammar. """ - block_depth = 0 index = 0 remove: set[int] = set() while index < len(code): - if block_depth == 0: - if code.startswith("--", index): - newline = code.find("\n", index + 2) - index = len(code) if newline < 0 else newline + 1 - continue - if code.startswith("/-", index): - block_depth = 1 - index += 2 - continue - if code[index] == '"': - index += 1 - while index < len(code): - if code[index] == "\\": - index += 2 - elif code[index] == '"': - index += 1 - break - else: - index += 1 - continue + if code[index] != "-": index += 1 continue - - if code.startswith("/-", index): - block_depth += 1 - index += 2 - continue - if code[index] == "-": - end = index - while end < len(code) and code[end] == "-": - end += 1 - if end < len(code) and code[end] == "/": - if (end - index) % 2 == 0: - remove.add(index) - block_depth -= 1 - index = end + 1 - continue - index = end - continue - index += 1 + end = index + while end < len(code) and code[end] == "-": + end += 1 + if end < len(code) and code[end] == "/" and (end - index) % 2 == 0: + remove.add(index) + index = end + 1 if end < len(code) and code[end] == "/" else end if not remove: return code diff --git a/tests/fixtures/repl-smoke/Mathlib/Foo--.lean b/tests/fixtures/repl-smoke/Mathlib/Foo--.lean new file mode 100644 index 000000000..a75721fee --- /dev/null +++ b/tests/fixtures/repl-smoke/Mathlib/Foo--.lean @@ -0,0 +1 @@ +/- Fixture module whose quoted component contains line-comment punctuation. -/ diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 967da84a0..8b8e97568 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -53,6 +53,14 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): "#check Lean.Name", "cannot validate safely", ), + ( + (), + "import Mathlib.«Foo--» /- a --/\n" + "import Lean\n" + "-- -/\n" + "#check Lean.Name", + "cannot validate safely", + ), ((), "import REPL.Frontend\n#check Nat", None), ((), '#eval "--/"', None), ((), "--/ harmless line comment\n#check Nat", None), diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index b6393855c..18d3f4433 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1207,7 +1207,27 @@ def check_header(code, deadline): assert checked == ["/- note -/ import Unsafe\n#check Nat"] -def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass(monkeypatch): +@pytest.mark.parametrize( + "code", + [ + ( + "prelude -- ordinary comment\n" + "import Init /- a --/\n" + "import Lean\n" + "-- -/\n" + "#check Lean.Name" + ), + ( + "import Mathlib.«Foo--» /- a --/\n" + "import Lean\n" + "-- -/\n" + "#check Lean.Name" + ), + ], +) +def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass( + monkeypatch, code +): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( allowed_imports=frozenset({"Init"}), @@ -1226,13 +1246,7 @@ def check_header(code, deadline): lambda *args, **kwargs: pytest.fail("ambiguous header must not start Lean"), ) - response = repl.run_disposable( - "prelude -- ordinary comment\n" - "import Init /- a --/\n" - "import Lean\n" - "-- -/\n" - "#check Lean.Name", - ) + response = repl.run_disposable(code) assert "lean --deps-json cannot validate safely" in response["repl_error"] assert repl.is_clean() @@ -1246,12 +1260,9 @@ def check_header(code, deadline): "#check Nat\n/- body --/", ], ) -def test_legacy_deps_json_guard_ignores_non_header_context(code): +def test_legacy_deps_json_normalizer_does_not_decide_lexical_context(code): normalized = repl_core._normalize_legacy_deps_json_comment_closes(code) - if code.startswith("#check Nat"): - assert normalized != code - else: - assert normalized == code + assert normalized != code def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeypatch): From 378fa777a842b102632b618072d0e3f3ca31a1d4 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 6 Oct 2026 15:57:53 -0400 Subject: [PATCH 024/109] Reject ambiguous import module components --- .github/workflows/tests.yml | 2 +- servers/README.md | 4 +++- servers/repl/core.py | 11 +++++++++-- tests/fixtures/repl-smoke/Mathlib/Foo--.lean | 1 - tests/fixtures/repl-smoke/Secret.lean | 1 + tests/fixtures/repl-smoke/lakefile.toml | 3 +++ tests/test_real_repl.py | 7 ++----- tests/test_repl_core_protocol.py | 18 +++++++----------- tests/test_skill_examples.py | 1 + 9 files changed, 27 insertions(+), 21 deletions(-) delete mode 100644 tests/fixtures/repl-smoke/Mathlib/Foo--.lean create mode 100644 tests/fixtures/repl-smoke/Secret.lean diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e7a826f38..75d4bdd82 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -97,7 +97,7 @@ jobs: with: auto-config: false build: true - build-args: "Mathlib @repl/repl" + build-args: "Mathlib Secret @repl/repl" lake-package-directory: tests/fixtures/repl-smoke - run: uv sync --frozen --extra dev --extra repl - run: uv run pytest -q tests/test_real_repl.py diff --git a/servers/README.md b/servers/README.md index c9ae2b88a..0d4e3a737 100644 --- a/servers/README.md +++ b/servers/README.md @@ -15,7 +15,9 @@ project. LSP sessions stay warm; each public REPL call gets a fresh child and no process-owned environment or proof-state handle survives the response. Before that child starts, the selected Lake toolchain's `lean --deps-json` parser validates the submitted header and its complete import set; a rejected -or unrecognized parser response fails closed. Execution names the +or unrecognized parser response fails closed. Allowlisted imports require +plain, nonempty module components; quoted or path-like components are refused +instead of being reduced to a misleading allowlisted root. Execution names the package-qualified `@repl/repl` target so a project target cannot shadow it. Closing the session that started it does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an diff --git a/servers/repl/core.py b/servers/repl/core.py index 0229927e9..2742776c4 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -472,9 +472,16 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: module = item.get("module") if not isinstance(module, str) or not module: raise ValueError - if "/" in module or "\\" in module: + components = module.split(".") + if ( + "/" in module + or "\\" in module + or "«" in module + or "»" in module + or any(not component for component in components) + ): raise ValueError( - "Lean header contains an import module with a path separator" + "Lean header imports must use plain, nonempty module components" ) if has_result: is_meta = item.get("isMeta") diff --git a/tests/fixtures/repl-smoke/Mathlib/Foo--.lean b/tests/fixtures/repl-smoke/Mathlib/Foo--.lean deleted file mode 100644 index a75721fee..000000000 --- a/tests/fixtures/repl-smoke/Mathlib/Foo--.lean +++ /dev/null @@ -1 +0,0 @@ -/- Fixture module whose quoted component contains line-comment punctuation. -/ diff --git a/tests/fixtures/repl-smoke/Secret.lean b/tests/fixtures/repl-smoke/Secret.lean new file mode 100644 index 000000000..6d0e72a37 --- /dev/null +++ b/tests/fixtures/repl-smoke/Secret.lean @@ -0,0 +1 @@ +def escapedTraversalMarker : Unit := () diff --git a/tests/fixtures/repl-smoke/lakefile.toml b/tests/fixtures/repl-smoke/lakefile.toml index 037657c99..b35bb3f13 100644 --- a/tests/fixtures/repl-smoke/lakefile.toml +++ b/tests/fixtures/repl-smoke/lakefile.toml @@ -8,3 +8,6 @@ rev = "68a3b3a059787a7db44fb1e6281e4a657efee470" [[lean_lib]] name = "Mathlib" + +[[lean_lib]] +name = "Secret" diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 8b8e97568..cf0f13e2c 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -55,11 +55,8 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): ), ( (), - "import Mathlib.«Foo--» /- a --/\n" - "import Lean\n" - "-- -/\n" - "#check Lean.Name", - "cannot validate safely", + "import Mathlib.«..».Secret\n#check escapedTraversalMarker", + "plain, nonempty module components", ), ((), "import REPL.Frontend\n#check Nat", None), ((), '#eval "--/"', None), diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 18d3f4433..8d9ba0244 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1070,9 +1070,7 @@ def _header_modules(command: list[str], *, timeout: float = 10, **config) -> lis def test_header_check_accepts_current_and_legacy_lean_schemas(): - current = _fake_header_deps( - _deps_json("Init", "Mathlib.Tactic", "«Mathlib.X»") - ) + current = _fake_header_deps(_deps_json("Init", "Mathlib.Tactic")) legacy = _fake_header_deps( json.dumps( { @@ -1089,7 +1087,7 @@ def test_header_check_accepts_current_and_legacy_lean_schemas(): ) ) - assert _header_modules(current) == ["Mathlib.Tactic", "«Mathlib.X»"] + assert _header_modules(current) == ["Mathlib.Tactic"] assert _header_modules(legacy) == ["Mathlib.Tactic"] @@ -1133,7 +1131,11 @@ def write_parser(path, module): (_fake_header_deps(_deps_json(errors=("bad header",))), "bad header"), ( _fake_header_deps(_deps_json("Mathlib.«/abs/path/Secret»")), - "module with a path separator", + "plain, nonempty module components", + ), + ( + _fake_header_deps(_deps_json("Mathlib.«..».Secret")), + "plain, nonempty module components", ), (_fake_header_deps("", returncode=1, stderr="unknown package\n"), "unknown package"), (_fake_header_deps("not json"), "unrecognized output"), @@ -1217,12 +1219,6 @@ def check_header(code, deadline): "-- -/\n" "#check Lean.Name" ), - ( - "import Mathlib.«Foo--» /- a --/\n" - "import Lean\n" - "-- -/\n" - "#check Lean.Name" - ), ], ) def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass( diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 695ca2e72..b1c73ca46 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -175,6 +175,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain's `lean --deps-json`" in servers assert "unrecognized parser response fails closed" in servers + assert "quoted or path-like components are refused" in servers assert "package-qualified `@repl/repl` target" in servers assert "one total post-admission budget" in servers assert "default and maximum are 240 seconds" in servers From 8bfdf44de3e500faccc0ed24239f4b3357a0c4e5 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 6 Oct 2026 23:55:28 -0400 Subject: [PATCH 025/109] Bound legacy header compatibility checks --- servers/repl/core.py | 24 +++++++------------ tests/fixtures/repl-smoke/Mathlib.lean | 1 + .../repl-smoke/Mathlib/TraversalAnchor.lean | 1 + tests/test_real_repl.py | 21 ++++++++++++++++ tests/test_repl_core_protocol.py | 20 ++++++++++++++++ 5 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/repl-smoke/Mathlib/TraversalAnchor.lean diff --git a/servers/repl/core.py b/servers/repl/core.py index 2742776c4..f26e8c0b7 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -10,6 +10,7 @@ import json import os import random +import re import select import signal import subprocess @@ -35,6 +36,7 @@ _STDERR_TAIL_BYTES = 200 _PUBLIC_DIAGNOSTIC_FIELDS = frozenset({"severity", "data", "pos", "endPos"}) _PUBLIC_SORRY_FIELDS = frozenset({"goal", "pos", "endPos"}) +_LEGACY_DEPS_JSON_SUSPECT_CLOSE = re.compile(r"(? str: lexer or duplicated Lean grammar. """ - index = 0 - remove: set[int] = set() - while index < len(code): - if code[index] != "-": - index += 1 - continue - end = index - while end < len(code) and code[end] == "-": - end += 1 - if end < len(code) and code[end] == "/" and (end - index) % 2 == 0: - remove.add(index) - index = end + 1 if end < len(code) and code[end] == "/" else end - - if not remove: + if _LEGACY_DEPS_JSON_SUSPECT_CLOSE.search(code) is None: return code - return "".join(character for position, character in enumerate(code) if position not in remove) + return _LEGACY_DEPS_JSON_SUSPECT_CLOSE.sub( + lambda match: match.group(0)[1:], + code, + ) def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: @@ -987,6 +979,8 @@ def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: The parser is this wrapper's process generation until close() verifies that it exited, so a failed cleanup is retried before the slot is reused. """ + if deadline <= time.monotonic(): + raise TimeoutError("timed out checking the Lean header") env = _inherit_clean_env() env.update(self.config.env) self.process = subprocess.Popen( diff --git a/tests/fixtures/repl-smoke/Mathlib.lean b/tests/fixtures/repl-smoke/Mathlib.lean index c2325a0e3..5322ba6d5 100644 --- a/tests/fixtures/repl-smoke/Mathlib.lean +++ b/tests/fixtures/repl-smoke/Mathlib.lean @@ -1,4 +1,5 @@ import Init.System.IO +import Mathlib.TraversalAnchor /- A lightweight stand-in for the runtime's default warmup import. -/ diff --git a/tests/fixtures/repl-smoke/Mathlib/TraversalAnchor.lean b/tests/fixtures/repl-smoke/Mathlib/TraversalAnchor.lean new file mode 100644 index 000000000..50c7a45b0 --- /dev/null +++ b/tests/fixtures/repl-smoke/Mathlib/TraversalAnchor.lean @@ -0,0 +1 @@ +/- Ensure the built Mathlib artifact directory exists for traversal tests. -/ diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index cf0f13e2c..7b6c709f5 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -31,6 +31,27 @@ def test_disposable_call_matches_the_pinned_repl_protocol(): assert repl.is_clean() +def test_fixture_exposes_escaped_module_traversal_without_policy_validation(): + repl = LeanRepl( + LeanReplConfig( + cwd=str(REPL_FIXTURE), + warmup_imports=frozenset(), + validate_imports=False, + ) + ) + + response = repl.run_disposable( + "import Mathlib.«..».Secret\n#check escapedTraversalMarker", + timeout=180, + ) + + assert "repl_error" not in response + assert not any( + message["severity"] == "error" for message in response.get("messages", []) + ) + assert repl.is_clean() + + @pytest.mark.parametrize( ("warmup", "code", "expected_error"), [ diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 8d9ba0244..2e0089de7 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1261,6 +1261,13 @@ def test_legacy_deps_json_normalizer_does_not_decide_lexical_context(code): assert normalized != code +def test_legacy_deps_json_normalizer_rewrites_long_runs_linearly(): + assert repl_core._normalize_legacy_deps_json_comment_closes("--/" * 10_000) == ( + "-/" * 10_000 + ) + assert repl_core._normalize_legacy_deps_json_comment_closes("---/") == "---/" + + def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeypatch): repl = repl_core.LeanRepl(repl_core.LeanReplConfig()) monkeypatch.setattr(repl_core.os, "name", "nt") @@ -1349,6 +1356,19 @@ def test_header_check_kills_a_command_that_outlives_the_deadline(): assert time.monotonic() - started < 5 +def test_header_check_never_spawns_after_its_shared_deadline(monkeypatch): + repl = repl_core.LeanRepl(repl_core.LeanReplConfig()) + monkeypatch.setattr(repl_core.time, "monotonic", lambda: 2.0) + monkeypatch.setattr( + repl_core.subprocess, + "Popen", + lambda *args, **kwargs: pytest.fail("expired header check spawned Lean"), + ) + + with pytest.raises(TimeoutError, match="timed out checking the Lean header"): + repl._check_header("#check Nat", deadline=1.0) + + def test_header_check_rejects_output_over_the_combined_limit(): with pytest.raises(ValueError, match="output exceeded 1024 bytes"): _header_modules(_fake_header_deps("x" * 1025), max_buffer_bytes=1024) From cb790a7981e762395c0ed1d6343f8c35a5aeb460 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Wed, 7 Oct 2026 03:34:12 -0400 Subject: [PATCH 026/109] Preserve warmup composition contracts --- servers/README.md | 5 ++-- servers/lean_runtime.py | 5 ++++ servers/repl/core.py | 5 ++-- tests/test_real_repl.py | 25 ++++++++++++++++ tests/test_repl_core_protocol.py | 48 ++++++++++++++++++++++++------- tests/test_shared_lean_runtime.py | 4 +++ tests/test_skill_examples.py | 13 ++++++--- 7 files changed, 87 insertions(+), 18 deletions(-) diff --git a/servers/README.md b/servers/README.md index 0d4e3a737..56536ac13 100644 --- a/servers/README.md +++ b/servers/README.md @@ -16,8 +16,9 @@ no process-owned environment or proof-state handle survives the response. Before that child starts, the selected Lake toolchain's `lean --deps-json` parser validates the submitted header and its complete import set; a rejected or unrecognized parser response fails closed. Allowlisted imports require -plain, nonempty module components; quoted or path-like components are refused -instead of being reduced to a misleading allowlisted root. Execution names the +plain, nonempty canonical module components; names that remain quoted or +path-like after Lean parsing are refused instead of being reduced to a +misleading allowlisted root. Execution names the package-qualified `@repl/repl` target so a project target cannot shadow it. Closing the session that started it does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 3f6110142..2b6518816 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -214,6 +214,11 @@ def from_environment(cls) -> "LeanRuntimeConfig": "AUTOFORM_MAX_REPL_REQUEST_SECONDS", DEFAULT_MAX_REPL_REQUEST_SECONDS, ) + if max_repl_request_seconds > DEFAULT_MAX_REPL_REQUEST_SECONDS: + raise ValueError( + "AUTOFORM_MAX_REPL_REQUEST_SECONDS cannot exceed " + f"{DEFAULT_MAX_REPL_REQUEST_SECONDS:g} seconds" + ) if repl_request_timeout > max_repl_request_seconds: raise ValueError( "AUTOFORM_REPL_REQUEST_TIMEOUT cannot exceed " diff --git a/servers/repl/core.py b/servers/repl/core.py index f26e8c0b7..6ed6d0d86 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -1035,7 +1035,7 @@ def remaining(phase: str) -> float: if os.name != "posix": raise RuntimeError("Lean REPL transport requires a POSIX platform") imports, _, _ = _split_imports_and_body(code) - accepts_leading_imports = True + accepts_leading_imports = False if ( self.config.validate_imports and self._allowed_import_roots is not None @@ -1055,6 +1055,7 @@ def remaining(phase: str) -> float: else: imports = list(header.modules) accepts_leading_imports = header.accepts_leading_imports + submitted_modules = set(imports) submitted_roots = {statement.split(".")[0] for statement in imports} if ( result is None @@ -1075,7 +1076,7 @@ def remaining(phase: str) -> float: added_imports = tuple( root for root in sorted(self.config.warmup_imports) - if accepts_leading_imports and root not in submitted_roots + if accepts_leading_imports and root not in submitted_modules ) prefix = "\n".join( f"import {root}" for root in added_imports diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 7b6c709f5..2894d4399 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -84,6 +84,11 @@ def test_fixture_exposes_escaped_module_traversal_without_policy_validation(): ((), "--/ harmless line comment\n#check Nat", None), ((), "#check Nat\n/- body --/", None), (("Mathlib",), "#check autoformWarmupMarker", None), + ( + ("Mathlib",), + "import Mathlib.TraversalAnchor\n#check autoformWarmupMarker", + None, + ), (("Mathlib",), "module\npublic import REPL.Frontend\n", None), (("Mathlib",), "prelude\nimport REPL.Frontend\n#check Nat", None), ], @@ -107,6 +112,26 @@ def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): assert repl.is_clean() +def test_unvalidated_module_header_is_sent_without_a_warmup_prefix(): + repl = LeanRepl( + LeanReplConfig( + cwd=str(REPL_FIXTURE), + validate_imports=False, + ) + ) + + response = repl.run_disposable( + "module\npublic import REPL.Frontend\n", + timeout=180, + ) + + assert "repl_error" not in response + assert not any( + message["severity"] == "error" for message in response.get("messages", []) + ) + assert repl.is_clean() + + def test_runtime_calls_use_distinct_disposable_processes(runtime_dir, monkeypatch): monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "1") monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "1") diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 2e0089de7..a1ba9261a 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -761,7 +761,7 @@ def fake_select(readable, writable, exceptional, timeout=None): def test_disposable_call_uses_one_frame_and_removes_process_handles(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - validate_imports=False, + validate_imports=True, warmup_imports=frozenset({"Mathlib"}), ) ) @@ -781,6 +781,11 @@ def start(**kwargs): monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "start", start) + monkeypatch.setattr( + repl, + "_check_header", + lambda code, deadline: repl_core._LeanHeaderAnalysis((), True), + ) def run(code, env_id, timeout): calls.append((code, env_id, timeout)) @@ -909,7 +914,7 @@ def start(**kwargs): def test_disposable_backlog_adjusts_prefixed_positions(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - validate_imports=False, + validate_imports=True, warmup_imports=frozenset({"Mathlib"}), ) ) @@ -920,6 +925,11 @@ def close(**kwargs): monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr( + repl, + "_check_header", + lambda code, deadline: repl_core._LeanHeaderAnalysis((), True), + ) response = { "env": 4, "messages": [ @@ -949,7 +959,17 @@ def close(**kwargs): } -def test_disposable_call_does_not_shift_positions_without_a_prefix(monkeypatch): +@pytest.mark.parametrize( + "code", + [ + "import Mathlib\n#check Nat", + "module\npublic import REPL.Frontend\n", + "prelude\nimport REPL.Frontend\n#check Nat", + ], +) +def test_unvalidated_disposable_call_never_invents_a_warmup_prefix( + monkeypatch, code +): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( validate_imports=False, @@ -963,10 +983,11 @@ def close(**kwargs): monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "start", lambda **kwargs: None) - monkeypatch.setattr( - repl, - "_run", - lambda **kwargs: { + frames = [] + + def run(**kwargs): + frames.append(kwargs["code"]) + return { "env": 4, "messages": [ { @@ -975,10 +996,11 @@ def close(**kwargs): "pos": {"line": 1, "column": 1}, } ], - }, - ) + } - assert repl.run_disposable("import Mathlib\n#check Nat", timeout=3) == { + monkeypatch.setattr(repl, "_run", run) + + assert repl.run_disposable(code, timeout=3) == { "messages": [ { "severity": "info", @@ -987,6 +1009,7 @@ def close(**kwargs): } ] } + assert frames == [code] def test_disposable_call_does_not_return_a_result_before_verified_cleanup(monkeypatch): @@ -1292,6 +1315,11 @@ def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeyp ("deps_output", "code", "frame"), [ (_deps_json(), "#check Nat", "import Mathlib\n#check Nat"), + ( + _deps_json("Mathlib.TraversalAnchor"), + "import Mathlib.TraversalAnchor\n#check autoformWarmupMarker", + "import Mathlib\nimport Mathlib.TraversalAnchor\n#check autoformWarmupMarker", + ), ( _deps_json("REPL.Frontend", is_module=True), "module\npublic import REPL.Frontend\n", diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 06c894010..cd6a80e6e 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -617,6 +617,9 @@ def __init__(self, config): {"project_dir": str(project), "code": "#check Nat", "timeout": 1}, ) assert configs[0].max_retries == 0 + assert configs[0].header_deps_command == list( + services.config.repl_header_command + ) finally: services.close() @@ -1637,6 +1640,7 @@ def close(self): ("LEAN_NUM_REPLS", "-1", "nonnegative integer"), ("LEAN_REPL_CMD", " ", "must not be empty"), ("AUTOFORM_LEAN_IDLE_SECONDS", "nan", "finite nonnegative"), + ("AUTOFORM_MAX_REPL_REQUEST_SECONDS", "241", "cannot exceed 240"), ("LEAN_LSP_TIMEOUT", "601", "cannot exceed"), ("AUTOFORM_RUNTIME_RESPONSE_TIMEOUT", "100", "too small"), ], diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index b1c73ca46..45b546d0d 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -175,7 +175,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain's `lean --deps-json`" in servers assert "unrecognized parser response fails closed" in servers - assert "quoted or path-like components are refused" in servers + assert "names that remain quoted or\npath-like after Lean parsing are refused" in servers assert "package-qualified `@repl/repl` target" in servers assert "one total post-admission budget" in servers assert "default and maximum are 240 seconds" in servers @@ -320,10 +320,15 @@ def test_setup_asset_is_a_repo_shaped_thesis_vault(repo_root: Path) -> None: assert (example / "src/CabannesThesis.lean").is_file() assert (example / "src/CabannesThesis/Basic.lean").is_file() toolchain = (example / "lean-toolchain").read_text(encoding="utf-8").strip() - manifest = tomllib.loads((example / "lakefile.toml").read_text(encoding="utf-8")) + lakefile = tomllib.loads((example / "lakefile.toml").read_text(encoding="utf-8")) + manifest = json.loads((example / "lake-manifest.json").read_text(encoding="utf-8")) assert toolchain == "leanprover/lean4:v4.32.2" - assert manifest["require"][0]["rev"] == "v4.32.2" - assert manifest["lean_lib"][0]["srcDir"] == "src" + requirements = {entry["name"]: entry for entry in lakefile["require"]} + packages = {entry["name"]: entry for entry in manifest["packages"]} + assert requirements["mathlib"]["rev"] == "v4.32.2" + assert requirements["repl"]["rev"] == packages["repl"]["inputRev"] + assert packages["repl"]["rev"] == requirements["repl"]["rev"] + assert lakefile["lean_lib"][0]["srcDir"] == "src" def test_roadmap_example_is_structural_not_a_completion_fixture( From ade5186321f5cbd7a8576fe602ca3d1bb277aed3 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Wed, 7 Oct 2026 03:45:00 -0400 Subject: [PATCH 027/109] Separate warmup composition from import policy --- servers/repl/core.py | 5 +-- tests/test_real_repl.py | 15 ++++++--- tests/test_repl_core_protocol.py | 53 ++++++++++++++++---------------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 6ed6d0d86..b344f08c6 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -1036,10 +1036,11 @@ def remaining(phase: str) -> float: raise RuntimeError("Lean REPL transport requires a POSIX platform") imports, _, _ = _split_imports_and_body(code) accepts_leading_imports = False - if ( + needs_header_analysis = bool(self.config.warmup_imports) or ( self.config.validate_imports and self._allowed_import_roots is not None - ): + ) + if needs_header_analysis: try: header = self._check_header(code, deadline) normalized = _normalize_legacy_deps_json_comment_closes(code) diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 2894d4399..5e6f38c02 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -112,7 +112,15 @@ def test_disposable_imports_are_checked_by_lean(warmup, code, expected_error): assert repl.is_clean() -def test_unvalidated_module_header_is_sent_without_a_warmup_prefix(): +@pytest.mark.parametrize( + "code", + [ + "#check autoformWarmupMarker", + "module\npublic import REPL.Frontend\n", + "prelude\nimport REPL.Frontend\n#check Nat", + ], +) +def test_unvalidated_calls_use_warmup_only_when_composition_is_proven(code): repl = LeanRepl( LeanReplConfig( cwd=str(REPL_FIXTURE), @@ -120,10 +128,7 @@ def test_unvalidated_module_header_is_sent_without_a_warmup_prefix(): ) ) - response = repl.run_disposable( - "module\npublic import REPL.Frontend\n", - timeout=180, - ) + response = repl.run_disposable(code, timeout=180) assert "repl_error" not in response assert not any( diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index a1ba9261a..8a11bb351 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -960,15 +960,32 @@ def close(**kwargs): @pytest.mark.parametrize( - "code", + ("code", "analysis", "expected_frame"), [ - "import Mathlib\n#check Nat", - "module\npublic import REPL.Frontend\n", - "prelude\nimport REPL.Frontend\n#check Nat", + ( + "#check autoformWarmupMarker", + repl_core._LeanHeaderAnalysis((), True), + "import Mathlib\n#check autoformWarmupMarker", + ), + ( + "import Mathlib\n#check Nat", + repl_core._LeanHeaderAnalysis(("Mathlib",), True), + "import Mathlib\n#check Nat", + ), + ( + "module\npublic import REPL.Frontend\n", + repl_core._LeanHeaderAnalysis(("REPL.Frontend",), False), + "module\npublic import REPL.Frontend\n", + ), + ( + "prelude\nimport REPL.Frontend\n#check Nat", + repl_core._LeanHeaderAnalysis(("REPL.Frontend",), False), + "prelude\nimport REPL.Frontend\n#check Nat", + ), ], ) -def test_unvalidated_disposable_call_never_invents_a_warmup_prefix( - monkeypatch, code +def test_unvalidated_disposable_composes_only_proven_ordinary_headers( + monkeypatch, code, analysis, expected_frame ): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( @@ -983,33 +1000,17 @@ def close(**kwargs): monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "_check_header", lambda code, deadline: analysis) frames = [] def run(**kwargs): frames.append(kwargs["code"]) - return { - "env": 4, - "messages": [ - { - "severity": "info", - "data": "ok", - "pos": {"line": 1, "column": 1}, - } - ], - } + return {"env": 4, "messages": []} monkeypatch.setattr(repl, "_run", run) - assert repl.run_disposable(code, timeout=3) == { - "messages": [ - { - "severity": "info", - "data": "ok", - "pos": {"line": 1, "column": 1}, - } - ] - } - assert frames == [code] + assert repl.run_disposable(code, timeout=3) == {"messages": []} + assert frames == [expected_frame] def test_disposable_call_does_not_return_a_result_before_verified_cleanup(monkeypatch): From e47ef0ec8f618829661c93a80aaa1f757d1c387d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Sun, 11 Oct 2026 00:12:57 -0400 Subject: [PATCH 028/109] Read REPL headers with Lean's own header parser The allowlist took its imports from lean --deps-json, whose fast parser can disagree with Lean.Parser.parseHeader, the parser the REPL imports with. On v4.27 through v4.32 an even dash run before / does not close a block comment for it, and on every release `import allX` in a module header reports X while Lean imports allX. The normalized second parse missed two inputs from the review, and a context-free rewrite cannot track comment nesting. A differential run of about 2,000 generated headers without --/ on v4.27.0 and v4.32.2 found no other import that the fast parser hides. The header command now runs servers/repl/parse_header.lean with lean --run through the same lake env. It calls Parser.parseHeader and prints the deps-json shape, so the decoder is unchanged and the allowlist sees every import the REPL will load. The normalizer and its tests are gone. Both review inputs and the module-header case are real-REPL cases, and the --/ string, line-comment and body-comment cases stay accepted. --- servers/README.md | 11 ++-- servers/lean_runtime.py | 3 +- servers/repl/core.py | 45 ++++------------- servers/repl/parse_header.lean | 23 +++++++++ tests/test_real_repl.py | 10 ++-- tests/test_repl_core_protocol.py | 83 +++++++------------------------ tests/test_shared_lean_runtime.py | 10 +++- tests/test_skill_examples.py | 2 +- 8 files changed, 78 insertions(+), 109 deletions(-) create mode 100644 servers/repl/parse_header.lean diff --git a/servers/README.md b/servers/README.md index 56536ac13..8f24d4d5a 100644 --- a/servers/README.md +++ b/servers/README.md @@ -13,9 +13,10 @@ runtime for the current AutoformBot installation, Unix user, and compute node. That runtime owns one REPL admission pool and LSP session per active Lean project. LSP sessions stay warm; each public REPL call gets a fresh child and no process-owned environment or proof-state handle survives the response. -Before that child starts, the selected Lake toolchain's `lean --deps-json` -parser validates the submitted header and its complete import set; a rejected -or unrecognized parser response fails closed. Allowlisted imports require +Before that child starts, the selected Lake toolchain runs Lean's own header +parser, which the REPL also uses, on the submitted source. Every import it +reports is validated, and a rejected or +unrecognized parser response fails closed. Allowlisted imports require plain, nonempty canonical module components; names that remain quoted or path-like after Lean parsing are refused instead of being reduced to a misleading allowlisted root. Execution names the @@ -50,7 +51,9 @@ by `AUTOFORM_REPL_TOTAL_WORKERS`, `AUTOFORM_REPL_WORKERS_PER_PROJECT`, process to start the runtime supplies those settings until it is stopped. `LEAN_REPL_CMD` selects the disposable child command. An absolute `lake` executable is reused for header validation; a container or other wrapper must -also set `LEAN_REPL_HEADER_CMD` to the matching toolchain-owned parser command. +also set `LEAN_REPL_HEADER_CMD` to a command that runs +`servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, +passing the source on standard input. `AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, the tool operation, and a safety margin. Failed cleanup transfers to a retained background reaper and may continue past that response budget rather than diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 2b6518816..e94c16d0a 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -46,7 +46,7 @@ LeanLspSession, format_lsp_diagnostics, ) -from servers.repl.core import LEAN_HEADER_LAUNCHER, format_repl_response +from servers.repl.core import LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER, format_repl_response from servers.repl.pool import ( DEFAULT_RAM_FRACTION, LeanReplPool, @@ -198,6 +198,7 @@ def from_environment(cls) -> "LeanRuntimeConfig": sys.executable, "-c", LEAN_HEADER_LAUNCHER, + LEAN_HEADER_PARSER, ) else: raise ValueError( diff --git a/servers/repl/core.py b/servers/repl/core.py index b344f08c6..4e5bbd111 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -10,7 +10,6 @@ import json import os import random -import re import select import signal import subprocess @@ -36,11 +35,12 @@ _STDERR_TAIL_BYTES = 200 _PUBLIC_DIAGNOSTIC_FIELDS = frozenset({"severity", "data", "pos", "endPos"}) _PUBLIC_SORRY_FIELDS = frozenset({"goal", "pos", "endPos"}) -_LEGACY_DEPS_JSON_SUSPECT_CLOSE = re.compile(r"(? _LeanHeaderAnalysis: - """Decode the strict schemas emitted by Lean's fast import parser.""" + """Decode the ``lean --deps-json`` schemas printed by the header parser.""" try: payload = _decode_repl_json(stdout) if not isinstance(payload, dict) or set(payload) != {"imports"}: @@ -508,30 +508,11 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: ), ) except (ReplProtocolError, TypeError, KeyError, json.JSONDecodeError): - raise ValueError("unrecognized output from lean --deps-json") from None + raise ValueError("unrecognized output from the Lean header parser") from None except ValueError as error: if str(error): raise - raise ValueError("unrecognized output from lean --deps-json") from None - - -def _normalize_legacy_deps_json_comment_closes(code: str) -> str: - """Normalize suspect closes for a differential Lean 4.30--4.32 parse. - - Those releases skip one character too many when an even run of dashes - precedes ``/`` inside a block comment. Removing one dash globally is - intentionally context-free: callers compare Lean's authoritative header - facts for both byte strings and reject only when they differ. Strings, - line comments, body text, and quoted identifiers therefore need no Python - lexer or duplicated Lean grammar. - """ - - if _LEGACY_DEPS_JSON_SUSPECT_CLOSE.search(code) is None: - return code - return _LEGACY_DEPS_JSON_SUSPECT_CLOSE.sub( - lambda match: match.group(0)[1:], - code, - ) + raise ValueError("unrecognized output from the Lean header parser") from None def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: @@ -582,7 +563,9 @@ class LeanReplConfig: repl_command: list[str] = field(default_factory=lambda: ["lake", "exe", "@repl/repl"]) header_deps_command: list[str] = field( - default_factory=lambda: ["lake", "env", sys.executable, "-c", LEAN_HEADER_LAUNCHER] + default_factory=lambda: [ + "lake", "env", sys.executable, "-c", LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER + ] ) # stdout is capped per response. stderr has no protocol framing, so its @@ -1043,14 +1026,6 @@ def remaining(phase: str) -> float: if needs_header_analysis: try: header = self._check_header(code, deadline) - normalized = _normalize_legacy_deps_json_comment_closes(code) - if normalized != code: - normalized_header = self._check_header(normalized, deadline) - if normalized_header != header: - raise ValueError( - "Lean header contains a block-comment close " - "spelling that lean --deps-json cannot validate safely" - ) except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} else: diff --git a/servers/repl/parse_header.lean b/servers/repl/parse_header.lean new file mode 100644 index 000000000..5d907721a --- /dev/null +++ b/servers/repl/parse_header.lean @@ -0,0 +1,23 @@ +import Lean.Elab.Import +import Lean.Elab.ParseImportsFast + +open Lean Elab + +/-! +Print the header of the Lean source on standard input, in the JSON shape of +`lean --deps-json`, as `Lean.Parser.parseHeader` reads it. The REPL imports +what that parser reports. `lean --deps-json` uses a separate fast parser that +can report different imports for the same header. +-/ + +def main : IO Unit := do + let input ← IO.FS.readFile "/dev/stdin" + let (header, _, messages) ← Parser.parseHeader (Parser.mkInputContext input "") + let mut errors := #[] + for message in messages.toList do + if message.severity == .error then + errors := errors.push s!"{message.pos.line}:{message.pos.column}: {← message.data.toString}" + let result : PrintImportResult := + if errors.isEmpty then { result? := some (HeaderSyntax.toModuleHeader header) } + else { errors } + IO.println (toJson { imports := #[result] : PrintImportsResult }).compress diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 5e6f38c02..ed4394dc1 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -60,10 +60,13 @@ def test_fixture_exposes_escaped_module_traversal_without_policy_validation(): (("REPL",), "/- note -/ import Init.Data\n#check Nat", "Disallowed imports: Init"), ((), "import NotAllowlisted.Mod\n", "Disallowed imports: NotAllowlisted"), ((), "import «REPL\n", "Rejected Lean header"), + ((), "/- a --/\nimport REPL.Frontend\n-- -/\n#check Nat", None), + ((), "/- a --/\nimport Unsafe\n-- -/\n#check Nat", "Disallowed imports: Unsafe"), + ((), "/- /---/ -/\nimport Unsafe\n-- -/\n#check Nat\n", "Disallowed imports: Unsafe"), ( (), - "/- a --/\nimport REPL.Frontend\n-- -/\n#check Nat", - "cannot validate safely", + "/- a --/\n/- x /--/ -/\nimport Unsafe\n-- -/\n#check Nat\n", + "Disallowed imports: Unsafe", ), ( (), @@ -72,8 +75,9 @@ def test_fixture_exposes_escaped_module_traversal_without_policy_validation(): "import Lean\n" "-- -/\n" "#check Lean.Name", - "cannot validate safely", + "Disallowed imports: Lean", ), + ((), "module\nimport allMathlib\n", "Disallowed imports: allMathlib"), ( (), "import Mathlib.«..».Secret\n#check escapedTraversalMarker", diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 8a11bb351..9700de00e 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1119,7 +1119,9 @@ def test_default_header_parser_selects_lean_from_the_lake_toolchain(): command = repl_core.LeanReplConfig().header_deps_command assert command[:3] == ["lake", "env", sys.executable] - assert "LEAN_SYSROOT" in command[-1] + assert "LEAN_SYSROOT" in command[-2] + assert command[-1] == repl_core.LEAN_HEADER_PARSER + assert os.path.isfile(command[-1]) def test_header_parser_launcher_ignores_a_path_shadow(tmp_path): @@ -1132,6 +1134,7 @@ def write_parser(path, module): path.write_text( f"#!{sys.executable}\n" "import sys\n" + f"assert sys.argv[1:] == ['--run', {repl_core.LEAN_HEADER_PARSER!r}]\n" "sys.stdin.read()\n" f"sys.stdout.write({_deps_json(module)!r})\n" ) @@ -1144,7 +1147,10 @@ def write_parser(path, module): "PATH": f"{shadow_bin}{os.pathsep}{os.environ.get('PATH', '')}", } - modules = _header_modules([sys.executable, "-c", repl_core.LEAN_HEADER_LAUNCHER], env=env) + modules = _header_modules( + [sys.executable, "-c", repl_core.LEAN_HEADER_LAUNCHER, repl_core.LEAN_HEADER_PARSER], + env=env, + ) assert modules == ["Mathlib"] @@ -1213,83 +1219,32 @@ def test_disposable_call_rejects_what_lean_reports_before_starting( assert repl.is_clean() -def test_disposable_call_checks_submitted_header_before_warmup_prefix(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib"}), - warmup_imports=frozenset({"Mathlib"}), - ) - ) - checked = [] - - def check_header(code, deadline): - checked.append(code) - raise ValueError("stop") - - monkeypatch.setattr(repl, "_check_header", check_header) - - repl.run_disposable("/- note -/ import Unsafe\n#check Nat") - - assert checked == ["/- note -/ import Unsafe\n#check Nat"] - - @pytest.mark.parametrize( "code", [ - ( - "prelude -- ordinary comment\n" - "import Init /- a --/\n" - "import Lean\n" - "-- -/\n" - "#check Lean.Name" - ), + "/- note -/ import Unsafe\n#check Nat", + "/- /---/ -/\nimport Unsafe\n-- -/\n#check Nat\n", + "/- a --/\n/- x /--/ -/\nimport Unsafe\n-- -/\n#check Nat\n", ], ) -def test_disposable_call_refuses_the_legacy_deps_json_comment_bypass( - monkeypatch, code -): +def test_disposable_call_checks_submitted_header_before_warmup_prefix(monkeypatch, code): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - allowed_imports=frozenset({"Init"}), - warmup_imports=frozenset(), + allowed_imports=frozenset({"Mathlib"}), + warmup_imports=frozenset({"Mathlib"}), ) ) + checked = [] def check_header(code, deadline): - modules = ("Init",) if "--/" in code else ("Init", "Lean") - return repl_core._LeanHeaderAnalysis(modules, True) + checked.append(code) + raise ValueError("stop") monkeypatch.setattr(repl, "_check_header", check_header) - monkeypatch.setattr( - repl, - "start", - lambda *args, **kwargs: pytest.fail("ambiguous header must not start Lean"), - ) - response = repl.run_disposable(code) + repl.run_disposable(code) - assert "lean --deps-json cannot validate safely" in response["repl_error"] - assert repl.is_clean() - - -@pytest.mark.parametrize( - "code", - [ - '#eval "--/"', - "--/ harmless line comment\n#check Nat", - "#check Nat\n/- body --/", - ], -) -def test_legacy_deps_json_normalizer_does_not_decide_lexical_context(code): - normalized = repl_core._normalize_legacy_deps_json_comment_closes(code) - assert normalized != code - - -def test_legacy_deps_json_normalizer_rewrites_long_runs_linearly(): - assert repl_core._normalize_legacy_deps_json_comment_closes("--/" * 10_000) == ( - "-/" * 10_000 - ) - assert repl_core._normalize_legacy_deps_json_comment_closes("---/") == "---/" + assert checked == [code] def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeypatch): diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index cd6a80e6e..0d8275459 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -25,6 +25,7 @@ ProjectResourceCache, ProjectResourceBusyError, ) +from servers.repl.core import LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER def make_lake_project(tmp_path, name: str): @@ -1679,7 +1680,14 @@ def test_absolute_lake_command_is_reused_for_header_validation(monkeypatch): config = LeanRuntimeConfig.from_environment() - assert config.repl_header_command[:2] == ("/opt/lean/bin/lake", "env") + assert config.repl_header_command == ( + "/opt/lean/bin/lake", + "env", + sys.executable, + "-c", + LEAN_HEADER_LAUNCHER, + LEAN_HEADER_PARSER, + ) def test_wrapped_repl_command_requires_an_explicit_header_command(monkeypatch): diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 45b546d0d..10e4781ab 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -173,7 +173,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "each public REPL call gets a fresh child" in servers assert "no process-owned environment or proof-state handle survives" in servers - assert "toolchain's `lean --deps-json`" in servers + assert "toolchain runs Lean's own header" in servers assert "unrecognized parser response fails closed" in servers assert "names that remain quoted or\npath-like after Lean parsing are refused" in servers assert "package-qualified `@repl/repl` target" in servers From c0d59a38cebcf7f33d989a5e0f1fe3be4009490b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 12 Oct 2026 16:17:49 -0400 Subject: [PATCH 029/109] Compose the Mathlib warmup from the parsed header kind The warmup prefix needed a meta Init entry, which Lean emits only from v4.30.0, so on v4.27 through v4.29 every call went out without import Mathlib. Those releases report one plain Init both for an ordinary header and for prelude followed by import Init, so the import list cannot tell the two apart. The header parser now also reports whether the header starts with prelude, and imports are prefixed only when it has neither module nor prelude. That holds on every release, prelude headers that import Init included. The output Lean v4.27.0 prints is now a unit case, and on a scratch v4.27.0 project the review's Nat.Prime snippet goes out behind import Mathlib. --- servers/repl/core.py | 26 +++++++++++------------- servers/repl/parse_header.lean | 13 +++++++----- tests/test_repl_core_protocol.py | 34 ++++++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 4e5bbd111..53c676dde 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -452,6 +452,7 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: if has_result == has_imports: raise ValueError is_module: bool | None = None + is_prelude: bool | None = None if has_result: result = entry["result"] if not isinstance(result, dict): @@ -460,14 +461,15 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: is_module = result.get("isModule") if is_module is not None and type(is_module) is not bool: raise ValueError + is_prelude = result.get("isPrelude") + if is_prelude is not None and type(is_prelude) is not bool: + raise ValueError else: imports = entry["imports"] if not isinstance(imports, list): raise ValueError modules: list[str] = [] - has_implicit_init = False - has_composition_metadata = has_result and is_module is not None for item in imports: if not isinstance(item, dict): raise ValueError @@ -489,23 +491,17 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: is_meta = item.get("isMeta") if is_meta is not None and type(is_meta) is not bool: raise ValueError - if is_meta is None: - has_composition_metadata = False - if module == "Init" and is_meta: - has_implicit_init = True if module != "Init": modules.append(module) - # Only the current schema exposes enough parser state to distinguish - # Lean's implicit Init imports from an explicit ``prelude``/``module`` - # header. The legacy schema remains valid for allowlist checks, but - # fails closed on source rewriting and sends the validated frame exact. + # Imports can go in front only of a header that starts with neither + # ``module`` nor ``prelude``. Lean's implicit Init entries cannot say + # which: before v4.30 an ordinary header and ``prelude`` followed by + # ``import Init`` both report one plain Init. Output without both + # flags, such as plain ``lean --deps-json``, remains valid for allowlist + # checks but sends the validated frame exact. return _LeanHeaderAnalysis( modules=tuple(modules), - accepts_leading_imports=( - has_composition_metadata - and has_implicit_init - and is_module is False - ), + accepts_leading_imports=is_module is False and is_prelude is False, ) except (ReplProtocolError, TypeError, KeyError, json.JSONDecodeError): raise ValueError("unrecognized output from the Lean header parser") from None diff --git a/servers/repl/parse_header.lean b/servers/repl/parse_header.lean index 5d907721a..28261361d 100644 --- a/servers/repl/parse_header.lean +++ b/servers/repl/parse_header.lean @@ -1,5 +1,4 @@ import Lean.Elab.Import -import Lean.Elab.ParseImportsFast open Lean Elab @@ -17,7 +16,11 @@ def main : IO Unit := do for message in messages.toList do if message.severity == .error then errors := errors.push s!"{message.pos.line}:{message.pos.column}: {← message.data.toString}" - let result : PrintImportResult := - if errors.isEmpty then { result? := some (HeaderSyntax.toModuleHeader header) } - else { errors } - IO.println (toJson { imports := #[result] : PrintImportsResult }).compress + let mut entry := Json.mkObj [("errors", toJson errors)] + if errors.isEmpty then + -- Before v4.30 an ordinary header and `prelude` followed by `import Init` + -- both report one plain `Init` import, so also say whether `prelude` is there. + let result := (toJson (HeaderSyntax.toModuleHeader header)).setObjVal! "isPrelude" + (toJson !header.raw[1].isNone) + entry := entry.setObjVal! "result" result + IO.println (Json.mkObj [("imports", Json.arr #[entry])]).compress diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 9700de00e..a7c90da7a 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1081,7 +1081,7 @@ def item(module: str, is_meta: bool = False) -> dict: if not errors: implicit = [] if prelude else [item("Init"), item("Init", is_meta=True)] imports = implicit + [item(module) for module in modules] - entry["result"] = {"imports": imports, "isModule": is_module} + entry["result"] = {"imports": imports, "isModule": is_module, "isPrelude": prelude} return json.dumps({"imports": [entry]}) @@ -1265,6 +1265,29 @@ def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeyp _LEGACY_INIT_ONLY = json.dumps( {"imports": [{"errors": [], "imports": [{"module": "Init", "importAll": False}]}]} ) +# The header parser on Lean v4.27.0 reports one plain Init import both for an +# ordinary header and for ``prelude`` followed by ``import Init``. +_V4_27_INIT_ONLY = ( + '{"imports":[{"errors":[],"result":{"imports":[{"importAll":false,"isExported":true,' + '"isMeta":false,"module":"Init"}],"isModule":false,"isPrelude":%s}}]}' +) +# Plain ``lean --deps-json`` output says nothing about ``prelude``. +_FAST_PARSER_ORDINARY = json.dumps( + { + "imports": [ + { + "errors": [], + "result": { + "imports": [ + {"importAll": False, "isExported": True, "isMeta": False, "module": "Init"}, + {"importAll": False, "isExported": True, "isMeta": True, "module": "Init"}, + ], + "isModule": False, + }, + } + ] + } +) @pytest.mark.parametrize( @@ -1287,8 +1310,15 @@ def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeyp "prelude\nimport REPL.Frontend\n#check Nat", ), (_deps_json("Init", prelude=True), "prelude\nimport Init\n#check Nat", "prelude\nimport Init\n#check Nat"), - # The legacy schema cannot prove an ordinary header, so nothing is prefixed. + (_V4_27_INIT_ONLY % "false", "#check Nat", "import Mathlib\n#check Nat"), + ( + _V4_27_INIT_ONLY % "true", + "prelude\nimport Init\n#check Nat", + "prelude\nimport Init\n#check Nat", + ), + # Output that cannot prove an ordinary header gets nothing prefixed. (_LEGACY_INIT_ONLY, "#check Nat", "#check Nat"), + (_FAST_PARSER_ORDINARY, "#check Nat", "#check Nat"), ], ) def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monkeypatch, deps_output, code, frame): From 055af77e426d6bd59f357bea78efb151ca71446a Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 12 Oct 2026 16:18:26 -0400 Subject: [PATCH 030/109] Install the real-REPL job's Elan from the pinned release lean-action installs elan by piping elan's master elan-init.sh into sh, with no version or checksum, and that step always runs. The job now uses the real-Lean job's install: the v4.2.3 release tarball with its sha256 check, then elan toolchain install for the fixture's toolchain and lake build Mathlib Secret @repl/repl in tests/fixtures/repl-smoke. Elan's bin directory still goes on PATH. lean-action skipped the Mathlib cache for this fixture, and its .lake cache only saved the small REPL build, so neither is carried over. --- .github/workflows/tests.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 75d4bdd82..d50fa6a07 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -93,12 +93,24 @@ jobs: version: "0.12.1" python-version: "3.13" enable-cache: true - - uses: leanprover/lean-action@50fcf42d2e460296f1a34b402e990d1b24f8b596 # v1 - with: - auto-config: false - build: true - build-args: "Mathlib Secret @repl/repl" - lake-package-directory: tests/fixtures/repl-smoke + - name: Install pinned Elan + run: | + set -euo pipefail + timeout --signal=TERM --kill-after=15s 2m curl -sSfL \ + https://github.com/leanprover/elan/releases/download/v4.2.3/elan-x86_64-unknown-linux-gnu.tar.gz \ + -o elan.tar.gz + echo "df0b2b3a439961ffcbb3985214365ffe40f49bc871df04dff268c7d8e21ca8b2 elan.tar.gz" \ + | sha256sum --check --strict + tar xzf elan.tar.gz + ./elan-init -y --default-toolchain none + echo "$HOME/.elan/bin" >> "$GITHUB_PATH" + - name: Build the REPL fixture + working-directory: tests/fixtures/repl-smoke + run: | + set -euo pipefail + timeout --signal=TERM --kill-after=30s 5m \ + elan toolchain install "$(tr -d '\r\n' < lean-toolchain)" + timeout --signal=TERM --kill-after=30s 8m lake build Mathlib Secret @repl/repl - run: uv sync --frozen --extra dev --extra repl - run: uv run pytest -q tests/test_real_repl.py env: From 5fb08be5859ff7dcc46e872e861d1a70c621c07d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 12 Oct 2026 16:19:17 -0400 Subject: [PATCH 031/109] Say when the Lean runtime refuses to start A non-lake LEAN_REPL_CMD without LEAN_REPL_HEADER_CMD, or AUTOFORM_MAX_REPL_REQUEST_SECONDS above 240, makes LeanRuntimeConfig.from_environment raise, so the whole daemon stays down and the LSP tools go with the REPL. The behaviour stays; the servers README now says so, and the doc test pins the sentence. --- servers/README.md | 5 ++++- tests/test_skill_examples.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/servers/README.md b/servers/README.md index 8f24d4d5a..92614c3f0 100644 --- a/servers/README.md +++ b/servers/README.md @@ -53,7 +53,10 @@ process to start the runtime supplies those settings until it is stopped. executable is reused for header validation; a container or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, -passing the source on standard input. +passing the source on standard input. If `LEAN_REPL_CMD` does not invoke `lake` +and `LEAN_REPL_HEADER_CMD` is unset, or if `AUTOFORM_MAX_REPL_REQUEST_SECONDS` +exceeds 240, the runtime refuses to start, so the LSP tools are unavailable as +well. `AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, the tool operation, and a safety margin. Failed cleanup transfers to a retained background reaper and may continue past that response budget rather than diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 10e4781ab..b012029ee 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -181,6 +181,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "default and maximum are 240 seconds" in servers assert "does not mean a REPL subprocess remains resident" in servers assert "`LEAN_REPL_HEADER_CMD`" in servers + assert "the runtime refuses to start" in servers assert "`leanprover-community/repl` at an immutable revision" in setup assert "run `lake update repl`" in setup assert "commit the manifest" in setup From b85d800c5c54c12ce9df9e84a2a629ad9380fa3b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 12 Oct 2026 16:20:14 -0400 Subject: [PATCH 032/109] Report diagnostics in generated imports at line 1 Lean reports a header import failure at the header's start, which is line 1 of the prefixed frame, so after the shift for the generated import Mathlib every such error came back at line 0, both on the normal path and on the stderr-backlog path. The shift now clamps to line 1. The unit tests for both paths include an import error on line 1. --- servers/repl/core.py | 14 +++++++++----- tests/test_repl_core_protocol.py | 30 ++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 53c676dde..30a055eb6 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -577,23 +577,27 @@ class LeanReplConfig: def _adjust_line_numbers(resp: dict, offset: int) -> None: - """Offset all pos.line values so they map back to original source.""" + """Offset all pos.line values so they map back to original source. + + A position inside generated imports, such as an import error, maps to + line 1 rather than to line 0 or before. + """ if offset == 0: return for msg in resp.get("messages", []): pos = msg.get("pos") if pos and isinstance(pos, dict) and "line" in pos: - pos["line"] = pos["line"] + offset + pos["line"] = max(1, pos["line"] + offset) end_pos = msg.get("endPos") if end_pos and isinstance(end_pos, dict) and "line" in end_pos: - end_pos["line"] = end_pos["line"] + offset + end_pos["line"] = max(1, end_pos["line"] + offset) for sorry in resp.get("sorries", []): pos = sorry.get("pos") if pos and isinstance(pos, dict) and "line" in pos: - pos["line"] = pos["line"] + offset + pos["line"] = max(1, pos["line"] + offset) end_pos = sorry.get("endPos") if end_pos and isinstance(end_pos, dict) and "line" in end_pos: - end_pos["line"] = end_pos["line"] + offset + end_pos["line"] = max(1, end_pos["line"] + offset) def _without_process_handles(response: dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index a7c90da7a..3dbbebc6c 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -792,12 +792,18 @@ def run(code, env_id, timeout): return { "env": 4, "messages": [ + { + "severity": "error", + "data": "unknown module prefix 'Mathlib'", + "pos": {"line": 1, "column": 0}, + "endPos": {"line": 1, "column": 14}, + }, { "severity": "info", "data": "message", "pos": {"line": 2, "column": 1}, "endPos": {"line": 2, "column": 3}, - } + }, ], "sorries": [ { @@ -813,12 +819,18 @@ def run(code, env_id, timeout): assert repl.run_disposable("#check Nat", timeout=3) == { "messages": [ + { + "severity": "error", + "data": "unknown module prefix 'Mathlib'", + "pos": {"line": 1, "column": 0}, + "endPos": {"line": 1, "column": 14}, + }, { "severity": "info", "data": "message", "pos": {"line": 1, "column": 1}, "endPos": {"line": 1, "column": 3}, - } + }, ], "sorries": [ { @@ -933,11 +945,16 @@ def close(**kwargs): response = { "env": 4, "messages": [ + { + "severity": "error", + "data": "unknown module prefix 'Mathlib'", + "pos": {"line": 1, "column": 0}, + }, { "severity": "error", "data": "bad", "pos": {"line": 2, "column": 1}, - } + }, ], } monkeypatch.setattr( @@ -950,11 +967,16 @@ def close(**kwargs): assert repl.run_disposable("#check missing", timeout=3) == { "messages": [ + { + "severity": "error", + "data": "unknown module prefix 'Mathlib'", + "pos": {"line": 1, "column": 0}, + }, { "severity": "error", "data": "bad", "pos": {"line": 1, "column": 1}, - } + }, ] } From b8924201d9712beb8df867b7d57a455cccabb6b9 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 12 Oct 2026 22:40:16 -0400 Subject: [PATCH 033/109] Escape each import component the header parser prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_header.lean printed each import's module with toJson, that is Name.toString, which skips the «» escaping when the last component contains ✝ or is _inaccessible or _hyg, or when the root starts with # or ?. So import «Mathlib.Unsafe».«x✝» came out as Mathlib.Unsafe.x✝, which the decoder splits to the allowlisted root Mathlib while Lean imports from root Mathlib.Unsafe, under module and prelude headers too. lean --deps-json prints the same string, so this predates the launcher. The launcher now overwrites each module with Name.toStringWithSep "." true, which escapes every component that needs it and, for every other name, prints exactly what Name.toString does. The decoder's existing «» check then refuses these imports; the rest of the JSON is unchanged. The decoder's fail-closed test gains an escaped dotted root, and the real-REPL test gains five such imports. --- servers/repl/parse_header.lean | 10 ++++++++-- tests/test_real_repl.py | 6 ++++++ tests/test_repl_core_protocol.py | 6 ++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/servers/repl/parse_header.lean b/servers/repl/parse_header.lean index 28261361d..86a489d8c 100644 --- a/servers/repl/parse_header.lean +++ b/servers/repl/parse_header.lean @@ -18,9 +18,15 @@ def main : IO Unit := do errors := errors.push s!"{message.pos.line}:{message.pos.column}: {← message.data.toString}" let mut entry := Json.mkObj [("errors", toJson errors)] if errors.isEmpty then + let moduleHeader := HeaderSyntax.toModuleHeader header + -- `toJson` prints a module with `Name.toString`, which does not escape names such as + -- `«Mathlib.Unsafe».«x✝»` or `«Mathlib.Unsafe»._hyg`, so their root would read as + -- `Mathlib`. Escape each component that needs it instead. + let imports := moduleHeader.imports.map fun imp => + (toJson imp).setObjVal! "module" (toJson (Name.toStringWithSep "." true imp.module)) -- Before v4.30 an ordinary header and `prelude` followed by `import Init` -- both report one plain `Init` import, so also say whether `prelude` is there. - let result := (toJson (HeaderSyntax.toModuleHeader header)).setObjVal! "isPrelude" - (toJson !header.raw[1].isNone) + let result := (toJson moduleHeader).setObjVal! "imports" (toJson imports) + |>.setObjVal! "isPrelude" (toJson !header.raw[1].isNone) entry := entry.setObjVal! "result" result IO.println (Json.mkObj [("imports", Json.arr #[entry])]).compress diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index ed4394dc1..e1ec4c518 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -83,6 +83,12 @@ def test_fixture_exposes_escaped_module_traversal_without_policy_validation(): "import Mathlib.«..».Secret\n#check escapedTraversalMarker", "plain, nonempty module components", ), + # Name.toString leaves these unescaped, so each root would read as Mathlib or Aesop. + ((), "import «Mathlib.Unsafe».«x✝»\n#check Nat", "plain, nonempty module components"), + ((), "import «Mathlib.Unsafe»._hyg\n#check Nat", "plain, nonempty module components"), + ((), "import «Mathlib.Unsafe».«_inaccessible»\n#check Nat", "plain, nonempty module components"), + ((), "import «Mathlib.Unsafe✝»\n#check Nat", "plain, nonempty module components"), + ((), "import «Aesop.Unsafe».«x✝»\n#check Nat", "plain, nonempty module components"), ((), "import REPL.Frontend\n#check Nat", None), ((), '#eval "--/"', None), ((), "--/ harmless line comment\n#check Nat", None), diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 3dbbebc6c..2f6a2e243 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1189,6 +1189,12 @@ def write_parser(path, module): _fake_header_deps(_deps_json("Mathlib.«..».Secret")), "plain, nonempty module components", ), + # parse_header.lean prints this name escaped. Unescaped, as Name.toString + # prints it, its root would read as the allowlisted Mathlib. + ( + _fake_header_deps(_deps_json("«Mathlib.Unsafe».«x✝»")), + "plain, nonempty module components", + ), (_fake_header_deps("", returncode=1, stderr="unknown package\n"), "unknown package"), (_fake_header_deps("not json"), "unrecognized output"), ( From 1278fbefd7c99414722e8220f3c287461b2b0f99 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Mon, 12 Oct 2026 22:40:46 -0400 Subject: [PATCH 034/109] Say that the header check costs one Lean process per call The servers README described the header check but not its cost. It now says each call runs the check as one extra short Lean process, and that its time comes out of the same request budget as the rest of the call. --- servers/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/servers/README.md b/servers/README.md index 92614c3f0..39e9de48c 100644 --- a/servers/README.md +++ b/servers/README.md @@ -19,7 +19,9 @@ reports is validated, and a rejected or unrecognized parser response fails closed. Allowlisted imports require plain, nonempty canonical module components; names that remain quoted or path-like after Lean parsing are refused instead of being reduced to a -misleading allowlisted root. Execution names the +misleading allowlisted root. The header check runs as one extra short Lean +process per call and counts toward the same request budget as the rest of +the call. Execution names the package-qualified `@repl/repl` target so a project target cannot shadow it. Closing the session that started it does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an From 03d7346bb49c8e40d6ae24ff20bec2d4d8974dc6 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 03:44:01 -0400 Subject: [PATCH 035/109] Name both remedies when Lake cannot find the REPL package The default LEAN_REPL_CMD, lake exe @repl/repl, finds the REPL only through a require named repl: Lake matches @repl against each package's require name, and the upstream package calls itself REPL. main's lake exe repl found the executable under any require name, so a project that requires the REPL under another name now fails every call, with Lake's unknown package line buried in a stderr tail. The package-qualified default stays, since it stops a project's own repl executable from standing in for the REPL. The setup skill now says the require must be named repl and why. The servers README says a require with any other name needs LEAN_REPL_CMD="lake exe @/repl", which still starts with lake, so the header command is still derived from it. The doc test pins both sentences. A failed start reaches the caller through run_disposable, as an unknown outcome or a process exit whose message carries the child's stderr tail. When the tail has Lake's unknown package error, the message now ends with a hint naming both remedies. A frame larger than the pipe buffer meets the failure before it is fully written, and that error dropped the tail, so it now keeps it like the other pre-send failures. A unit test runs a stand-in for lake exe that fails as Lake does, with and without that error, and another checks the pre-send tail. --- servers/README.md | 9 ++++-- servers/repl/core.py | 31 ++++++++++++++++-- skills/setup/SKILL.md | 8 +++-- tests/test_repl_core_protocol.py | 54 ++++++++++++++++++++++++++++++++ tests/test_skill_examples.py | 5 +++ 5 files changed, 98 insertions(+), 9 deletions(-) diff --git a/servers/README.md b/servers/README.md index 39e9de48c..184856569 100644 --- a/servers/README.md +++ b/servers/README.md @@ -51,9 +51,12 @@ uid-specific directory in `/tmp`; the rotating runtime log is beside it. by `AUTOFORM_REPL_TOTAL_WORKERS`, `AUTOFORM_REPL_WORKERS_PER_PROJECT`, `AUTOFORM_MAX_LEAN_PROJECTS`, and `AUTOFORM_LEAN_IDLE_SECONDS`. The first process to start the runtime supplies those settings until it is stopped. -`LEAN_REPL_CMD` selects the disposable child command. An absolute `lake` -executable is reused for header validation; a container or other wrapper must -also set `LEAN_REPL_HEADER_CMD` to a command that runs +`LEAN_REPL_CMD` selects the disposable child command. Its default, +`lake exe @repl/repl`, finds the REPL only through a require named `repl`. A +require with any other name needs `LEAN_REPL_CMD="lake exe @/repl"`, +which still starts with `lake`, so the header command is still derived from +it. An absolute `lake` executable is reused for header validation; a container +or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, passing the source on standard input. If `LEAN_REPL_CMD` does not invoke `lake` and `LEAN_REPL_HEADER_CMD` is unset, or if `AUTOFORM_MAX_REPL_REQUEST_SECONDS` diff --git a/servers/repl/core.py b/servers/repl/core.py index 30a055eb6..946e4d221 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -42,6 +42,13 @@ "lean = os.path.join(os.environ['LEAN_SYSROOT'], 'bin', 'lean'); " "os.execv(lean, [lean, '--run', sys.argv[1]])" ) +# Lake's error for ``lake exe @/`` when no package in the +# workspace has that name, as when the REPL is required under another name. +_LAKE_UNKNOWN_PACKAGE = "unknown package `" +_REPL_REQUIRE_HINT = ( + "Lake could not find the REPL package: name the project's REPL require `repl`, " + 'or set LEAN_REPL_CMD="lake exe @/repl" for a require named ' +) # --------------------------------------------------------------------------- @@ -633,6 +640,17 @@ def public_fields(item: dict[str, Any], allowed: frozenset[str]) -> dict[str, An return cleaned +def _with_repl_require_hint(message: str) -> str: + """Name both remedies when Lake found no package for the REPL command. + + A child that fails this way reaches the caller as a process exit or an + unknown outcome whose message carries the child's stderr tail. + """ + if _LAKE_UNKNOWN_PACKAGE not in message: + return message + return f"{message.rstrip()}; {_REPL_REQUIRE_HINT}" + + def format_message(msg: dict) -> str: """Format one REPL message: ``"3:5: error: unknown identifier"``.""" severity = msg.get("severity", "info") @@ -1097,9 +1115,12 @@ def remaining(phase: str) -> float: except ReplCommandError as error: result = {"repl_error": str(error)} except (ReplProtocolError, ReplOutcomeUnknown) as error: - result = {"repl_error": str(error), "outcome_unknown": True} + result = { + "repl_error": _with_repl_require_hint(str(error)), + "outcome_unknown": True, + } except (ReplProcessExited, TimeoutError, RuntimeError, OSError) as error: - result = {"repl_error": str(error)} + result = {"repl_error": _with_repl_require_hint(str(error))} except BaseException as error: request_error = error finally: @@ -1419,14 +1440,18 @@ def reject_unsolicited_stdout() -> None: raise ReplProcessExited( "Lean REPL stdout failed before the request frame was fully sent" ) from error + _, stderr_tail = stderr_details() self.close(deadline=end_time) if chunk: raise ReplProcessExited( "Lean REPL emitted unsolicited stdout before the request frame " "was fully sent; the process was recycled" ) + # A child that exits before reading the frame, as Lake does when it + # cannot find the REPL package, says why only on stderr. raise ReplProcessExited( - "Lean REPL closed stdout before the request frame was fully sent" + "Lean REPL closed stdout before the request frame was fully sent. " + f"Tail: {stderr_tail!r}" ) def drain_stderr(*, max_reads: int | None = None, after_response: bool = False) -> bool: diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index c7ac7222d..2c81ff0e4 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -98,9 +98,11 @@ prefix only for the Autoform vault/site repair overlay. Neither `project new` nor `init` adds the Lean REPL that the REPL tool runs. Declare `leanprover-community/repl` at an immutable revision tested against the -project's exact Lean toolchain, such as the bundled example's pin for v4.32.2, -run `lake update repl` so an existing manifest locks it without moving Mathlib, -commit the manifest, then verify `lake build @repl/repl`; never guess a +project's exact Lean toolchain, such as the bundled example's pin for v4.32.2. +Name the require `repl`, as that example does: the runtime runs +`lake exe @repl/repl`, which finds the package only under its require name. +Then run `lake update repl` so an existing manifest locks it without moving +Mathlib, commit the manifest, and verify `lake build @repl/repl`; never guess a compatible revision. `autoform init` is the whole vault: `blueprint/` with its landing page, diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index e26ee773a..dc0216c52 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1294,6 +1294,44 @@ def test_disposable_call_refuses_non_posix_before_spawning_header_parser(monkeyp assert repl.is_clean() +def _failing_lake_exe(stderr: str) -> list[str]: + # Reading the whole frame first makes the failure arrive after the frame is + # sent, as it does with Lake, which takes longer to start than a short + # frame takes to write. + script = ( + "import sys\n" + "while sys.stdin.readline() not in ('\\n', ''):\n" + " pass\n" + f"sys.stderr.write({stderr!r})\n" + "sys.exit(1)\n" + ) + return [sys.executable, "-c", script] + + +@pytest.mark.parametrize( + ("lake_error", "hinted"), + [ + ("error: unknown package `repl`", True), + ("error: unknown executable @repl/repl", False), + ], +) +def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_error, hinted): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + validate_imports=False, + warmup_imports=frozenset(), + repl_command=_failing_lake_exe(lake_error + "\n"), + ) + ) + + error = repl.run_disposable("#check Nat", timeout=30)["repl_error"] + + assert lake_error in error + assert ("name the project's REPL require `repl`" in error) is hinted + assert ('set LEAN_REPL_CMD="lake exe @/repl"' in error) is hinted + assert repl.is_clean() + + _LEGACY_INIT_ONLY = json.dumps( {"imports": [{"errors": [], "imports": [{"module": "Init", "importAll": False}]}]} ) @@ -1929,6 +1967,22 @@ def test_wire_protocol_retires_a_process_with_closed_stderr_before_writing(): assert repl.process is None +def test_stdout_eof_before_the_frame_is_sent_reports_the_stderr_tail(): + # A child that exits before reading the frame, as Lake does when it cannot + # find the REPL package, says why only on stderr. + with ExitStack() as stack: + process = _PipeProcess(stack, []) + process._stderr_write.write(b"error: unknown package `repl`\n") + process._stdout_write.close() + repl = _repl_with_process(process) + + with pytest.raises(repl_core.ReplProcessExited, match="closed stdout before") as error: + repl._run("#check Nat", env_id=None, timeout=1) + + assert repl.process is None + assert "error: unknown package `repl`" in str(error.value) + + def test_stdout_eof_keeps_only_a_fixed_stderr_tail(monkeypatch): with ExitStack() as stack: process = _PipeProcess( diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 4080187e2..a4c025ae0 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -256,12 +256,17 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "unrecognized parser response fails closed" in servers assert "names that remain quoted or\npath-like after Lean parsing are refused" in servers assert "package-qualified `@repl/repl` target" in servers + assert "finds the REPL only through a require named `repl`" in servers + assert 'needs `LEAN_REPL_CMD="lake exe @/repl"`' in servers + assert "so the header command is still derived from" in servers assert "one total post-admission budget" in servers assert "default and maximum are 240 seconds" in servers assert "does not mean a REPL subprocess remains resident" in servers assert "`LEAN_REPL_HEADER_CMD`" in servers assert "the runtime refuses to start" in servers assert "`leanprover-community/repl` at an immutable revision" in setup + assert "Name the require `repl`" in setup + assert "which finds the package only under its require name" in setup assert "run `lake update repl`" in setup assert "commit the manifest" in setup assert "lake build @repl/repl" in setup From 2322c975a7d2e2563c0946e0ae08c35d68945088 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 03:46:48 -0400 Subject: [PATCH 036/109] Check the header parser on the oldest supported Lean in CI The real-REPL job ran servers/repl/parse_header.lean only with the fixture's v4.32.2, though Autoform supports Lean from v4.27.0 and the warmup composition depends on what the parser prints on each release. An edit that used a newer Lean API would make every call on an older project fail with "Rejected Lean header", and CI would not notice. The job now installs the toolchain pinned in tests/fixtures/minimum-lean, v4.27.0, under a step timeout like the others. A real-REPL test runs the default header command, lake env with the Python launcher, from that directory, as the runtime runs it from a project. On "#check Nat" and on "prelude" followed by "import Init" it must print exactly one plain Init import, with isPrelude false and true respectively. Neither Mathlib nor a REPL build is needed. --- .github/workflows/tests.yml | 5 ++++ tests/fixtures/minimum-lean/lean-toolchain | 1 + tests/test_real_repl.py | 32 ++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/fixtures/minimum-lean/lean-toolchain diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 06dcb5a42..c6c4381cf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -138,6 +138,11 @@ jobs: timeout --signal=TERM --kill-after=30s 5m \ elan toolchain install "$(tr -d '\r\n' < lean-toolchain)" timeout --signal=TERM --kill-after=30s 8m lake build Mathlib Secret @repl/repl + - name: Install the minimum supported Lean toolchain + run: | + set -euo pipefail + timeout --signal=TERM --kill-after=30s 5m \ + elan toolchain install "$(tr -d '\r\n' < tests/fixtures/minimum-lean/lean-toolchain)" - run: uv sync --frozen --extra dev --extra repl - run: uv run pytest -q tests/test_real_repl.py env: diff --git a/tests/fixtures/minimum-lean/lean-toolchain b/tests/fixtures/minimum-lean/lean-toolchain new file mode 100644 index 000000000..5249182c0 --- /dev/null +++ b/tests/fixtures/minimum-lean/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.27.0 diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index e1ec4c518..b6e507f7d 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import os import re +import subprocess from contextlib import suppress from pathlib import Path @@ -14,6 +16,7 @@ REPL_FIXTURE = Path(__file__).parent / "fixtures" / "repl-smoke" +MINIMUM_LEAN = Path(__file__).parent / "fixtures" / "minimum-lean" pytestmark = pytest.mark.skipif( os.environ.get("AUTOFORM_RUN_REAL_REPL_TESTS") != "1", reason="set AUTOFORM_RUN_REAL_REPL_TESTS=1 to run pinned REPL integration", @@ -147,6 +150,35 @@ def test_unvalidated_calls_use_warmup_only_when_composition_is_proven(code): assert repl.is_clean() +@pytest.mark.parametrize(("code", "is_prelude"), [("#check Nat", False), ("prelude\nimport Init", True)]) +def test_header_parser_runs_on_the_minimum_supported_lean(code, is_prelude): + # The default header command, run from a project on the oldest supported + # Lean. Before v4.30 both headers report one plain Init import, so only + # isPrelude tells them apart. + completed = subprocess.run( + LeanReplConfig().header_deps_command, + cwd=MINIMUM_LEAN, + input=code.encode(), + capture_output=True, + timeout=180, + check=False, + ) + + assert completed.returncode == 0, (completed.stdout, completed.stderr) + assert json.loads(completed.stdout) == { + "imports": [ + { + "errors": [], + "result": { + "imports": [{"module": "Init", "importAll": False, "isExported": True, "isMeta": False}], + "isModule": False, + "isPrelude": is_prelude, + }, + } + ] + } + + def test_runtime_calls_use_distinct_disposable_processes(runtime_dir, monkeypatch): monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "1") monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "1") From c716f99f69e84c6577ad552b63fe6823c75b9f6d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 03:50:06 -0400 Subject: [PATCH 037/109] Default the REPL timeout to a lowered maximum The default AUTOFORM_REPL_REQUEST_TIMEOUT rose from 30 to 240 seconds on this branch, so setting only AUTOFORM_MAX_REPL_REQUEST_SECONDS below 240 failed the timeout-versus-maximum check, and the runtime refused to start, LSP tools included. The same setting started on main. When AUTOFORM_REPL_REQUEST_TIMEOUT is unset, it now defaults to the lower of 240 seconds and the maximum, which is read first. An explicit timeout above the maximum still refuses to start. The servers README says both, beside the other refuse-to-start cases, and the doc test pins them. A unit test covers the maximum set alone and an explicit timeout above it. --- servers/README.md | 11 +++++++---- servers/lean_runtime.py | 9 +++++---- tests/test_shared_lean_runtime.py | 14 ++++++++++++++ tests/test_skill_examples.py | 3 ++- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/servers/README.md b/servers/README.md index 184856569..ebe4abecb 100644 --- a/servers/README.md +++ b/servers/README.md @@ -58,10 +58,13 @@ which still starts with `lake`, so the header command is still derived from it. An absolute `lake` executable is reused for header validation; a container or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, -passing the source on standard input. If `LEAN_REPL_CMD` does not invoke `lake` -and `LEAN_REPL_HEADER_CMD` is unset, or if `AUTOFORM_MAX_REPL_REQUEST_SECONDS` -exceeds 240, the runtime refuses to start, so the LSP tools are unavailable as -well. +passing the source on standard input. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps +the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default +`timeout` is the lower of 240 seconds and that cap. If `LEAN_REPL_CMD` does not +invoke `lake` and `LEAN_REPL_HEADER_CMD` is unset, if +`AUTOFORM_MAX_REPL_REQUEST_SECONDS` exceeds 240, or if an explicit +`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, the runtime refuses to start, +so the LSP tools are unavailable as well. `AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, the tool operation, and a safety margin. Failed cleanup transfers to a retained background reaper and may continue past that response budget rather than diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index ee2668268..0b1571539 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -207,10 +207,6 @@ def from_environment(cls) -> "LeanRuntimeConfig": ) if not lsp_command: raise ValueError("LEAN_LSP_CMD must not be empty") - repl_request_timeout = _positive_float( - "AUTOFORM_REPL_REQUEST_TIMEOUT", - DEFAULT_REPL_REQUEST_TIMEOUT, - ) max_repl_request_seconds = _positive_float( "AUTOFORM_MAX_REPL_REQUEST_SECONDS", DEFAULT_MAX_REPL_REQUEST_SECONDS, @@ -220,6 +216,11 @@ def from_environment(cls) -> "LeanRuntimeConfig": "AUTOFORM_MAX_REPL_REQUEST_SECONDS cannot exceed " f"{DEFAULT_MAX_REPL_REQUEST_SECONDS:g} seconds" ) + # The default follows a lowered maximum, so the maximum can be set alone. + repl_request_timeout = _positive_float( + "AUTOFORM_REPL_REQUEST_TIMEOUT", + min(DEFAULT_REPL_REQUEST_TIMEOUT, max_repl_request_seconds), + ) if repl_request_timeout > max_repl_request_seconds: raise ValueError( "AUTOFORM_REPL_REQUEST_TIMEOUT cannot exceed " diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index f5b38fc73..06d21bd2b 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -1879,6 +1879,20 @@ def test_wrapped_repl_command_requires_an_explicit_header_command(monkeypatch): ) +def test_lowered_repl_maximum_lowers_only_the_default_timeout(monkeypatch): + monkeypatch.delenv("AUTOFORM_REPL_REQUEST_TIMEOUT", raising=False) + monkeypatch.setenv("AUTOFORM_MAX_REPL_REQUEST_SECONDS", "120") + + config = LeanRuntimeConfig.from_environment() + + assert config.max_repl_request_seconds == 120.0 + assert config.repl_request_timeout == 120.0 + + monkeypatch.setenv("AUTOFORM_REPL_REQUEST_TIMEOUT", "180") + with pytest.raises(ValueError, match="AUTOFORM_REPL_REQUEST_TIMEOUT cannot exceed"): + LeanRuntimeConfig.from_environment() + + def test_response_budget_includes_pool_replacement_cleanup(monkeypatch): monkeypatch.setenv("AUTOFORM_REPL_TOTAL_WORKERS", "3") monkeypatch.setenv("AUTOFORM_REPL_WORKERS_PER_PROJECT", "3") diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index a4c025ae0..b03c3166b 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -263,7 +263,8 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "default and maximum are 240 seconds" in servers assert "does not mean a REPL subprocess remains resident" in servers assert "`LEAN_REPL_HEADER_CMD`" in servers - assert "the runtime refuses to start" in servers + assert "is the lower of 240 seconds and that cap" in servers + assert "`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, the runtime refuses to start" in servers assert "`leanprover-community/repl` at an immutable revision" in setup assert "Name the require `repl`" in setup assert "which finds the package only under its require name" in setup From 482418afeb8298f37948c473fcbe06907dbeda42 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 03:51:53 -0400 Subject: [PATCH 038/109] Report the last output line of a failed header command A failed header command reported only the first line of its stderr. For a launcher that cannot start, such as one run outside lake env where LEAN_SYSROOT is unset, that line is "Traceback (most recent call last):". Lean prints elaboration errors on stdout, so a parser that failed to elaborate reported only "exit status 1". The detail is now the last non-empty line of stderr, else of stdout, cut to 200 characters, still as "Rejected Lean header: ...". A unit test covers the traceback, stdout-only, empty and overlong cases. --- servers/repl/core.py | 20 +++++++++++++++-- tests/test_repl_core_protocol.py | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 946e4d221..2d05be540 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -33,6 +33,7 @@ WARMUP_IMPORTS = frozenset({"Mathlib"}) _VALID_DIAGNOSTIC_SEVERITIES = frozenset({"trace", "info", "warning", "error"}) _STDERR_TAIL_BYTES = 200 +_HEADER_FAILURE_DETAIL_CHARS = 200 _PUBLIC_DIAGNOSTIC_FIELDS = frozenset({"severity", "data", "pos", "endPos"}) _PUBLIC_SORRY_FIELDS = frozenset({"goal", "pos", "endPos"}) # Runs Lean's own header parser, which the REPL uses, on the source on stdin. @@ -426,6 +427,22 @@ def close_stdin() -> None: return bytes(stdout), bytes(stderr) +def _header_failure_detail(stdout: bytes, stderr: bytes, returncode: int) -> str: + """Return the last non-empty line of stderr, else of stdout, bounded. + + A launcher that cannot start ends its stderr with the exception after a + traceback, and Lean prints elaboration errors on stdout. + """ + for output in (stderr, stdout): + lines = output.decode(errors="replace").splitlines() + detail = next((line.strip() for line in reversed(lines) if line.strip()), "") + if detail: + if len(detail) > _HEADER_FAILURE_DETAIL_CHARS: + detail = detail[:_HEADER_FAILURE_DETAIL_CHARS] + "..." + return detail + return f"exit status {returncode}" + + @dataclass(frozen=True) class _LeanHeaderAnalysis: """Lean-owned facts needed to validate and compose one submitted header.""" @@ -1004,8 +1021,7 @@ def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: self.close(deadline=deadline) returncode = process.returncode if returncode != 0: - detail = stderr.decode(errors="replace").strip().splitlines() - raise ValueError(detail[0] if detail else f"exit status {returncode}") + raise ValueError(_header_failure_detail(stdout, stderr, returncode)) return _decode_header_analysis(stdout) def run_disposable( diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index dc0216c52..ee10ca78a 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1251,6 +1251,44 @@ def test_disposable_call_rejects_what_lean_reports_before_starting( assert repl.is_clean() +@pytest.mark.parametrize( + ("stdout", "stderr", "detail"), + [ + # The launcher outside lake env, where LEAN_SYSROOT is unset. + ( + "", + "Traceback (most recent call last):\n" + ' File "", line 1, in \n' + "KeyError: 'LEAN_SYSROOT'\n", + "KeyError: 'LEAN_SYSROOT'", + ), + # Lean prints elaboration errors on stdout. + ( + "parse_header.lean:14:3: error: unknown constant 'Lean.Parser.parseHeader'\n\n", + "", + "parse_header.lean:14:3: error: unknown constant 'Lean.Parser.parseHeader'", + ), + ("", "", "exit status 1"), + ("", "x" * 1000, "x" * repl_core._HEADER_FAILURE_DETAIL_CHARS + "..."), + ], +) +def test_failed_header_command_reports_its_last_output_line(monkeypatch, stdout, stderr, detail): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + warmup_imports=frozenset(), + header_deps_command=_fake_header_deps(stdout, returncode=1, stderr=stderr), + ) + ) + monkeypatch.setattr( + repl, + "start", + lambda *args, **kwargs: pytest.fail("a failed header check must not start Lean"), + ) + + assert repl.run_disposable("#check Nat") == {"repl_error": f"Rejected Lean header: {detail}"} + assert repl.is_clean() + + @pytest.mark.parametrize( "code", [ From 2522637513a7cc1debfbc6898a4fd1a8986743a7 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:53:59 -0400 Subject: [PATCH 039/109] Rename the REPL header command field to header_command LeanReplConfig.header_deps_command was named after the lean --deps-json design that parse_header.lean replaced, while the runtime field and the variable that sets it are repl_header_command and LEAN_REPL_HEADER_CMD. The field is new on this branch, so it is renamed to header_command at no compatibility cost. The protocol tests' helpers _fake_header_deps and _deps_json, and their deps_output parameter, are renamed to match. The default command was also built twice: in the dataclass default and, for an absolute lake, in LeanRuntimeConfig.from_environment. Both now call default_header_command(lake) in servers/repl/core.py, so lean_runtime.py no longer imports sys or the launcher constants. The tests that pin the default and the absolute-lake commands are unchanged. --- servers/lean_runtime.py | 14 ++----- servers/repl/core.py | 13 +++--- tests/test_real_repl.py | 2 +- tests/test_repl_core_protocol.py | 68 +++++++++++++++---------------- tests/test_shared_lean_runtime.py | 2 +- 5 files changed, 46 insertions(+), 53 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 0b1571539..6afbbfa40 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -16,7 +16,6 @@ import signal import socketserver import stat -import sys import threading import time from collections.abc import Callable, Iterator @@ -46,7 +45,7 @@ LeanLspSession, format_lsp_diagnostics, ) -from servers.repl.core import LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER, format_repl_response +from servers.repl.core import default_header_command, format_repl_response from servers.repl.pool import ( DEFAULT_RAM_FRACTION, LeanReplPool, @@ -192,14 +191,7 @@ def from_environment(cls) -> "LeanRuntimeConfig": if not repl_header_command: raise ValueError("LEAN_REPL_HEADER_CMD must not be empty") elif Path(repl_command[0]).name == "lake": - repl_header_command = ( - repl_command[0], - "env", - sys.executable, - "-c", - LEAN_HEADER_LAUNCHER, - LEAN_HEADER_PARSER, - ) + repl_header_command = tuple(default_header_command(repl_command[0])) else: raise ValueError( "LEAN_REPL_HEADER_CMD must be set when LEAN_REPL_CMD does not " @@ -842,7 +834,7 @@ def default_repl_factory( LeanReplPoolConfig( cwd=str(project_dir), repl_command=list(self.config.repl_command), - header_deps_command=list(self.config.repl_header_command), + header_command=list(self.config.repl_header_command), num_repls=self.config.repl_workers_per_project, max_retries=0, ) diff --git a/servers/repl/core.py b/servers/repl/core.py index 2d05be540..86734c9fd 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -564,6 +564,11 @@ def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: # --------------------------------------------------------------------------- +def default_header_command(lake: str = "lake") -> list[str]: + """Return the command that runs the header parser with the Lean ``lake`` selects.""" + return [lake, "env", sys.executable, "-c", LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER] + + @dataclass class LeanReplConfig: """Configuration for a Lean REPL instance.""" @@ -582,11 +587,7 @@ class LeanReplConfig: warmup_imports: frozenset[str] = WARMUP_IMPORTS repl_command: list[str] = field(default_factory=lambda: ["lake", "exe", "@repl/repl"]) - header_deps_command: list[str] = field( - default_factory=lambda: [ - "lake", "env", sys.executable, "-c", LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER - ] - ) + header_command: list[str] = field(default_factory=default_header_command) # stdout is capped per response. stderr has no protocol framing, so its # ceiling applies to the entire process generation and resets on restart. @@ -1002,7 +1003,7 @@ def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: env = _inherit_clean_env() env.update(self.config.env) self.process = subprocess.Popen( - self.config.header_deps_command, + self.config.header_command, cwd=self.cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index b6e507f7d..26b7a0983 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -156,7 +156,7 @@ def test_header_parser_runs_on_the_minimum_supported_lean(code, is_prelude): # Lean. Before v4.30 both headers report one plain Init import, so only # isPrelude tells them apart. completed = subprocess.run( - LeanReplConfig().header_deps_command, + LeanReplConfig().header_command, cwd=MINIMUM_LEAN, input=code.encode(), capture_output=True, diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index ee10ca78a..27503178d 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1072,7 +1072,7 @@ def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): repl_core.LeanReplConfig( allowed_imports=frozenset({"Mathlib"}), warmup_imports=frozenset(), - header_deps_command=_fake_header_deps(_deps_json("Unsafe")), + header_command=_fake_header_command(_header_json("Unsafe")), ) ) starts = [] @@ -1085,7 +1085,7 @@ def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): assert repl.is_clean() -def _fake_header_deps(stdout: str, returncode: int = 0, stderr: str = "") -> list[str]: +def _fake_header_command(stdout: str, returncode: int = 0, stderr: str = "") -> list[str]: script = ( "import sys; sys.stdin.read(); " f"sys.stdout.write({stdout!r}); sys.stderr.write({stderr!r}); " @@ -1094,7 +1094,7 @@ def _fake_header_deps(stdout: str, returncode: int = 0, stderr: str = "") -> lis return [sys.executable, "-c", script] -def _deps_json( +def _header_json( *modules: str, errors: tuple[str, ...] = (), is_module: bool = False, @@ -1112,7 +1112,7 @@ def item(module: str, is_meta: bool = False) -> dict: def _header_modules(command: list[str], *, timeout: float = 10, **config) -> list[str]: - repl = repl_core.LeanRepl(repl_core.LeanReplConfig(header_deps_command=command, **config)) + repl = repl_core.LeanRepl(repl_core.LeanReplConfig(header_command=command, **config)) try: return list(repl._check_header("import Mathlib", time.monotonic() + timeout).modules) finally: @@ -1120,8 +1120,8 @@ def _header_modules(command: list[str], *, timeout: float = 10, **config) -> lis def test_header_check_accepts_current_and_legacy_lean_schemas(): - current = _fake_header_deps(_deps_json("Init", "Mathlib.Tactic")) - legacy = _fake_header_deps( + current = _fake_header_command(_header_json("Init", "Mathlib.Tactic")) + legacy = _fake_header_command( json.dumps( { "imports": [ @@ -1142,7 +1142,7 @@ def test_header_check_accepts_current_and_legacy_lean_schemas(): def test_default_header_parser_selects_lean_from_the_lake_toolchain(): - command = repl_core.LeanReplConfig().header_deps_command + command = repl_core.LeanReplConfig().header_command assert command[:3] == ["lake", "env", sys.executable] assert "LEAN_SYSROOT" in command[-2] @@ -1162,7 +1162,7 @@ def write_parser(path, module): "import sys\n" f"assert sys.argv[1:] == ['--run', {repl_core.LEAN_HEADER_PARSER!r}]\n" "sys.stdin.read()\n" - f"sys.stdout.write({_deps_json(module)!r})\n" + f"sys.stdout.write({_header_json(module)!r})\n" ) path.chmod(0o755) @@ -1184,32 +1184,32 @@ def write_parser(path, module): @pytest.mark.parametrize( ("command", "message"), [ - (_fake_header_deps(_deps_json(errors=("bad header",))), "bad header"), + (_fake_header_command(_header_json(errors=("bad header",))), "bad header"), ( - _fake_header_deps(_deps_json("Mathlib.«/abs/path/Secret»")), + _fake_header_command(_header_json("Mathlib.«/abs/path/Secret»")), "plain, nonempty module components", ), ( - _fake_header_deps(_deps_json("Mathlib.«..».Secret")), + _fake_header_command(_header_json("Mathlib.«..».Secret")), "plain, nonempty module components", ), # parse_header.lean prints this name escaped. Unescaped, as Name.toString # prints it, its root would read as the allowlisted Mathlib. ( - _fake_header_deps(_deps_json("«Mathlib.Unsafe».«x✝»")), + _fake_header_command(_header_json("«Mathlib.Unsafe».«x✝»")), "plain, nonempty module components", ), - (_fake_header_deps("", returncode=1, stderr="unknown package\n"), "unknown package"), - (_fake_header_deps("not json"), "unrecognized output"), + (_fake_header_command("", returncode=1, stderr="unknown package\n"), "unknown package"), + (_fake_header_command("not json"), "unrecognized output"), ( - _fake_header_deps( + _fake_header_command( '{"imports":[{"errors":[],"result":{"isModule":false,' '"imports":[{"module":"Unsafe","isMeta":false}],"imports":[]}}]}' ), "unrecognized output", ), ( - _fake_header_deps( + _fake_header_command( '{"imports":[{"errors":[],"result":{"isModule":false,' '"imports":[{"module":"Mathlib","isMeta":false,"importAll":NaN}]}}]}' ), @@ -1223,20 +1223,20 @@ def test_header_check_fails_closed_on_rejected_or_unknown_output(command, messag @pytest.mark.parametrize( - ("deps_output", "expected_error"), + ("header_output", "expected_error"), [ - (_deps_json("Mathlib", "Unsafe.Mod"), "Disallowed imports: Unsafe"), - (_deps_json(errors=("bad header",)), "Rejected Lean header: bad header"), + (_header_json("Mathlib", "Unsafe.Mod"), "Disallowed imports: Unsafe"), + (_header_json(errors=("bad header",)), "Rejected Lean header: bad header"), ], ) def test_disposable_call_rejects_what_lean_reports_before_starting( - monkeypatch, deps_output, expected_error + monkeypatch, header_output, expected_error ): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( allowed_imports=frozenset({"Mathlib"}), warmup_imports=frozenset(), - header_deps_command=_fake_header_deps(deps_output), + header_command=_fake_header_command(header_output), ) ) monkeypatch.setattr( @@ -1276,7 +1276,7 @@ def test_failed_header_command_reports_its_last_output_line(monkeypatch, stdout, repl = repl_core.LeanRepl( repl_core.LeanReplConfig( warmup_imports=frozenset(), - header_deps_command=_fake_header_deps(stdout, returncode=1, stderr=stderr), + header_command=_fake_header_command(stdout, returncode=1, stderr=stderr), ) ) monkeypatch.setattr( @@ -1399,25 +1399,25 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ @pytest.mark.parametrize( - ("deps_output", "code", "frame"), + ("header_output", "code", "frame"), [ - (_deps_json(), "#check Nat", "import Mathlib\n#check Nat"), + (_header_json(), "#check Nat", "import Mathlib\n#check Nat"), ( - _deps_json("Mathlib.TraversalAnchor"), + _header_json("Mathlib.TraversalAnchor"), "import Mathlib.TraversalAnchor\n#check autoformWarmupMarker", "import Mathlib\nimport Mathlib.TraversalAnchor\n#check autoformWarmupMarker", ), ( - _deps_json("REPL.Frontend", is_module=True), + _header_json("REPL.Frontend", is_module=True), "module\npublic import REPL.Frontend\n", "module\npublic import REPL.Frontend\n", ), ( - _deps_json("REPL.Frontend", prelude=True), + _header_json("REPL.Frontend", prelude=True), "prelude\nimport REPL.Frontend\n#check Nat", "prelude\nimport REPL.Frontend\n#check Nat", ), - (_deps_json("Init", prelude=True), "prelude\nimport Init\n#check Nat", "prelude\nimport Init\n#check Nat"), + (_header_json("Init", prelude=True), "prelude\nimport Init\n#check Nat", "prelude\nimport Init\n#check Nat"), (_V4_27_INIT_ONLY % "false", "#check Nat", "import Mathlib\n#check Nat"), ( _V4_27_INIT_ONLY % "true", @@ -1429,12 +1429,12 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ (_FAST_PARSER_ORDINARY, "#check Nat", "#check Nat"), ], ) -def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monkeypatch, deps_output, code, frame): +def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monkeypatch, header_output, code, frame): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( allowed_imports=frozenset({"Mathlib", "REPL", "Init"}), warmup_imports=frozenset({"Mathlib"}), - header_deps_command=_fake_header_deps(deps_output), + header_command=_fake_header_command(header_output), ) ) frames = [] @@ -1455,7 +1455,7 @@ def test_disposable_call_rejects_disallowed_warmup_import(monkeypatch): repl_core.LeanReplConfig( allowed_imports=frozenset({"Mathlib"}), warmup_imports=frozenset({"Unsafe.Prelude"}), - header_deps_command=_fake_header_deps(_deps_json()), + header_command=_fake_header_command(_header_json()), ) ) monkeypatch.setattr( @@ -1493,7 +1493,7 @@ def test_header_check_never_spawns_after_its_shared_deadline(monkeypatch): def test_header_check_rejects_output_over_the_combined_limit(): with pytest.raises(ValueError, match="output exceeded 1024 bytes"): - _header_modules(_fake_header_deps("x" * 1025), max_buffer_bytes=1024) + _header_modules(_fake_header_command("x" * 1025), max_buffer_bytes=1024) def test_header_check_reaps_descendants_after_success(tmp_path): @@ -1506,7 +1506,7 @@ def test_header_check_reaps_descendants_after_success(tmp_path): f"open({str(process_record)!r}, 'w').write(json.dumps(" "{'child': child.pid, 'group': os.getpgrp()})); " "sys.stdin.read(); " - f"sys.stdout.write({_deps_json('Mathlib')!r})" + f"sys.stdout.write({_header_json('Mathlib')!r})" ) assert _header_modules([sys.executable, "-c", script]) == ["Mathlib"] @@ -1517,7 +1517,7 @@ def test_header_check_reaps_descendants_after_success(tmp_path): def _repl_failing_first_header_cleanup(monkeypatch, error: BaseException): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - warmup_imports=frozenset(), header_deps_command=_fake_header_deps(_deps_json("Mathlib")) + warmup_imports=frozenset(), header_command=_fake_header_command(_header_json("Mathlib")) ) ) real_kill = repl_core._kill_subprocesses diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 06d21bd2b..4c1bf86a5 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -620,7 +620,7 @@ def __init__(self, config): {"project_dir": str(project), "code": "#check Nat", "timeout": 1}, ) assert configs[0].max_retries == 0 - assert configs[0].header_deps_command == list( + assert configs[0].header_command == list( services.config.repl_header_command ) finally: From 27756d1fec0a9bb90b7ee38dd2933ce939084140 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:56:10 -0400 Subject: [PATCH 040/109] Refuse header output that parse_header.lean does not print The header decoder still accepted both schemas of plain lean --deps-json: a result without isPrelude, and the older shape with imports on the entry. Its comment called that output valid for allowlist checks, but it comes from the fast parser this branch replaced because it can miss imports the REPL loads. A LEAN_REPL_HEADER_CMD running lean --deps-json, as the default once did, validated imports with that parser and silently dropped the warmup. The decoder now requires an entry with exactly errors and result, and a result with an imports list and boolean isModule and isPrelude, as parse_header.lean always prints; anything else is unrecognized output and fails closed. The fast-parser and legacy fixtures move from the warmup-prefix table to the fail-closed table, the legacy half of the schema test goes, and the duplicate-key and NaN fixtures gain isPrelude so that each fails only for its intended reason. --- servers/repl/core.py | 44 +++++++------------ tests/test_repl_core_protocol.py | 74 +++++++++++++------------------- 2 files changed, 45 insertions(+), 73 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 86734c9fd..01a91a20c 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -452,7 +452,7 @@ class _LeanHeaderAnalysis: def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: - """Decode the ``lean --deps-json`` schemas printed by the header parser.""" + """Decode the JSON that parse_header.lean prints.""" try: payload = _decode_repl_json(stdout) if not isinstance(payload, dict) or set(payload) != {"imports"}: @@ -471,27 +471,18 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: if errors: raise ValueError(errors[0]) - has_result = "result" in entry - has_imports = "imports" in entry - if has_result == has_imports: + if set(entry) != {"errors", "result"}: raise ValueError - is_module: bool | None = None - is_prelude: bool | None = None - if has_result: - result = entry["result"] - if not isinstance(result, dict): - raise ValueError - imports = result.get("imports") - is_module = result.get("isModule") - if is_module is not None and type(is_module) is not bool: - raise ValueError - is_prelude = result.get("isPrelude") - if is_prelude is not None and type(is_prelude) is not bool: - raise ValueError - else: - imports = entry["imports"] + result = entry["result"] + if not isinstance(result, dict): + raise ValueError + imports = result.get("imports") if not isinstance(imports, list): raise ValueError + is_module = result.get("isModule") + is_prelude = result.get("isPrelude") + if type(is_module) is not bool or type(is_prelude) is not bool: + raise ValueError modules: list[str] = [] for item in imports: @@ -511,21 +502,16 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: raise ValueError( "Lean header imports must use plain, nonempty module components" ) - if has_result: - is_meta = item.get("isMeta") - if is_meta is not None and type(is_meta) is not bool: - raise ValueError + is_meta = item.get("isMeta") + if is_meta is not None and type(is_meta) is not bool: + raise ValueError if module != "Init": modules.append(module) # Imports can go in front only of a header that starts with neither - # ``module`` nor ``prelude``. Lean's implicit Init entries cannot say - # which: before v4.30 an ordinary header and ``prelude`` followed by - # ``import Init`` both report one plain Init. Output without both - # flags, such as plain ``lean --deps-json``, remains valid for allowlist - # checks but sends the validated frame exact. + # ``module`` nor ``prelude``. return _LeanHeaderAnalysis( modules=tuple(modules), - accepts_leading_imports=is_module is False and is_prelude is False, + accepts_leading_imports=not is_module and not is_prelude, ) except (ReplProtocolError, TypeError, KeyError, json.JSONDecodeError): raise ValueError("unrecognized output from the Lean header parser") from None diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 27503178d..a74d5add5 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1119,26 +1119,10 @@ def _header_modules(command: list[str], *, timeout: float = 10, **config) -> lis repl.close() -def test_header_check_accepts_current_and_legacy_lean_schemas(): - current = _fake_header_command(_header_json("Init", "Mathlib.Tactic")) - legacy = _fake_header_command( - json.dumps( - { - "imports": [ - { - "errors": [], - "imports": [ - {"module": "Init", "importAll": False}, - {"module": "Mathlib.Tactic", "importAll": False}, - ], - } - ] - } - ) - ) +def test_header_check_accepts_the_header_parser_schema(): + command = _fake_header_command(_header_json("Init", "Mathlib.Tactic")) - assert _header_modules(current) == ["Mathlib.Tactic"] - assert _header_modules(legacy) == ["Mathlib.Tactic"] + assert _header_modules(command) == ["Mathlib.Tactic"] def test_default_header_parser_selects_lean_from_the_lake_toolchain(): @@ -1181,6 +1165,29 @@ def write_parser(path, module): assert modules == ["Mathlib"] +# Plain ``lean --deps-json`` output, in its older and current schemas. Its fast +# parser can miss imports that the REPL loads. +_LEGACY_INIT_ONLY = json.dumps( + {"imports": [{"errors": [], "imports": [{"module": "Init", "importAll": False}]}]} +) +_FAST_PARSER_ORDINARY = json.dumps( + { + "imports": [ + { + "errors": [], + "result": { + "imports": [ + {"importAll": False, "isExported": True, "isMeta": False, "module": "Init"}, + {"importAll": False, "isExported": True, "isMeta": True, "module": "Init"}, + ], + "isModule": False, + }, + } + ] + } +) + + @pytest.mark.parametrize( ("command", "message"), [ @@ -1203,18 +1210,20 @@ def write_parser(path, module): (_fake_header_command("not json"), "unrecognized output"), ( _fake_header_command( - '{"imports":[{"errors":[],"result":{"isModule":false,' + '{"imports":[{"errors":[],"result":{"isModule":false,"isPrelude":false,' '"imports":[{"module":"Unsafe","isMeta":false}],"imports":[]}}]}' ), "unrecognized output", ), ( _fake_header_command( - '{"imports":[{"errors":[],"result":{"isModule":false,' + '{"imports":[{"errors":[],"result":{"isModule":false,"isPrelude":false,' '"imports":[{"module":"Mathlib","isMeta":false,"importAll":NaN}]}}]}' ), "unrecognized output", ), + (_fake_header_command(_LEGACY_INIT_ONLY), "unrecognized output"), + (_fake_header_command(_FAST_PARSER_ORDINARY), "unrecognized output"), ], ) def test_header_check_fails_closed_on_rejected_or_unknown_output(command, message): @@ -1370,32 +1379,12 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ assert repl.is_clean() -_LEGACY_INIT_ONLY = json.dumps( - {"imports": [{"errors": [], "imports": [{"module": "Init", "importAll": False}]}]} -) # The header parser on Lean v4.27.0 reports one plain Init import both for an # ordinary header and for ``prelude`` followed by ``import Init``. _V4_27_INIT_ONLY = ( '{"imports":[{"errors":[],"result":{"imports":[{"importAll":false,"isExported":true,' '"isMeta":false,"module":"Init"}],"isModule":false,"isPrelude":%s}}]}' ) -# Plain ``lean --deps-json`` output says nothing about ``prelude``. -_FAST_PARSER_ORDINARY = json.dumps( - { - "imports": [ - { - "errors": [], - "result": { - "imports": [ - {"importAll": False, "isExported": True, "isMeta": False, "module": "Init"}, - {"importAll": False, "isExported": True, "isMeta": True, "module": "Init"}, - ], - "isModule": False, - }, - } - ] - } -) @pytest.mark.parametrize( @@ -1424,9 +1413,6 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ "prelude\nimport Init\n#check Nat", "prelude\nimport Init\n#check Nat", ), - # Output that cannot prove an ordinary header gets nothing prefixed. - (_LEGACY_INIT_ONLY, "#check Nat", "#check Nat"), - (_FAST_PARSER_ORDINARY, "#check Nat", "#check Nat"), ], ) def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monkeypatch, header_output, code, frame): From 1decfbf49f51226c21fa82158feaab6f3c1e1fd8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:59:43 -0400 Subject: [PATCH 041/109] Tell a failed header check apart from a rejected header A non-zero exit of the header command, unrecognized parser output and output over the cap were all reported as "Rejected Lean header: ...", the label for a header that Lean or the module check refuses. The parser reports parse errors as JSON and exits 0, so these are failures of the check itself, such as the launcher run outside lake env, and an agent told its header was rejected would rewrite imports that are fine. They now raise RuntimeError reading "Lean header check failed: ...", which run_disposable reports as a plain REPL error. Parse errors and refused module components keep "Rejected Lean header:". The detail of a failed command was the last non-empty line of stderr, else of stdout. Lean follows an error line with indented terms, so a type mismatch reported a fragment of a type, and any stderr output, such as Lake's warning that the manifest is out of date, hid the error on stdout. The detail is now the first stdout line containing ": error:", then the last non-empty line of stderr, else of stdout. The fail-closed protocol test splits into a ValueError table and a RuntimeError table that expects the new label, and the failure-detail test adds a multi-line Lean error with and without that Lake warning. --- servers/repl/core.py | 40 +++++++++++++++++++++-------- tests/test_repl_core_protocol.py | 44 ++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 16 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 01a91a20c..169af835e 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -400,8 +400,8 @@ def close_stdin() -> None: stderr_open = False continue if len(stdout) + len(stderr) + len(chunk) > max_output_bytes: - raise ValueError( - f"Lean header parser output exceeded {max_output_bytes} bytes" + raise RuntimeError( + f"Lean header check failed: parser output exceeded {max_output_bytes} bytes" ) target.extend(chunk) @@ -428,14 +428,22 @@ def close_stdin() -> None: def _header_failure_detail(stdout: bytes, stderr: bytes, returncode: int) -> str: - """Return the last non-empty line of stderr, else of stdout, bounded. + """Return the line that names a failed header command's error, bounded. - A launcher that cannot start ends its stderr with the exception after a - traceback, and Lean prints elaboration errors on stdout. + Lean prints an elaboration error on stdout as a ``file:line:col: error:`` + line followed by indented terms, so the first such line wins, even over a + Lake warning on stderr. Otherwise the last non-empty line of stderr, else + of stdout: a launcher that cannot start ends its stderr with the exception + after a traceback. """ - for output in (stderr, stdout): - lines = output.decode(errors="replace").splitlines() - detail = next((line.strip() for line in reversed(lines) if line.strip()), "") + stdout_lines = stdout.decode(errors="replace").splitlines() + stderr_lines = stderr.decode(errors="replace").splitlines() + for lines in ( + (line for line in stdout_lines if ": error:" in line), + reversed(stderr_lines), + reversed(stdout_lines), + ): + detail = next((line.strip() for line in lines if line.strip()), "") if detail: if len(detail) > _HEADER_FAILURE_DETAIL_CHARS: detail = detail[:_HEADER_FAILURE_DETAIL_CHARS] + "..." @@ -514,11 +522,15 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: accepts_leading_imports=not is_module and not is_prelude, ) except (ReplProtocolError, TypeError, KeyError, json.JSONDecodeError): - raise ValueError("unrecognized output from the Lean header parser") from None + raise RuntimeError( + "Lean header check failed: unrecognized output from the Lean header parser" + ) from None except ValueError as error: if str(error): raise - raise ValueError("unrecognized output from the Lean header parser") from None + raise RuntimeError( + "Lean header check failed: unrecognized output from the Lean header parser" + ) from None def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: @@ -981,6 +993,11 @@ def is_clean(self) -> bool: def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: """Analyze the submitted header with the selected toolchain's Lean parser. + A header that the parser or the module check refuses raises ValueError. + A non-zero exit, unrecognized output or output over the cap means the + check itself failed and raises RuntimeError, since the parser reports + parse errors as JSON and exits 0. + The parser is this wrapper's process generation until close() verifies that it exited, so a failed cleanup is retried before the slot is reused. """ @@ -1008,7 +1025,8 @@ def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: self.close(deadline=deadline) returncode = process.returncode if returncode != 0: - raise ValueError(_header_failure_detail(stdout, stderr, returncode)) + detail = _header_failure_detail(stdout, stderr, returncode) + raise RuntimeError(f"Lean header check failed: {detail}") return _decode_header_analysis(stdout) def run_disposable( diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index a74d5add5..6371870ae 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1206,6 +1206,17 @@ def write_parser(path, module): _fake_header_command(_header_json("«Mathlib.Unsafe».«x✝»")), "plain, nonempty module components", ), + ], +) +def test_header_check_rejects_parse_errors_and_ambiguous_module_components(command, message): + with pytest.raises(ValueError, match=message): + _header_modules(command) + + +# The parser reports parse errors as JSON, so these are failures of the check. +@pytest.mark.parametrize( + ("command", "message"), + [ (_fake_header_command("", returncode=1, stderr="unknown package\n"), "unknown package"), (_fake_header_command("not json"), "unrecognized output"), ( @@ -1226,8 +1237,8 @@ def write_parser(path, module): (_fake_header_command(_FAST_PARSER_ORDINARY), "unrecognized output"), ], ) -def test_header_check_fails_closed_on_rejected_or_unknown_output(command, message): - with pytest.raises(ValueError, match=message): +def test_header_check_fails_closed_when_the_parser_fails(command, message): + with pytest.raises(RuntimeError, match=f"Lean header check failed: {message}"): _header_modules(command) @@ -1260,6 +1271,16 @@ def test_disposable_call_rejects_what_lean_reports_before_starting( assert repl.is_clean() +_LEAN_TYPE_MISMATCH = ( + "parse_header.lean:14:47: error: Application type mismatch: The argument\n" + " input\n" + "has type\n" + " String : Type\n" + "but is expected to have type\n" + " Parser.InputContext : Type\n" +) + + @pytest.mark.parametrize( ("stdout", "stderr", "detail"), [ @@ -1277,11 +1298,24 @@ def test_disposable_call_rejects_what_lean_reports_before_starting( "", "parse_header.lean:14:3: error: unknown constant 'Lean.Parser.parseHeader'", ), + # Lean follows the error line with indented terms, and Lake warns on + # stderr while the manifest is out of date. + ( + _LEAN_TYPE_MISMATCH, + "", + "parse_header.lean:14:47: error: Application type mismatch: The argument", + ), + ( + _LEAN_TYPE_MISMATCH, + "warning: manifest out of date: git revision of dependency 'repl' changed; " + "use `lake update repl` to update it\n", + "parse_header.lean:14:47: error: Application type mismatch: The argument", + ), ("", "", "exit status 1"), ("", "x" * 1000, "x" * repl_core._HEADER_FAILURE_DETAIL_CHARS + "..."), ], ) -def test_failed_header_command_reports_its_last_output_line(monkeypatch, stdout, stderr, detail): +def test_failed_header_command_reports_its_error_line(monkeypatch, stdout, stderr, detail): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( warmup_imports=frozenset(), @@ -1294,7 +1328,7 @@ def test_failed_header_command_reports_its_last_output_line(monkeypatch, stdout, lambda *args, **kwargs: pytest.fail("a failed header check must not start Lean"), ) - assert repl.run_disposable("#check Nat") == {"repl_error": f"Rejected Lean header: {detail}"} + assert repl.run_disposable("#check Nat") == {"repl_error": f"Lean header check failed: {detail}"} assert repl.is_clean() @@ -1478,7 +1512,7 @@ def test_header_check_never_spawns_after_its_shared_deadline(monkeypatch): def test_header_check_rejects_output_over_the_combined_limit(): - with pytest.raises(ValueError, match="output exceeded 1024 bytes"): + with pytest.raises(RuntimeError, match="Lean header check failed: parser output exceeded 1024 bytes"): _header_modules(_fake_header_command("x" * 1025), max_buffer_bytes=1024) From b7b12970b1c579258b55e073061532204edf21a8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:03:24 -0400 Subject: [PATCH 042/109] Cover each fail-closed branch of the header decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most checks in _decode_header_analysis, the allowlist's trust boundary, had no test, so any of them could be deleted unnoticed. Dropping the single-entry check, for example, would leave a second entry's imports unread. A protocol test now calls the decoder directly, with no subprocess per case, on payloads that each break one rule of parse_header.lean's schema: the payload, entry count and entry; the errors list; a missing, extra or non-object result; the imports list and the isModule and isPrelude flags; and each item's module and isMeta. Each must fail as unrecognized output, as must invalid UTF-8. Mathlib/X, Mathlib\X and Mathlib..X must be refused as ambiguous module components; the existing cases all contained «, so the slash and empty-component checks never decided one. Deleting any decoder check now fails a case, except three that later checks back up: the payload and entry-list type checks and the nonempty-error check. --- tests/test_repl_core_protocol.py | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 6371870ae..c98c4bd62 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1242,6 +1242,56 @@ def test_header_check_fails_closed_when_the_parser_fails(command, message): _header_modules(command) +_NO_IMPORTS = {"imports": [], "isModule": False, "isPrelude": False} + + +# Each payload breaks one rule of parse_header.lean's schema. +@pytest.mark.parametrize( + "payload", + [ + [], + {"imports": [{"errors": [], "result": _NO_IMPORTS}], "errors": []}, + {"imports": {}}, + {"imports": []}, + {"imports": [{"errors": [], "result": _NO_IMPORTS}] * 2}, + {"imports": [[]]}, + {"imports": [{"result": _NO_IMPORTS}]}, + {"imports": [{"errors": "bad header", "result": _NO_IMPORTS}]}, + {"imports": [{"errors": [""]}]}, + {"imports": [{"errors": [1]}]}, + {"imports": [{"errors": []}]}, + {"imports": [{"errors": [], "imports": []}]}, + {"imports": [{"errors": [], "result": _NO_IMPORTS, "imports": []}]}, + {"imports": [{"errors": [], "result": []}]}, + {"imports": [{"errors": [], "result": {"isModule": False, "isPrelude": False}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "imports": {}}}]}, + {"imports": [{"errors": [], "result": {"imports": [], "isPrelude": False}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "isModule": 0}}]}, + {"imports": [{"errors": [], "result": {"imports": [], "isModule": False}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "isPrelude": None}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "imports": ["Mathlib"]}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "imports": [{"isMeta": False}]}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "imports": [{"module": ""}]}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "imports": [{"module": 1}]}}]}, + {"imports": [{"errors": [], "result": {**_NO_IMPORTS, "imports": [{"module": "Mathlib", "isMeta": 1}]}}]}, + ], +) +def test_header_decoder_fails_closed_on_unrecognized_output(payload): + with pytest.raises(RuntimeError, match="Lean header check failed: unrecognized output"): + repl_core._decode_header_analysis(json.dumps(payload).encode()) + + +def test_header_decoder_fails_closed_on_invalid_utf8(): + with pytest.raises(RuntimeError, match="Lean header check failed: unrecognized output"): + repl_core._decode_header_analysis(b"\xff") + + +@pytest.mark.parametrize("module", ["Mathlib/X", "Mathlib\\X", "Mathlib..X"]) +def test_header_decoder_refuses_path_like_or_empty_module_components(module): + with pytest.raises(ValueError, match="plain, nonempty module components"): + repl_core._decode_header_analysis(_header_json(module).encode()) + + @pytest.mark.parametrize( ("header_output", "expected_error"), [ From 1d051b6012ace189cf684477b0aae2a2106b0ab4 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:03:59 -0400 Subject: [PATCH 043/109] Test the header output cap across stdout and stderr together test_header_check_rejects_output_over_the_combined_limit wrote 1025 bytes to stdout alone, so a cap on each stream separately would also have passed it, although the cap is one limit on both streams combined. The fake parser now writes 600 bytes to each stream under a 1024-byte cap. The combined cap refuses that output; a per-stream cap would let it through to the decoder, which reports unrecognized output instead. --- tests/test_repl_core_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index c98c4bd62..e7cdb313f 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1563,7 +1563,7 @@ def test_header_check_never_spawns_after_its_shared_deadline(monkeypatch): def test_header_check_rejects_output_over_the_combined_limit(): with pytest.raises(RuntimeError, match="Lean header check failed: parser output exceeded 1024 bytes"): - _header_modules(_fake_header_command("x" * 1025), max_buffer_bytes=1024) + _header_modules(_fake_header_command("x" * 600, stderr="y" * 600), max_buffer_bytes=1024) def test_header_check_reaps_descendants_after_success(tmp_path): From 78d08924631184ff95f6831c687e35977ac17ed8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:05:12 -0400 Subject: [PATCH 044/109] Spawn the REPL and the header parser through one helper _check_header copied start()'s spawn block: the clean environment plus config.env, Popen with three pipes in a new session, and the published process group id that lets close() retire the whole group. A later change to spawn hygiene would have had to be made twice. LeanRepl._spawn(command) now does all of that and returns the process. start() calls it inside its existing try, so an interrupted publication is still retired, and _check_header calls it with the header command. --- servers/repl/core.py | 48 ++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 169af835e..dcc5389e8 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -855,20 +855,8 @@ def remaining() -> float: raise TimeoutError(f"REPL startup timed out after {timeout:g} seconds") return value - env = _inherit_clean_env() - env.update(self.config.env) - try: - self.process = subprocess.Popen( - self.config.repl_command, - cwd=self.cwd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - start_new_session=True, - ) - self._process_group_id = self.process.pid + self._spawn(self.config.repl_command) self._retire_pending = False self._stderr_bytes = 0 self._stderr_tail.clear() @@ -990,6 +978,26 @@ def is_clean(self) -> bool: """Return whether this wrapper owns no process generation.""" return self.process is None and self._process_group_id is None + def _spawn(self, command: list[str]) -> subprocess.Popen: + """Start ``command`` as this wrapper's process generation. + + The child leads its own session, and close() retires that whole + process group through the id published here. + """ + env = _inherit_clean_env() + env.update(self.config.env) + self.process = subprocess.Popen( + command, + cwd=self.cwd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=True, + ) + self._process_group_id = self.process.pid + return self.process + def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: """Analyze the submitted header with the selected toolchain's Lean parser. @@ -1003,19 +1011,7 @@ def _check_header(self, code: str, deadline: float) -> _LeanHeaderAnalysis: """ if deadline <= time.monotonic(): raise TimeoutError("timed out checking the Lean header") - env = _inherit_clean_env() - env.update(self.config.env) - self.process = subprocess.Popen( - self.config.header_command, - cwd=self.cwd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - start_new_session=True, - ) - process = self.process - self._process_group_id = process.pid + process = self._spawn(self.config.header_command) stdout, stderr = _communicate_bounded( process, code.encode(), From 2e5cc6ad285fe04d62e13be46a899b9760a17bcf Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:06:45 -0400 Subject: [PATCH 045/109] Explain the prelude index and drop a redundant with_traceback parse_header.lean reads isPrelude from header.raw[1] without saying why that index is the prelude. Its comment now says that the header is module? prelude? import*, as Lean's header parser defines it on every supported release, so raw[1] is the optional prelude. run_disposable re-raised a request error with request_error.with_traceback(request_error.__traceback__), the only with_traceback in the repository. That behaves exactly like raise request_error, with the same traceback, context and cause, so it now says that. --- servers/repl/core.py | 2 +- servers/repl/parse_header.lean | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index dcc5389e8..a448438ec 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -1164,7 +1164,7 @@ def remaining(phase: str) -> float: ) from cleanup_error if request_error is not None: - raise request_error.with_traceback(request_error.__traceback__) + raise request_error return result def run(self, code: str, env_id: int | None = None, timeout: float | None = None) -> dict[str, Any]: diff --git a/servers/repl/parse_header.lean b/servers/repl/parse_header.lean index 86a489d8c..bedab87b0 100644 --- a/servers/repl/parse_header.lean +++ b/servers/repl/parse_header.lean @@ -26,6 +26,7 @@ def main : IO Unit := do (toJson imp).setObjVal! "module" (toJson (Name.toStringWithSep "." true imp.module)) -- Before v4.30 an ordinary header and `prelude` followed by `import Init` -- both report one plain `Init` import, so also say whether `prelude` is there. + -- `header` is `module? prelude? import*`, so `header.raw[1]` is the optional `prelude`. let result := (toJson moduleHeader).setObjVal! "imports" (toJson imports) |>.setObjVal! "isPrelude" (toJson !header.raw[1].isNone) entry := entry.setObjVal! "result" result From 76a23c07c23bb440ac9b8c6634d683e718f2e105 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:52:53 -0400 Subject: [PATCH 046/109] Test how run_disposable maps each REPL response outcome No test covered how run_disposable maps the outcome of its one frame: a REPL command error, a malformed response, ReplOutcomeUnknown, or the two backlog branches. Deleting either outcome_unknown flag, or treating a command error as an unknown outcome, failed no test, although that flag is what tells the user a request was not retried. A parametrized test now stubs _run with each outcome and asserts the exact result and a single _run call, so the no-retry invariant keeps a REPL-level test. The run() tests whose contracts still apply to the disposable path are ported beside their originals: each malformed response shape is an unknown outcome, a timeout or a cleanup failure after the full write keeps the unknown outcome, stderr overflow and invalid or noncanonical JSON are unknown outcomes, a backlog response drops its environment, and a request timeout still reaps a slow process within the cleanup budget. The originals stay until run() is removed. --- tests/test_repl_core_protocol.py | 244 +++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index e7cdb313f..0d9383aa6 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -725,6 +725,13 @@ def _repl_with_process(process: _PipeProcess, *, chunk_size: int = 4096, max_buf return repl +def _disposable_repl_with_process(monkeypatch, process: _PipeProcess, **config): + """Return a repl whose disposable call starts on ``process``.""" + repl = _repl_with_process(process, **config) + monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", process)) + return repl + + def _patch_pipe_reads(monkeypatch, process: _PipeProcess): real_read = os.read @@ -1067,6 +1074,100 @@ def close(**kwargs): assert error.value.result == {"messages": []} +_MALFORMED_RESPONSE = { + "repl_error": "Lean REPL returned a malformed response for the requested command.", + "outcome_unknown": True, +} + + +@pytest.mark.parametrize( + ("outcome", "expected"), + [ + ({"message": "bad"}, {"repl_error": "bad"}), + ([], _MALFORMED_RESPONSE), + (repl_core.ReplOutcomeUnknown("x"), {"repl_error": "x", "outcome_unknown": True}), + (repl_core.ReplStderrBacklog("x", {"message": "bad"}), {"repl_error": "bad"}), + (repl_core.ReplStderrBacklog("x", []), _MALFORMED_RESPONSE), + ( + repl_core.ReplStderrBacklog( + "x", + {"env": 7, "sorries": [{"goal": "False", "proofState": 9}]}, + ), + {"sorries": [{"goal": "False"}]}, + ), + ], +) +def test_disposable_call_maps_each_response_outcome_without_a_retry( + monkeypatch, outcome, expected +): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) + ) + frames = [] + + def run(**kwargs): + frames.append(kwargs["code"]) + if isinstance(outcome, Exception): + raise outcome + return outcome + + monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "_run", run) + + assert repl.run_disposable("#eval 1", timeout=1) == expected + assert frames == ["#eval 1"] + + +@pytest.mark.parametrize( + "raw_response", + [ + {}, + {"messages": []}, + {"messages": {}}, + { + "env": 1, + "messages": [ + { + "severity": [], + "data": "boom", + "pos": {"line": 1, "column": 1}, + } + ], + }, + { + "env": 1, + "messages": [ + { + "severity": {}, + "data": "boom", + "pos": {"line": 1, "column": 1}, + } + ], + }, + {"env": True}, + {"sorries": [{"goal": "False"}]}, + ], +) +def test_disposable_call_reports_every_malformed_response_as_unknown( + monkeypatch, raw_response +): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) + ) + frames = [] + monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr( + repl, + "_run", + lambda **kwargs: frames.append(kwargs["code"]) or raw_response, + ) + + result = repl.run_disposable("#eval 1", timeout=1) + + assert result["outcome_unknown"] is True + assert frames == ["#eval 1"] + + def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( @@ -1683,6 +1784,28 @@ def fail_after_send(code, env_id, timeout, mark_sent): assert repl.process is None +def test_disposable_timeout_after_full_write_is_not_retried(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) + ) + calls = [] + monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", object())) + monkeypatch.setattr(repl, "close", lambda **kwargs: setattr(repl, "process", None)) + + def fail_after_send(code, env_id, timeout, mark_sent): + calls.append((code, env_id)) + mark_sent() + raise TimeoutError("response timed out") + + monkeypatch.setattr(repl, "_run_io", fail_after_send) + response = repl.run_disposable("#eval 1", timeout=1) + + assert response["outcome_unknown"] is True + assert "fully sent" in response["repl_error"] + assert calls == [("#eval 1", None)] + assert repl.process is None + + def test_cleanup_failure_after_full_write_preserves_unknown_outcome(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( @@ -1721,6 +1844,36 @@ def fail_after_send(code, env_id, timeout, mark_sent): assert repl.process is not None +def test_disposable_cleanup_failure_after_full_write_keeps_the_unknown_outcome(monkeypatch): + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) + ) + close_calls = 0 + + def close(**kwargs): + nonlocal close_calls + close_calls += 1 + # The slot is clean when the call begins; every later cleanup fails. + if close_calls > 1: + raise RuntimeError("cleanup failed") + + def fail_after_send(code, env_id, timeout, mark_sent): + mark_sent() + raise TimeoutError("response timed out") + + monkeypatch.setattr(repl, "close", close) + monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", object())) + monkeypatch.setattr(repl, "_run_io", fail_after_send) + + with pytest.raises(repl_core.ReplCleanupError) as error: + repl.run_disposable("#eval 1", timeout=1) + + assert error.value.result["outcome_unknown"] is True + assert "process cleanup also failed" in error.value.result["repl_error"] + assert close_calls == 3 + assert repl.process is not None + + @pytest.mark.parametrize("request_sent", [False, True]) def test_run_closes_and_reraises_cancellation(monkeypatch, request_sent): repl = repl_core.LeanRepl( @@ -2021,6 +2174,19 @@ def test_process_stderr_overflow_without_a_response_is_not_retried(monkeypatch): assert repl.process is None +def test_disposable_stderr_overflow_without_a_response_is_not_retried(monkeypatch): + with ExitStack() as stack: + process = _PipeProcess(stack, [], stderr=b"x" * 32) + repl = _disposable_repl_with_process(monkeypatch, process, max_buffer_bytes=16) + _patch_pipe_reads(monkeypatch, process) + + response = repl.run_disposable("#check Nat", timeout=1) + + assert "execution outcome is unknown and was not retried" in response["repl_error"] + assert response["outcome_unknown"] is True + assert repl.process is None + + def test_explicit_environment_preserves_an_unknown_outcome(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [], stderr=b"x" * 32) @@ -2315,6 +2481,22 @@ def test_backlog_response_drops_environment_owned_by_the_recycled_process(monkey assert repl.process is None +def test_disposable_backlog_response_drops_the_environment_of_the_retired_process(monkeypatch): + with ExitStack() as stack: + process = _PipeProcess( + stack, + [b'{"env":9,"messages":[]}\n\n'], + stderr=b"e" * 64, + ) + repl = _disposable_repl_with_process(monkeypatch, process, chunk_size=30, max_buffer_bytes=32) + _patch_pipe_reads(monkeypatch, process) + + response = repl.run_disposable("#check Nat", timeout=5) + + assert response == {"messages": []} + assert repl.process is None + + def test_env_scoped_request_refuses_to_outlive_the_recycled_process(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [b'{"env": 9, "messages": []}\n\n'], stderr=b"e" * 40) @@ -2487,6 +2669,20 @@ def test_invalid_json_with_a_stderr_backlog_is_not_retried(monkeypatch): assert process.stderr_bytes == b"e" * 4 +def test_disposable_invalid_json_with_a_stderr_backlog_is_not_retried(monkeypatch): + with ExitStack() as stack: + process = _PipeProcess(stack, [b"not-json\n\n"], stderr=b"e" * 40) + repl = _disposable_repl_with_process(monkeypatch, process, chunk_size=18, max_buffer_bytes=20) + _patch_pipe_reads(monkeypatch, process) + + response = repl.run_disposable("#check Nat", timeout=5) + + assert response["outcome_unknown"] is True + assert "response frame was malformed" in response["repl_error"] + assert repl.process is None + assert process.stderr_bytes == b"e" * 4 + + def test_wire_protocol_rejects_invalid_json(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [b"not-json\n\n"]) @@ -2516,6 +2712,25 @@ def test_wire_protocol_rejects_noncanonical_response_json(monkeypatch, response) assert repl.process is None +@pytest.mark.parametrize( + "response", + [ + b'{"env":1,"env":2}\n\n', + b'{"env":NaN}\n\n', + ], +) +def test_disposable_call_rejects_noncanonical_response_json(monkeypatch, response): + with ExitStack() as stack: + process = _PipeProcess(stack, [response]) + repl = _disposable_repl_with_process(monkeypatch, process) + _patch_pipe_reads(monkeypatch, process) + + result = repl.run_disposable("#check Nat", timeout=1) + + assert result["outcome_unknown"] is True + assert repl.process is None + + def test_wire_protocol_rejects_oversized_response(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [b'{"data":"0123456789"}\n\n']) @@ -2599,6 +2814,35 @@ def slow_reap(process, process_group_id, deadline=None): repl.close() +def test_disposable_timeout_reaps_a_slow_to_exit_process_without_cleanup_failure( + monkeypatch, +): + real_kill = repl_core._kill_subprocesses + + def slow_reap(process, process_group_id, deadline=None): + # A large Lean process needs real time to exit after SIGKILL. + if deadline is not None and deadline - time.monotonic() < 0.5: + raise RuntimeError("timed out reaping the Lean REPL process") + real_kill(process, process_group_id, deadline) + + monkeypatch.setattr(repl_core, "_kill_subprocesses", slow_reap) + repl = repl_core.LeanRepl( + repl_core.LeanReplConfig( + repl_command=[sys.executable, "-c", _RESPONSIVE_FAKE_REPL], + warmup_imports=frozenset(), + validate_imports=False, + ) + ) + try: + response = repl.run_disposable("HANG", timeout=0.5) + + assert response.get("outcome_unknown") is True + assert "cleanup also failed" not in response["repl_error"] + assert repl.is_clean() + finally: + repl.close() + + def test_worker_whose_close_failed_is_restarted_before_the_next_request( monkeypatch, ): From de9dcf45b3d3c0ec4d6129e1e6c09a508239c70f Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:56:43 -0400 Subject: [PATCH 047/109] Delete the resident REPL path that the runtime no longer reaches The pool calls only run_disposable, so no production code reached LeanRepl.run or what only it used: restart, is_alive, the memory restart, the warm start with its smoke test, the base environment, max_retries, mem_restart_ratio and the lexical _split_imports_and_body. The exported run() still checked imports with that splitter, which finds no imports behind a leading block comment, and its tests kept environment chaining under maintenance after servers/README.md stopped promising any process-owned state. That code is gone, with ReplProcessRestarted, which only run() raised (ReplOutcomeUnknown now subclasses RuntimeError), the retire-pending flag that only is_alive read, and the runtime's max_retries=0. start() loses its warm parameter, and its docstring and the stderr ceiling comment no longer mention warming or restarts. run_disposable starts from no modules instead of a lexical split whose result it never used: only the header Lean parses decides validation and the warmup prefix. The tests of the removed path go too: the splitter, run, environment, restart, retry and memory-restart tests, the multi-command stderr tests that a one-frame child cannot reach, the pool's retry test and the max_retries assertion. The contracts that still apply already have run_disposable tests. --- servers/lean_runtime.py | 1 - servers/repl/core.py | 317 +------------ tests/test_repl_core_protocol.py | 756 +----------------------------- tests/test_repl_pool_lifecycle.py | 30 -- tests/test_shared_lean_runtime.py | 1 - 5 files changed, 13 insertions(+), 1092 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 6afbbfa40..77feaca51 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -836,7 +836,6 @@ def default_repl_factory( repl_command=list(self.config.repl_command), header_command=list(self.config.repl_header_command), num_repls=self.config.repl_workers_per_project, - max_retries=0, ) ) except LeanReplPoolStartupError as error: diff --git a/servers/repl/core.py b/servers/repl/core.py index a448438ec..c23ce2091 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -9,7 +9,6 @@ import errno import json import os -import random import select import signal import subprocess @@ -23,7 +22,6 @@ logger = getLogger(__name__) DEFAULT_MAX_DIAGNOSTICS = 10 -DEFAULT_SMOKE_TEST_TIMEOUT = 10 DEFAULT_REPL_STARTUP_TIMEOUT = 180.0 DEFAULT_REPL_CLEANUP_SECONDS = 2.0 REPL_ABORT_TERM_SECONDS = 0.5 @@ -533,30 +531,6 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: ) from None -def _split_imports_and_body(code: str) -> tuple[list[str], str, int]: - """Split Lean code into import statements and body. - - Returns (import_names, body, header_line_count). - """ - lines = code.split("\n") - imports: list[str] = [] - body_start = 0 - - for i, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("import "): - imports.append(stripped[7:].strip()) - body_start = i + 1 - elif stripped == "" or stripped.startswith("--"): - if imports: - body_start = i + 1 - else: - break - - body = "\n".join(lines[body_start:]) - return imports, body, body_start - - # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- @@ -579,7 +553,6 @@ class LeanReplConfig: chunk_size: int = 4096 instance_mem_limit_gb: int = 16 - max_retries: int = 1 allowed_imports: frozenset[str] = ALLOWED_IMPORTS warmup_imports: frozenset[str] = WARMUP_IMPORTS @@ -588,9 +561,8 @@ class LeanReplConfig: header_command: list[str] = field(default_factory=default_header_command) # stdout is capped per response. stderr has no protocol framing, so its - # ceiling applies to the entire process generation and resets on restart. + # ceiling applies to the entire process generation. max_buffer_bytes: int = 10 * 1024 * 1024 - mem_restart_ratio: float = 0.9 validate_imports: bool = True @@ -771,11 +743,7 @@ class ReplProcessExited(RuntimeError): """Raised when the REPL process dies unexpectedly.""" -class ReplProcessRestarted(RuntimeError): - """Raised when the REPL restarts and env_id state is lost.""" - - -class ReplOutcomeUnknown(ReplProcessRestarted): +class ReplOutcomeUnknown(RuntimeError): """Raised when stderr poisoning leaves a sent command's outcome unknown.""" @@ -813,17 +781,10 @@ def __init__(self, config: LeanReplConfig) -> None: self.cwd = config.cwd self.process: subprocess.Popen | None = None self._process_group_id: int | None = None - # Set while a process handle is owned but a close() has not completed. - self._retire_pending = False self.request_timeout = config.request_timeout - self.max_retries = config.max_retries - - self._base_env_id: int | None = None self.chunk_size: int = config.chunk_size - self.mem_limit_gb: int = config.instance_mem_limit_gb - self._process_lock = threading.Lock() # stderr has no command boundary. Account for it monotonically across one # process generation and retain only a bounded tail for diagnostics. @@ -834,13 +795,8 @@ def __init__(self, config: LeanReplConfig) -> None: if config.validate_imports and config.allowed_imports: self._allowed_import_roots = config.allowed_imports - def start( - self, - startup_timeout: float | None = None, - *, - warm: bool = True, - ) -> None: - """Start and warm the Lean REPL within one startup deadline.""" + def start(self, startup_timeout: float | None = None) -> None: + """Start a Lean REPL child within one startup deadline.""" if os.name != "posix": raise RuntimeError("Lean REPL transport requires a POSIX platform") timeout = self.config.startup_timeout if startup_timeout is None else min( @@ -849,59 +805,10 @@ def start( ) deadline = time.monotonic() + timeout - def remaining() -> float: - value = deadline - time.monotonic() - if value <= 0: - raise TimeoutError(f"REPL startup timed out after {timeout:g} seconds") - return value - try: self._spawn(self.config.repl_command) - self._retire_pending = False self._stderr_bytes = 0 self._stderr_tail.clear() - - if warm and self.config.warmup_imports: - header = "\n".join( - f"import {root}" for root in self.config.warmup_imports - ) - logger.info( - "Loading imports at startup: %s", - self.config.warmup_imports, - ) - resp = self._run(code=header, env_id=None, timeout=remaining()) - environment, messages = _validate_command_response( - resp, - context="startup imports", - require_environment=True, - ) - errors = [message for message in messages if message["severity"] == "error"] - if errors: - error_details = "\n".join(message["data"] for message in errors) - raise RuntimeError(f"Import preloading failed:\n{error_details}") - - self._base_env_id = environment - - smoke = self._run( - code="#check Nat", - env_id=self._base_env_id, - timeout=min(DEFAULT_SMOKE_TEST_TIMEOUT, remaining()), - ) - _, smoke_messages = _validate_command_response( - smoke, - context="the startup smoke test", - require_environment=True, - ) - smoke_errors = [ - message - for message in smoke_messages - if message["severity"] == "error" - ] - if smoke_errors: - error_details = "; ".join(message["data"] for message in smoke_errors) - raise RuntimeError( - f"REPL smoke test failed — LEAN_PATH may be misconfigured. Errors: {error_details}" - ) except BaseException: try: self.close(deadline=deadline) @@ -925,7 +832,6 @@ def close(self, *, deadline: float | None = None) -> None: process_group_id = self._process_group_id try: if process is not None: - self._retire_pending = True if process_group_id is None: try: process_group_id = process.pid @@ -940,36 +846,10 @@ def close(self, *, deadline: float | None = None) -> None: _kill_subprocesses(process, process_group_id, deadline) self.process = None self._process_group_id = None - self._retire_pending = False finally: - self._base_env_id = None self._stderr_bytes = 0 self._stderr_tail.clear() - def restart(self, timeout: float | None = None) -> None: - """Restart the Lean REPL process within an optional total timeout.""" - deadline = time.monotonic() + timeout if timeout is not None else None - self.close(deadline=deadline) - if deadline is None: - self.start() - return - remaining = deadline - time.monotonic() - if remaining <= 0: - raise TimeoutError(f"REPL restart timed out after {timeout:g} seconds") - self.start(startup_timeout=remaining) - - def is_alive(self) -> bool: - """Conservatively check without reaping the process-group leader. - - A process whose close() failed is not alive even while its leader is - unreaped, so the next request retries cleanup instead of dispatching. - """ - return ( - self.process is not None - and not self._retire_pending - and getattr(self.process, "returncode", None) is None - ) - def get_memory_usage(self) -> float: """Return memory usage in GB.""" return _get_process_memory_gb(self.process) @@ -1052,7 +932,7 @@ def remaining(phase: str) -> float: self.close(deadline=deadline) if os.name != "posix": raise RuntimeError("Lean REPL transport requires a POSIX platform") - imports, _, _ = _split_imports_and_body(code) + modules: tuple[str, ...] = () accepts_leading_imports = False needs_header_analysis = bool(self.config.warmup_imports) or ( self.config.validate_imports @@ -1064,10 +944,9 @@ def remaining(phase: str) -> float: except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} else: - imports = list(header.modules) + modules = header.modules accepts_leading_imports = header.accepts_leading_imports - submitted_modules = set(imports) - submitted_roots = {statement.split(".")[0] for statement in imports} + submitted_roots = {module.split(".")[0] for module in modules} if ( result is None and self.config.validate_imports @@ -1087,18 +966,13 @@ def remaining(phase: str) -> float: added_imports = tuple( root for root in sorted(self.config.warmup_imports) - if accepts_leading_imports and root not in submitted_modules + if accepts_leading_imports and root not in modules ) prefix = "\n".join( f"import {root}" for root in added_imports ) command = f"{prefix}\n{code}" if prefix else code - # Do not send startup import or smoke-test frames. The - # submitted command is the generation's only request. - self.start( - startup_timeout=remaining("disposable child startup"), - warm=False, - ) + self.start(startup_timeout=remaining("disposable child startup")) response = self._run( code=command, env_id=None, @@ -1167,179 +1041,6 @@ def remaining(phase: str) -> float: raise request_error return result - def run(self, code: str, env_id: int | None = None, timeout: float | None = None) -> dict[str, Any]: - """Send code to the REPL within one deadline across recovery attempts.""" - timeout = self.request_timeout if timeout is None else timeout - deadline = time.monotonic() + timeout - - def remaining() -> float: - value = deadline - time.monotonic() - if value <= 0: - raise TimeoutError(f"REPL command timed out after {timeout:g} seconds") - return value - - run_from_env = env_id is not None - max_retries = 0 if run_from_env else self.max_retries - - header_line_count = 0 - if not run_from_env: - imports, code, header_line_count = _split_imports_and_body(code) - - if self.config.validate_imports and self._allowed_import_roots is not None: - submitted_roots = {stmt.split(".")[0] for stmt in imports} - disallowed = submitted_roots - self._allowed_import_roots - if disallowed: - return { - "repl_error": ( - f"Disallowed imports: {', '.join(sorted(disallowed))}. " - f"Allowed roots: {', '.join(sorted(self._allowed_import_roots))}." - ) - } - - last_exception: Exception | None = None - with self._process_lock: - if run_from_env and not self.is_alive(): - self.close(deadline=deadline) - raise ReplProcessRestarted( - "REPL process restarted before the request; environment state was lost" - ) - - process_before_memory_check = self.process - try: - if not self.is_alive(): - self.restart(timeout=remaining()) - self._check_memory_and_maybe_restart(timeout=remaining()) - except (TimeoutError, RuntimeError) as error: - self.close(deadline=deadline) - if run_from_env: - raise ReplProcessRestarted(str(error)) from error - return {"repl_error": str(error)} - - if run_from_env and self.process is not process_before_memory_check: - raise ReplProcessRestarted( - "REPL process restarted before the request; environment state was lost" - ) - - for i in range(max_retries + 1): - try: - dispatch_env_id = env_id if run_from_env else self._base_env_id - resp = self._run( - code=code, - env_id=dispatch_env_id, - timeout=remaining(), - ) - _validate_command_response( - resp, - context="the requested command", - require_environment=True, - ) - _adjust_line_numbers(resp, header_line_count) - return resp - except ReplStderrBacklog as e: - # _run() already retired the process, so nothing can inherit the - # undrained stderr; close() here is an idempotent assertion of - # that. The response is valid, so a plain request still receives - # it. An env-scoped request cannot transparently outlive the - # process that held its environment, so it is told loudly. - logger.error("%s", e) - self.close(deadline=deadline) - if run_from_env: - raise ReplProcessRestarted(str(e)) from e - try: - _validate_command_response( - e.response, - context="the requested command", - require_environment=True, - ) - except ReplCommandError as error: - return {"repl_error": str(error)} - except ReplProtocolError as error: - return { - "repl_error": str(error), - "outcome_unknown": True, - } - # The command's diagnostics remain valid, but any environment - # identifier belongs to the process _run() just retired. - response = _without_process_handles(e.response) - _adjust_line_numbers(response, header_line_count) - return response - except ReplOutcomeUnknown as e: - # The request was fully written, so replay could execute it - # twice. Retire the process and report the unknown outcome - # without entering the ordinary retry path. - logger.error("%s", e) - message = str(e) - try: - self.close(deadline=deadline) - except Exception as cleanup_error: - message += ( - f"; process cleanup also failed: {cleanup_error}" - ) - if run_from_env: - raise ReplOutcomeUnknown(message) from e - return {"repl_error": message, "outcome_unknown": True} - except ReplCommandError as e: - logger.error("Lean REPL rejected the command: %s", e) - return {"repl_error": str(e)} - except ReplProtocolError as e: - logger.error("%s", e) - message = str(e) - try: - self.close(deadline=deadline) - except Exception as cleanup_error: - message += ( - f"; process cleanup also failed: {cleanup_error}" - ) - if run_from_env: - raise ReplProcessRestarted(message) from e - return {"repl_error": message, "outcome_unknown": True} - except ReplProcessExited as e: - last_exception = e - logger.error("REPL process exited: %s. Attempt %d/%d.", e, i + 1, max_retries + 1) - if run_from_env: - self.close(deadline=deadline) - raise ReplProcessRestarted(str(e)) from e - except (TimeoutError, RuntimeError, json.JSONDecodeError) as e: - last_exception = e - logger.error("Error running command: %s. Attempt %d/%d.", e, i + 1, max_retries + 1) - if run_from_env: - self.close(deadline=deadline) - raise ReplProcessRestarted(str(e)) from e - - if i >= max_retries: - self.close(deadline=deadline) - break - - backoff = min(2**i, 30) + random.uniform(0, 1) - try: - if backoff >= remaining(): - raise TimeoutError( - f"REPL command timed out after {timeout:g} seconds" - ) - time.sleep(backoff) - self.restart(timeout=remaining()) - except (TimeoutError, RuntimeError) as error: - last_exception = error - self.close(deadline=deadline) - break - logger.error("Exceeded maximum retries for Lean REPL command") - return {"repl_error": str(last_exception)} - - def _check_memory_and_maybe_restart(self, timeout: float | None = None) -> None: - """Proactively restart if memory usage is near the limit.""" - if self.mem_limit_gb <= 0 or self.config.mem_restart_ratio <= 0: - return - try: - usage_gb = self.get_memory_usage() - threshold_gb = self.mem_limit_gb * self.config.mem_restart_ratio - if usage_gb >= threshold_gb: - logger.info("REPL memory %.2fGB >= threshold %.2fGB, restarting...", usage_gb, threshold_gb) - self.restart(timeout=timeout) - except (TimeoutError, RuntimeError): - raise - except Exception: - logger.warning("Memory check failed, continuing", exc_info=True) - def _run(self, code: str, env_id: int | None, timeout: float) -> dict[str, Any]: """Run one frame and distinguish safe pre-send failures from unknown outcomes.""" request_sent = False diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 0d9383aa6..35a6d3807 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -278,325 +278,6 @@ def test_process_group_cleanup_refuses_a_pre_reaped_leader_with_live_members( repl_core._kill_subprocesses(process, 1234) -def test_split_imports_preserves_body_offset_after_comments_and_blank_lines(): - code = """-- preface -import Mathlib.Data.Nat.Basic - --- body comment -#check Nat -""" - - imports, body, offset = repl_core._split_imports_and_body(code) - - assert imports == ["Mathlib.Data.Nat.Basic"] - assert body == "#check Nat\n" - assert offset == 4 - - -def test_split_imports_stops_at_the_first_body_statement(): - imports, body, offset = repl_core._split_imports_and_body( - "import Mathlib\n#check Nat\nimport Aesop\n" - ) - - assert imports == ["Mathlib"] - assert body == "#check Nat\nimport Aesop\n" - assert offset == 1 - - -def test_run_rejects_disallowed_import_roots_before_touching_the_process(): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib"}), - warmup_imports=frozenset(), - ) - ) - - response = repl.run("import Unsafe.Module\n#check Nat") - - assert response == { - "repl_error": "Disallowed imports: Unsafe. Allowed roots: Mathlib." - } - assert repl.process is None - - -def test_run_offsets_diagnostics_after_stripping_import_header(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - ) - ) - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "_run", - lambda code, env_id, timeout: { - "env": 74, - "messages": [ - { - "severity": "error", - "data": "boom", - "pos": {"line": 1, "column": 2}, - "endPos": {"line": 1, "column": 3}, - } - ], - "sorries": [ - { - "goal": "False", - "proofState": None, - "pos": {"line": 2, "column": 1}, - "endPos": {"line": 2, "column": 2}, - } - ], - }, - ) - - response = repl.run("import Mathlib\n\n#check Missing", timeout=1) - - assert response["messages"][0]["pos"]["line"] == 3 - assert response["messages"][0]["endPos"]["line"] == 3 - assert response["sorries"][0]["pos"]["line"] == 4 - assert response["sorries"][0]["endPos"]["line"] == 4 - - -def test_run_resolves_the_base_environment_after_restart(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) - ) - dispatched_envs = [] - - monkeypatch.setattr(repl, "is_alive", lambda: repl.process is not None) - - def restart(timeout=None): - repl.process = object() - repl._base_env_id = 73 - - monkeypatch.setattr(repl, "restart", restart) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "_run", - lambda code, env_id, timeout: dispatched_envs.append(env_id) or {"env": 74}, - ) - - assert repl.run("#check Nat", timeout=1) == {"env": 74} - assert dispatched_envs == [73] - - -def test_explicit_environment_is_not_sent_after_an_entry_restart(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) - ) - monkeypatch.setattr( - repl, - "restart", - lambda timeout=None: pytest.fail("a stale explicit environment must not restart"), - ) - monkeypatch.setattr( - repl, - "_run", - lambda code, env_id, timeout: pytest.fail("a stale environment must not be sent"), - ) - - with pytest.raises(repl_core.ReplProcessRestarted, match="environment state was lost"): - repl.run("#check Nat", env_id=7, timeout=1) - - -def test_run_refreshes_the_base_environment_after_retry(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - max_retries=1, - validate_imports=False, - warmup_imports=frozenset(), - ) - ) - repl.process = object() - repl._base_env_id = 11 - dispatched_envs = [] - - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr(repl_core.time, "sleep", lambda delay: None) - monkeypatch.setattr(repl_core.random, "uniform", lambda low, high: 0) - - def run_once(code, env_id, timeout): - dispatched_envs.append(env_id) - if len(dispatched_envs) == 1: - raise RuntimeError("retry me") - return {"env": 23} - - def restart(timeout=None): - repl.process = object() - repl._base_env_id = 22 - - monkeypatch.setattr(repl, "_run", run_once) - monkeypatch.setattr(repl, "restart", restart) - - assert repl.run("#check Nat", timeout=5) == {"env": 23} - assert dispatched_envs == [11, 22] - - -def test_pinned_repl_error_response_is_not_reported_as_success(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - max_retries=2, - ) - ) - repl.process = object() - calls = [] - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "_run", - lambda code, env_id, timeout: calls.append((code, env_id)) - or {"message": "Unknown environment."}, - ) - - assert repl.run("#eval 1", timeout=1) == { - "repl_error": "Unknown environment." - } - assert calls == [("#eval 1", None)] - - -@pytest.mark.parametrize( - "raw_response", - [ - [], - {}, - {"messages": []}, - {"messages": {}}, - { - "env": 1, - "messages": [ - { - "severity": [], - "data": "boom", - "pos": {"line": 1, "column": 1}, - } - ], - }, - { - "env": 1, - "messages": [ - { - "severity": {}, - "data": "boom", - "pos": {"line": 1, "column": 1}, - } - ], - }, - {"env": True}, - {"sorries": [{"goal": "False"}]}, - ], -) -def test_malformed_body_response_is_not_retried(monkeypatch, raw_response): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - max_retries=2, - ) - ) - repl.process = object() - calls = [] - retired = [] - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "_run", - lambda code, env_id, timeout: calls.append((code, env_id)) or raw_response, - ) - monkeypatch.setattr(repl, "close", lambda **kwargs: retired.append(True)) - - result = repl.run("#eval 1", timeout=1) - - assert result["outcome_unknown"] is True - assert calls == [("#eval 1", None)] - assert retired == [True] - - -def test_env_scoped_malformed_response_reports_the_lost_environment(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - ) - ) - process = object() - repl.process = process - retired = [] - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr(repl, "_run", lambda code, env_id, timeout: {}) - monkeypatch.setattr(repl, "close", lambda **kwargs: retired.append(True)) - - with pytest.raises(repl_core.ReplProcessRestarted) as error: - repl.run("#check Nat", env_id=7, timeout=1) - - assert isinstance(error.value.__cause__, repl_core.ReplProtocolError) - assert retired == [True] - - -@pytest.mark.parametrize("raw_response", [[], {"sorries": [{"goal": "False"}]}]) -def test_malformed_backlog_response_is_reported_as_unknown( - monkeypatch, raw_response -): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - ) - ) - repl.process = object() - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "_run", - lambda *args, **kwargs: (_ for _ in ()).throw( - repl_core.ReplStderrBacklog("stderr backlog", raw_response) - ), - ) - monkeypatch.setattr(repl, "close", lambda **kwargs: None) - - result = repl.run("#eval 1", timeout=1) - - assert result["outcome_unknown"] is True - assert "malformed" in result["repl_error"] - - -def test_explicit_environment_is_not_sent_after_a_memory_restart(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) - ) - repl.process = object() - - monkeypatch.setattr(repl, "is_alive", lambda: True) - - def restart_during_memory_check(timeout): - repl.process = object() - repl._base_env_id = 22 - - monkeypatch.setattr( - repl, - "_check_memory_and_maybe_restart", - restart_during_memory_check, - ) - monkeypatch.setattr( - repl, - "_run", - lambda code, env_id, timeout: pytest.fail("a stale environment must not be sent"), - ) - - with pytest.raises(repl_core.ReplProcessRestarted, match="environment state was lost"): - repl.run("#check Nat", env_id=7, timeout=1) - - def test_format_repl_response_prioritizes_errors_and_keeps_sorries(): formatted = repl_core.format_repl_response( { @@ -852,7 +533,6 @@ def run(code, env_id, timeout): ], } assert len(starts) == 1 - assert starts[0]["warm"] is False assert 0 < starts[0]["startup_timeout"] <= 3 assert len(calls) == 1 assert calls[0][0] == "import Mathlib\n#check Nat" @@ -878,8 +558,8 @@ def check_header(code, deadline): clock["now"] = 2 return repl_core._LeanHeaderAnalysis((), True) - def start(*, startup_timeout, warm): - budgets.append(("startup", startup_timeout, warm)) + def start(*, startup_timeout): + budgets.append(("startup", startup_timeout)) clock["now"] = 5 def run(code, env_id, timeout): @@ -893,7 +573,7 @@ def run(code, env_id, timeout): assert repl.run_disposable("#check Nat", timeout=10) == {"messages": []} assert budgets == [ - ("startup", 8, False), + ("startup", 8), ("command", 5, "import Mathlib\n#check Nat"), ] @@ -1747,43 +1427,6 @@ def cancel(*args, **kwargs): assert repl.is_clean() -def test_response_timeout_after_full_write_is_not_retried(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - max_retries=2, - ) - ) - repl.process = object() - calls = [] - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "close", - lambda **kwargs: setattr(repl, "process", None), - ) - monkeypatch.setattr( - repl, - "restart", - lambda timeout=None: pytest.fail("a sent request must not be retried"), - ) - - def fail_after_send(code, env_id, timeout, mark_sent): - calls.append((code, env_id)) - mark_sent() - raise TimeoutError("response timed out") - - monkeypatch.setattr(repl, "_run_io", fail_after_send) - response = repl.run("#eval 1", timeout=1) - - assert response["outcome_unknown"] is True - assert "fully sent" in response["repl_error"] - assert calls == [("#eval 1", None)] - assert repl.process is None - - def test_disposable_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) @@ -1806,44 +1449,6 @@ def fail_after_send(code, env_id, timeout, mark_sent): assert repl.process is None -def test_cleanup_failure_after_full_write_preserves_unknown_outcome(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - warmup_imports=frozenset(), - validate_imports=False, - max_retries=2, - ) - ) - repl.process = object() - close_calls = 0 - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr( - repl, - "restart", - lambda timeout=None: pytest.fail("a sent request must not be retried"), - ) - - def close(**kwargs): - nonlocal close_calls - close_calls += 1 - raise RuntimeError("cleanup failed") - - def fail_after_send(code, env_id, timeout, mark_sent): - mark_sent() - raise TimeoutError("response timed out") - - monkeypatch.setattr(repl, "close", close) - monkeypatch.setattr(repl, "_run_io", fail_after_send) - - response = repl.run("#eval 1", timeout=1) - - assert response["outcome_unknown"] is True - assert "process cleanup also failed" in response["repl_error"] - assert close_calls == 2 - assert repl.process is not None - - def test_disposable_cleanup_failure_after_full_write_keeps_the_unknown_outcome(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) @@ -2156,24 +1761,6 @@ def test_wire_protocol_retires_a_process_generation_that_exceeds_its_stderr_quot assert "23456789" in str(error.value) -def test_process_stderr_overflow_without_a_response_is_not_retried(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess(stack, [], stderr=b"x" * 32) - repl = _repl_with_process(process, max_buffer_bytes=16) - _patch_pipe_reads(monkeypatch, process) - monkeypatch.setattr( - repl, - "restart", - lambda timeout=None: pytest.fail("an uncertain command must not be retried"), - ) - - response = repl.run("#check Nat", timeout=1) - - assert "execution outcome is unknown and was not retried" in response["repl_error"] - assert response["outcome_unknown"] is True - assert repl.process is None - - def test_disposable_stderr_overflow_without_a_response_is_not_retried(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [], stderr=b"x" * 32) @@ -2187,19 +1774,6 @@ def test_disposable_stderr_overflow_without_a_response_is_not_retried(monkeypatc assert repl.process is None -def test_explicit_environment_preserves_an_unknown_outcome(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess(stack, [], stderr=b"x" * 32) - repl = _repl_with_process(process, max_buffer_bytes=16) - _patch_pipe_reads(monkeypatch, process) - - with pytest.raises(repl_core.ReplOutcomeUnknown) as error: - repl.run("#check Nat", env_id=7, timeout=1) - - assert isinstance(error.value, repl_core.ReplProcessRestarted) - assert repl.process is None - - def test_wire_protocol_drains_stderr_while_waiting_to_write(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [b"{}\n\n"], stderr=b"x" * 8) @@ -2401,86 +1975,6 @@ def fake_monotonic() -> float: assert error.value.response == {"messages": []} -def _patch_reads_across_processes(monkeypatch, processes: list[_PipeProcess]): - """Serve reads and readiness for several processes, keyed by descriptor.""" - real_read = os.read - - def take_chunk(chunks: list[bytes], size: int) -> bytes: - if not chunks: - return b"" - result = chunks[0][:size] - chunks[0] = chunks[0][size:] - if not chunks[0]: - chunks.pop(0) - return result - - def fake_read(fd: int, size: int) -> bytes: - for process in processes: - if fd == process.stdout.fileno(): - return take_chunk(process.stdout_chunks, size) - if fd == process.stderr.fileno(): - if not process.stderr_bytes: - raise BlockingIOError - result = process.stderr_bytes[:size] - process.stderr_bytes = process.stderr_bytes[size:] - return result - return real_read(fd, size) - - def fake_select(readable, writable, exceptional, timeout=None): - if writable: - return [], writable, [] - ready = [] - for process in processes: - if process.stderr_bytes: - ready.append(process.stderr.fileno()) - if process.stdout_chunks: - ready.append(process.stdout.fileno()) - return [fd for fd in ready if fd in readable], [], [] - - monkeypatch.setattr(repl_core.os, "read", fake_read) - monkeypatch.setattr(repl_core.select, "select", fake_select) - - -def test_backlog_recycles_the_process_so_two_commands_cannot_share_stderr(monkeypatch): - with ExitStack() as stack: - first = _PipeProcess(stack, [b'{"env":9}\n\n'], stderr=b"e" * 40) - second = _PipeProcess(stack, [b'{"env": 1}\n\n']) - repl = _repl_with_process(first, chunk_size=18, max_buffer_bytes=20) - _patch_reads_across_processes(monkeypatch, [first, second]) - - # Command one completes, but its stderr cannot be drained within budget. - assert repl.run("#check Nat", timeout=5) == {} - - # The process holding the remainder is gone, so nothing can inherit it. - assert repl.process is None - assert first.stderr_bytes == b"e" * 4 - - monkeypatch.setattr(repl, "restart", lambda timeout=None: setattr(repl, "process", second)) - - # Command two runs on a clean process and sees only its own streams. - assert repl.run("#check Nat", timeout=5) == {"env": 1} - - assert second.stderr_bytes == b"" - # Command one's stderr was never consumed by, or charged against, command two. - assert first.stderr_bytes == b"e" * 4 - - -def test_backlog_response_drops_environment_owned_by_the_recycled_process(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess( - stack, - [b'{"env":9,"messages":[]}\n\n'], - stderr=b"e" * 64, - ) - repl = _repl_with_process(process, chunk_size=30, max_buffer_bytes=32) - _patch_pipe_reads(monkeypatch, process) - - response = repl.run("#check Nat", timeout=5) - - assert response == {"messages": []} - assert repl.process is None - - def test_disposable_backlog_response_drops_the_environment_of_the_retired_process(monkeypatch): with ExitStack() as stack: process = _PipeProcess( @@ -2497,61 +1991,6 @@ def test_disposable_backlog_response_drops_the_environment_of_the_retired_proces assert repl.process is None -def test_env_scoped_request_refuses_to_outlive_the_recycled_process(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess(stack, [b'{"env": 9, "messages": []}\n\n'], stderr=b"e" * 40) - repl = _repl_with_process(process, chunk_size=18, max_buffer_bytes=20) - _patch_pipe_reads(monkeypatch, process) - - # An explicit environment cannot transparently survive the recycle, so the - # caller is told rather than handed a response tied to a dead process. - with pytest.raises(repl_core.ReplProcessRestarted): - repl.run("#check Nat", env_id=7, timeout=5) - - assert repl.process is None - - -def test_deadline_ended_drain_recycles_the_process_so_two_commands_cannot_share_stderr(monkeypatch): - with ExitStack() as stack: - first = _PipeProcess(stack, [b'{"env": 9, "messages": []}\n\n'], stderr=b"x") - second = _PipeProcess(stack, [b'{"env": 1}\n\n']) - # A ceiling far out of reach, so the deadline is what ends the drain. - repl = _repl_with_process(first, max_buffer_bytes=1_000_000) - _patch_reads_across_processes(monkeypatch, [first, second]) - - real_read = repl_core.os.read - - def fake_read(fd: int, size: int) -> bytes: - # first's stderr never empties, so no clean boundary is ever reached. - if fd == first.stderr.fileno(): - return b"x" * size - return real_read(fd, size) - - now = 0.0 - - def fake_monotonic() -> float: - nonlocal now - now += 0.25 - return now - - monkeypatch.setattr(repl_core.os, "read", fake_read) - monkeypatch.setattr(repl_core.time, "monotonic", fake_monotonic) - - # Command one still gets its response: the deadline must not starve stdout. - assert repl.run("#check Nat", timeout=5) == {"messages": []} - - # But the process that still holds unread stderr is out of service. - assert repl.process is None - assert first.stderr_bytes - - monkeypatch.setattr(repl, "restart", lambda timeout=None: setattr(repl, "process", second)) - monkeypatch.setattr(repl_core.os, "read", real_read) - - # Command two runs on a clean process, unaffected by command one's stderr. - assert repl.run("#check Nat", timeout=5) == {"env": 1} - assert second.stderr_bytes == b"" - - def test_run_never_leaves_a_reusable_process_when_a_backlog_stops_the_drain(monkeypatch): # The invariant holds at the source, so no caller of _run() can skip it. with ExitStack() as stack: @@ -2563,110 +2002,7 @@ def test_run_never_leaves_a_reusable_process_when_a_backlog_stops_the_drain(monk repl._run("#check Nat", env_id=None, timeout=5) assert repl.process is None - assert repl.is_alive() is False - - -def test_stderr_salvage_removes_every_process_owned_handle(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess(stack, [b"{}\n\n"]) - repl = _repl_with_process(process) - response = { - "env": 7, - "sorries": [{"goal": "False", "proofState": 9}], - } - monkeypatch.setattr( - repl, - "_run", - lambda *args, **kwargs: (_ for _ in ()).throw( - repl_core.ReplStderrBacklog("stderr backlog", response) - ), - ) - - result = repl.run("#check Nat", timeout=1) - - assert result == {"sorries": [{"goal": "False"}]} - assert repl.process is None - - -def test_stderr_arriving_during_the_next_write_is_process_scoped(monkeypatch): - with ExitStack() as stack: - first = _PipeProcess(stack, []) - second = _PipeProcess(stack, []) - repl = _repl_with_process(first, max_buffer_bytes=16) - requests: list[bytes] = [] - - def serve(process: _PipeProcess, responses: list[bytes]) -> None: - for response in responses: - request = bytearray() - while b"\n\n" not in request: - request.extend(os.read(process._stdin_read.fileno(), 4096)) - requests.append(bytes(request)) - os.write(process._stdout_write.fileno(), response) - - first_worker = threading.Thread( - target=serve, - args=(first, [b'{"env":1}\n\n', b'{"env":2}\n\n']), - daemon=True, - ) - second_worker = threading.Thread( - target=serve, - args=(second, [b'{"env":3}\n\n']), - daemon=True, - ) - first_worker.start() - second_worker.start() - - normal_select = repl_core.select.select - writes = 0 - - def inject_during_second_write(readable, writable, exceptional, timeout=None): - nonlocal writes - result = normal_select(readable, writable, exceptional, timeout) - if writable: - writes += 1 - if writes == 4: - # These bytes are emitted by command one after command two's - # old preflight window, while command two is being written. - os.write(first._stderr_write.fileno(), b"x" * 32) - return result - - monkeypatch.setattr(repl_core.select, "select", inject_during_second_write) - - assert repl.run("first", timeout=1) == {"env": 1} - # The process-generation quota is exceeded, but command two's captured - # response survives without the dead environment identifier or a retry. - assert repl.run("second", timeout=1) == {} - assert writes == 4 - assert repl.process is None - - monkeypatch.setattr(repl, "restart", lambda timeout=None: setattr(repl, "process", second)) - - # A third command starts on a clean process generation; command one's - # delayed stderr was neither consumed nor charged as command-three output. - assert repl.run("third", timeout=1) == {"env": 3} - assert repl._stderr_bytes == 0 - assert len(requests) == 3 - first_worker.join(timeout=1) - second_worker.join(timeout=1) - - -def test_invalid_json_with_a_stderr_backlog_is_not_retried(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess(stack, [b"not-json\n\n"], stderr=b"e" * 40) - repl = _repl_with_process(process, chunk_size=18, max_buffer_bytes=20) - _patch_pipe_reads(monkeypatch, process) - monkeypatch.setattr( - repl, - "restart", - lambda timeout=None: pytest.fail("an uncertain command must not be retried"), - ) - - response = repl.run("#check Nat", timeout=5) - - assert response["outcome_unknown"] is True - assert "response frame was malformed" in response["repl_error"] - assert repl.process is None - assert process.stderr_bytes == b"e" * 4 + assert repl.is_clean() def test_disposable_invalid_json_with_a_stderr_backlog_is_not_retried(monkeypatch): @@ -2693,25 +2029,6 @@ def test_wire_protocol_rejects_invalid_json(monkeypatch): repl._run("#check Nat", env_id=None, timeout=1) -@pytest.mark.parametrize( - "response", - [ - b'{"env":1,"env":2}\n\n', - b'{"env":NaN}\n\n', - ], -) -def test_wire_protocol_rejects_noncanonical_response_json(monkeypatch, response): - with ExitStack() as stack: - process = _PipeProcess(stack, [response]) - repl = _repl_with_process(process) - _patch_pipe_reads(monkeypatch, process) - - result = repl.run("#check Nat", timeout=1) - - assert result["outcome_unknown"] is True - assert repl.process is None - - @pytest.mark.parametrize( "response", [ @@ -2759,16 +2076,6 @@ def test_wire_protocol_rejects_oversized_response(monkeypatch): ) -def _responsive_fake_repl(): - return repl_core.LeanRepl( - repl_core.LeanReplConfig( - repl_command=[sys.executable, "-c", _RESPONSIVE_FAKE_REPL], - warmup_imports=frozenset({"Mathlib"}), - validate_imports=False, - ) - ) - - def test_close_after_an_expired_deadline_still_reserves_cleanup_time(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) @@ -2788,32 +2095,6 @@ def test_close_after_an_expired_deadline_still_reserves_cleanup_time(monkeypatch assert deadlines[0] >= before + repl_core.DEFAULT_REPL_CLEANUP_SECONDS -def test_request_timeout_reaps_a_slow_to_exit_process_without_cleanup_failure( - monkeypatch, -): - real_kill = repl_core._kill_subprocesses - - def slow_reap(process, process_group_id, deadline=None): - # A large Lean process needs real time to exit after SIGKILL. - if deadline is not None and deadline - time.monotonic() < 0.5: - raise RuntimeError("timed out reaping the Lean REPL process") - real_kill(process, process_group_id, deadline) - - monkeypatch.setattr(repl_core, "_kill_subprocesses", slow_reap) - repl = _responsive_fake_repl() - repl.start() - try: - response = repl.run("HANG", timeout=0.5) - - assert response.get("outcome_unknown") is True - assert "cleanup also failed" not in response["repl_error"] - assert repl.process is None - assert repl._process_group_id is None - assert repl._retire_pending is False - finally: - repl.close() - - def test_disposable_timeout_reaps_a_slow_to_exit_process_without_cleanup_failure( monkeypatch, ): @@ -2841,32 +2122,3 @@ def slow_reap(process, process_group_id, deadline=None): assert repl.is_clean() finally: repl.close() - - -def test_worker_whose_close_failed_is_restarted_before_the_next_request( - monkeypatch, -): - repl = _responsive_fake_repl() - repl.start() - real_kill = repl_core._kill_subprocesses - failures = iter([RuntimeError("timed out reaping the Lean REPL process")]) - - def fail_once(process, process_group_id, deadline=None): - error = next(failures, None) - if error is not None: - # The group is dead but its leader is left unreaped. - os.killpg(process_group_id, signal.SIGKILL) - raise error - real_kill(process, process_group_id, deadline) - - monkeypatch.setattr(repl_core, "_kill_subprocesses", fail_once) - try: - with pytest.raises(RuntimeError, match="timed out reaping"): - repl.close() - assert not repl.is_alive() - - response = repl.run("#check Nat", timeout=10) - - assert "repl_error" not in response - finally: - repl.close() diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index ef0635244..0d1309926 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -450,36 +450,6 @@ def get_memory_usage(self): pool.run("#check Int", timeout=0.1) -def test_repl_retry_recovery_uses_the_original_deadline(monkeypatch): - clock = {"now": 0.0} - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - max_retries=1, - validate_imports=False, - warmup_imports=frozenset(), - ) - ) - calls = [] - closed = [] - - monkeypatch.setattr(repl_core.time, "monotonic", lambda: clock["now"]) - monkeypatch.setattr(repl, "is_alive", lambda: True) - monkeypatch.setattr(repl, "_check_memory_and_maybe_restart", lambda timeout: None) - monkeypatch.setattr(repl, "close", lambda **kwargs: closed.append(True)) - - def consume_deadline(code, env_id, timeout): - calls.append(timeout) - clock["now"] += timeout - raise TimeoutError("ambiguous timeout") - - monkeypatch.setattr(repl, "_run", consume_deadline) - response = repl.run("#check Nat", timeout=1) - - assert calls == [1] - assert closed == [True] - assert "timed out" in response["repl_error"] - - def test_repl_request_write_uses_the_operation_deadline(): read_fd, write_fd = os.pipe() stdout_read_fd, stdout_write_fd = os.pipe() diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 4c1bf86a5..e56660afd 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -619,7 +619,6 @@ def __init__(self, config): "repl.run", {"project_dir": str(project), "code": "#check Nat", "timeout": 1}, ) - assert configs[0].max_retries == 0 assert configs[0].header_command == list( services.config.repl_header_command ) From 77ecebd3b391e7ea0e5e89849a8853d843a1b0bf Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:57:52 -0400 Subject: [PATCH 048/109] Describe the disposable REPL model in the stale docstrings Several docstrings still described the resident REPL. The core module and LeanRepl spoke of one lake exe repl session with a preloaded environment, memory monitoring, automatic restart and chaining, and pool.py of load-balanced instances. The get_repl_status description, which agents read, said memory use is available only for a warm resident, although a warm pool holds no process between calls. Two test docstrings still called the pool transactional and the server persistent. They now describe the disposable model: each public call runs as the only frame of a fresh lake exe @repl/repl child that is retired before the call returns. get_repl_status says that warm means the slots are cached and that memory use counts only processes of calls in progress. A comment above LEAN_HEADER_LAUNCHER says why it execs the lean in LEAN_SYSROOT rather than whatever lean PATH finds, and the minimum-Lean test says it runs in a directory pinned to that Lean, not in a project. --- servers/repl/core.py | 15 +++++++++------ servers/repl/pool.py | 2 +- servers/repl/server.py | 8 +++++--- tests/test_real_repl.py | 6 +++--- tests/test_repl_pool_lifecycle.py | 2 +- tests/test_servers.py | 2 +- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index c23ce2091..ce9810218 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -1,7 +1,8 @@ -"""Lean REPL backend: one session managing a ``lake exe repl`` subprocess. +"""Lean REPL backend: disposable ``lake exe @repl/repl`` children. -Provides LeanRepl with non-blocking I/O, a preloaded import environment, -memory monitoring, automatic restart, and multi-snippet chaining. +LeanRepl.run_disposable checks the submitted header with Lean's parser, +runs the call as the only frame of a fresh REPL process, and retires that +process before returning. """ from __future__ import annotations @@ -36,6 +37,8 @@ _PUBLIC_SORRY_FIELDS = frozenset({"goal", "pos", "endPos"}) # Runs Lean's own header parser, which the REPL uses, on the source on stdin. LEAN_HEADER_PARSER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "parse_header.lean") +# ``lake env`` sets LEAN_SYSROOT to the selected toolchain; exec its lean +# directly, not whatever ``lean`` PATH finds. LEAN_HEADER_LAUNCHER = ( "import os, sys; " "lean = os.path.join(os.environ['LEAN_SYSROOT'], 'bin', 'lean'); " @@ -770,10 +773,10 @@ def __init__(self, message: str, result: dict[str, Any]) -> None: class LeanRepl: - """Lean REPL process manager. + """Slot wrapper that runs each public call on a fresh Lean REPL process. - Manages a ``lake exe repl`` subprocess with non-blocking I/O, - a preloaded import environment, and automatic restart on failure. + It owns at most one process generation (header parser or REPL child) at + a time; close() verifies that generation's exit before the slot is reused. """ def __init__(self, config: LeanReplConfig) -> None: diff --git a/servers/repl/pool.py b/servers/repl/pool.py index c0785e5df..638e88e3b 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -1,4 +1,4 @@ -"""Pooled Lean REPL instances with load balancing.""" +"""Cold Lean REPL slots that each admit one disposable call.""" from __future__ import annotations diff --git a/servers/repl/server.py b/servers/repl/server.py index 1a9438cc4..b058474a9 100644 --- a/servers/repl/server.py +++ b/servers/repl/server.py @@ -33,9 +33,11 @@ def run_lean_code(project_dir: str, code: str, timeout: float | None = None) -> def get_repl_status(project_dir: str) -> str: """Return pool capacity, memory use, lifecycle state, and shutdown state. - State is ``cold``, ``warming``, ``warm``, or ``retiring``. Memory use - is available only for a warm resident; retiring reports zero and - ``shutdown: true`` while verified cleanup remains in progress. + State is ``cold``, ``warming``, ``warm``, or ``retiring``; ``warm`` + means the project's slots are cached, not that a REPL process stays + alive. Memory use counts only processes of calls in progress, so it is + zero between calls; retiring reports zero and ``shutdown: true`` while + verified cleanup remains in progress. Args: project_dir: Absolute path to the Lake project root. diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index 26b7a0983..c22ce3e41 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -152,9 +152,9 @@ def test_unvalidated_calls_use_warmup_only_when_composition_is_proven(code): @pytest.mark.parametrize(("code", "is_prelude"), [("#check Nat", False), ("prelude\nimport Init", True)]) def test_header_parser_runs_on_the_minimum_supported_lean(code, is_prelude): - # The default header command, run from a project on the oldest supported - # Lean. Before v4.30 both headers report one plain Init import, so only - # isPrelude tells them apart. + # The default header command, run in a directory pinned to the oldest + # supported Lean. Before v4.30 both headers report one plain Init import, + # so only isPrelude tells them apart. completed = subprocess.run( LeanReplConfig().header_command, cwd=MINIMUM_LEAN, diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 0d1309926..c7b91ff48 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -1,4 +1,4 @@ -"""Lifecycle regression tests for transactional REPL pool startup.""" +"""Lifecycle regression tests for the cold Lean REPL slot pool.""" from __future__ import annotations diff --git a/tests/test_servers.py b/tests/test_servers.py index a21b538e3..a1de2e367 100644 --- a/tests/test_servers.py +++ b/tests/test_servers.py @@ -91,7 +91,7 @@ def shutdown(self): class TestReplServer: - """Contracts for the persistent Lean REPL server.""" + """Contracts for the Lean REPL MCP adapter.""" def test_import_server(self): from servers.repl import server # noqa: F401 From 46bdd2b9b423f6c0a847922949fd2f98de1bd9e9 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:58:50 -0400 Subject: [PATCH 049/109] Say in run_lean_code what agents need to know run_lean_code's description is the contract agents read, but it used internal terms (post-admission, idle-slot, disposable child, generated imports) and left out what changes how a snippet should be written: that import Mathlib is added in front, which explains the import error a project without Mathlib reports at line 1; which imports are allowed; and the default and maximum timeout. It now says that import Mathlib is added unless the snippet starts with module or prelude or already has import Mathlib, that imports must come from Mathlib, Aesop, Batteries or LeanSearchClient, and that the timeout covers the whole call, from waiting for a free slot to the code, with a default and maximum of 240 seconds unless the operator changes them. "Changes" rather than "lowers", because the maximum is the operator's AUTOFORM_MAX_REPL_REQUEST_SECONDS. --- servers/repl/server.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/servers/repl/server.py b/servers/repl/server.py index b058474a9..7f5b239e8 100644 --- a/servers/repl/server.py +++ b/servers/repl/server.py @@ -15,14 +15,19 @@ def create_repl_server(runtime: LeanRuntimeClient) -> FastMCP: @server.tool def run_lean_code(project_dir: str, code: str, timeout: float | None = None) -> str: - """Compile a Lean snippet in a fresh project-scoped REPL process. + """Compile a Lean snippet in a fresh Lean REPL process for the project. + + ``import Mathlib`` is added in front unless the snippet starts with + ``module`` or ``prelude`` or already has ``import Mathlib``. Imports + must come from Mathlib, Aesop, Batteries, or LeanSearchClient. Args: project_dir: Absolute path to the Lake project root. code: Lean code to execute. - timeout: Optional total post-admission timeout in seconds. It - covers the idle-slot wait, Lean header validation, disposable - child startup, generated imports, and submitted code. + timeout: Optional seconds for the whole call: waiting for a free + slot, the header check, starting Lean, the imports, and the + code. The default and maximum are 240 seconds unless the + operator changes them. """ return runtime.request( "repl.run", From 33f33c4b11a3a055bad16b0546e12afc70b45380 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:32:18 -0400 Subject: [PATCH 050/109] Drop the 240-second cap on AUTOFORM_MAX_REPL_REQUEST_SECONDS The runtime refused to start, LSP tools included, when AUTOFORM_MAX_REPL_REQUEST_SECONDS exceeded 240, and nothing recorded why. Main accepted any maximum that fit the response budget, as the LSP maximum still does. Every call now pays for Lean startup and the Mathlib import, so slow nodes are the ones that need a higher limit. The cap is gone, so the response-budget check bounds the maximum again: 860 seconds under the default 900-second response timeout. The config test that pinned the cap with 241 now sets 900, which that check refuses, so the upper bound stays covered. --- servers/lean_runtime.py | 5 ----- tests/test_shared_lean_runtime.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index 77feaca51..c0a3dea2a 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -203,11 +203,6 @@ def from_environment(cls) -> "LeanRuntimeConfig": "AUTOFORM_MAX_REPL_REQUEST_SECONDS", DEFAULT_MAX_REPL_REQUEST_SECONDS, ) - if max_repl_request_seconds > DEFAULT_MAX_REPL_REQUEST_SECONDS: - raise ValueError( - "AUTOFORM_MAX_REPL_REQUEST_SECONDS cannot exceed " - f"{DEFAULT_MAX_REPL_REQUEST_SECONDS:g} seconds" - ) # The default follows a lowered maximum, so the maximum can be set alone. repl_request_timeout = _positive_float( "AUTOFORM_REPL_REQUEST_TIMEOUT", diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index e56660afd..b025bf646 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -1812,7 +1812,7 @@ def close(self): ("LEAN_NUM_REPLS", "-1", "nonnegative integer"), ("LEAN_REPL_CMD", " ", "must not be empty"), ("AUTOFORM_LEAN_IDLE_SECONDS", "nan", "finite nonnegative"), - ("AUTOFORM_MAX_REPL_REQUEST_SECONDS", "241", "cannot exceed 240"), + ("AUTOFORM_MAX_REPL_REQUEST_SECONDS", "900", "too small"), ("LEAN_LSP_TIMEOUT", "601", "cannot exceed"), ("AUTOFORM_RUNTIME_RESPONSE_TIMEOUT", "100", "too small"), ], From b95bbc42c9e59aa2bfdec45d0fd06e54cc2aef51 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:33:33 -0400 Subject: [PATCH 051/109] State the caller's budget when a REPL call times out The README promises one total budget, and run_disposable names the phase when that budget runs out before dispatch. The most common timeout, a submitted command that runs too long, read instead "Lean REPL transport failed after the request was fully sent; its execution outcome is unknown and was not retried: REPL command timed out after 237.8... seconds": a raw float for the budget left at dispatch, a timeout called a transport failure, and the outcome stated twice once the formatter added its own clause. A header check that ran out said only "timed out checking the Lean header". When the unknown outcome comes from a timeout, run_disposable now reports "Lean REPL combined command execution timed out after {timeout:g} seconds" and keeps outcome_unknown, so the formatter states the outcome once. A header-check timeout becomes "Lean REPL header validation timed out after {timeout:g} seconds". The phase test covers both with exact results, and the two full-write tests assert the new message; a cleanup failure there still raises ReplCleanupError. --- servers/repl/core.py | 12 +++++++- tests/test_repl_core_protocol.py | 49 ++++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index ce9810218..86cc25b01 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -946,6 +946,10 @@ def remaining(phase: str) -> float: header = self._check_header(code, deadline) except ValueError as error: result = {"repl_error": f"Rejected Lean header: {error}"} + except TimeoutError: + raise TimeoutError( + f"Lean REPL header validation timed out after {timeout:g} seconds" + ) from None else: modules = header.modules accepts_leading_imports = header.accepts_leading_imports @@ -1009,8 +1013,14 @@ def remaining(phase: str) -> float: except ReplCommandError as error: result = {"repl_error": str(error)} except (ReplProtocolError, ReplOutcomeUnknown) as error: + if isinstance(error.__cause__, TimeoutError): + # _run reports a timeout after dispatch as a transport + # failure with the budget left at dispatch. + message = f"Lean REPL combined command execution timed out after {timeout:g} seconds" + else: + message = _with_repl_require_hint(str(error)) result = { - "repl_error": _with_repl_require_hint(str(error)), + "repl_error": message, "outcome_unknown": True, } except (ReplProcessExited, TimeoutError, RuntimeError, OSError) as error: diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 35a6d3807..a9feb81e1 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -578,8 +578,22 @@ def run(code, env_id, timeout): ] -@pytest.mark.parametrize("expired_phase", ["startup", "command"]) -def test_disposable_timeout_names_the_expired_phase(monkeypatch, expired_phase): +@pytest.mark.parametrize( + ("expired_phase", "expected"), + [ + ("header", {"repl_error": "Lean REPL header validation timed out after 1 seconds"}), + ("startup", {"repl_error": "Lean REPL disposable child startup timed out after 1 seconds"}), + ("command", {"repl_error": "Lean REPL combined command execution timed out after 1 seconds"}), + ( + "dispatched", + { + "repl_error": "Lean REPL combined command execution timed out after 1 seconds", + "outcome_unknown": True, + }, + ), + ], +) +def test_disposable_timeout_names_the_expired_phase(monkeypatch, expired_phase, expected): clock = {"now": 0.0} repl = repl_core.LeanRepl( repl_core.LeanReplConfig(warmup_imports=frozenset()) @@ -592,6 +606,8 @@ def close(**kwargs): repl._process_group_id = None def check_header(code, deadline): + if expired_phase == "header": + raise TimeoutError("timed out checking the Lean header") if expired_phase == "startup": clock["now"] = 2 return repl_core._LeanHeaderAnalysis((), True) @@ -600,18 +616,17 @@ def start(**kwargs): if expired_phase == "command": clock["now"] = 2 + def run(**kwargs): + if expired_phase != "dispatched": + pytest.fail("expired budget dispatched the command") + raise repl_core.ReplOutcomeUnknown("transport failed") from TimeoutError() + monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "_check_header", check_header) monkeypatch.setattr(repl, "start", start) - monkeypatch.setattr( - repl, - "_run", - lambda **kwargs: pytest.fail("expired budget dispatched the command"), - ) - - result = repl.run_disposable("#check Nat", timeout=1) + monkeypatch.setattr(repl, "_run", run) - assert expired_phase in result["repl_error"] + assert repl.run_disposable("#check Nat", timeout=1) == expected def test_disposable_backlog_adjusts_prefixed_positions(monkeypatch): @@ -1443,8 +1458,10 @@ def fail_after_send(code, env_id, timeout, mark_sent): monkeypatch.setattr(repl, "_run_io", fail_after_send) response = repl.run_disposable("#eval 1", timeout=1) - assert response["outcome_unknown"] is True - assert "fully sent" in response["repl_error"] + assert response == { + "repl_error": "Lean REPL combined command execution timed out after 1 seconds", + "outcome_unknown": True, + } assert calls == [("#eval 1", None)] assert repl.process is None @@ -1470,11 +1487,13 @@ def fail_after_send(code, env_id, timeout, mark_sent): monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", object())) monkeypatch.setattr(repl, "_run_io", fail_after_send) - with pytest.raises(repl_core.ReplCleanupError) as error: + with pytest.raises(repl_core.ReplCleanupError, match="retirement is required: cleanup failed") as error: repl.run_disposable("#eval 1", timeout=1) - assert error.value.result["outcome_unknown"] is True - assert "process cleanup also failed" in error.value.result["repl_error"] + assert error.value.result == { + "repl_error": "Lean REPL combined command execution timed out after 1 seconds", + "outcome_unknown": True, + } assert close_calls == 3 assert repl.process is not None From 3e6f1cd2b5b3e8b669e7541c01ce3e4d9ec2209b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:34:16 -0400 Subject: [PATCH 052/109] Put the REPL require remedy ahead of the failure details When Lake cannot find the REPL package, the child exits before it answers, so the error opened with about 280 characters saying that process stderr became unsafe and that the outcome is unknown, and only then named the require as the cause. An upgrading user with a differently named require saw the wrong diagnosis first. _with_repl_require_hint now returns the remedy, then "Details:" and the original message, and the outcome stays unknown because stderr alone cannot prove that nothing ran. The unknown-package test asserts that the error starts with the remedy. --- servers/repl/core.py | 7 ++++--- tests/test_repl_core_protocol.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 86cc25b01..303d058e4 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -632,14 +632,15 @@ def public_fields(item: dict[str, Any], allowed: frozenset[str]) -> dict[str, An def _with_repl_require_hint(message: str) -> str: - """Name both remedies when Lake found no package for the REPL command. + """Lead with both remedies when Lake found no package for the REPL command. A child that fails this way reaches the caller as a process exit or an - unknown outcome whose message carries the child's stderr tail. + unknown outcome whose message gives the child's stderr tail only after + its own diagnosis, so the remedies go first. """ if _LAKE_UNKNOWN_PACKAGE not in message: return message - return f"{message.rstrip()}; {_REPL_REQUIRE_HINT}" + return f"{_REPL_REQUIRE_HINT}. Details: {message.rstrip()}" def format_message(msg: dict) -> str: diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index a9feb81e1..6f215e4dc 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1254,6 +1254,7 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ error = repl.run_disposable("#check Nat", timeout=30)["repl_error"] assert lake_error in error + assert error.startswith("Lake could not find the REPL package: ") is hinted assert ("name the project's REPL require `repl`" in error) is hinted assert ('set LEAN_REPL_CMD="lake exe @/repl"' in error) is hinted assert repl.is_clean() From d044ddfbb7ad3237e0fa9b16f10b3688de84b7f4 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:34:44 -0400 Subject: [PATCH 053/109] Say that LEAN_REPL_CMD needs a runtime restart and applies to all The unknown-package hint offered LEAN_REPL_CMD="lake exe @/repl" as an equal alternative to renaming the require, as if it were a per-project fix. The runtime reads that variable once, when it starts, and passes the same command to every project it serves. A user who set it and retried, or restarted the session, kept the old command, and once it took effect, every project with a require named repl broke. The hint now prefers the rename and says the variable takes a runtime restart and applies to every project: "rename the require to `repl`, or restart the runtime with LEAN_REPL_CMD=... for a require named , which applies to every project the runtime serves". The servers/README.md remedy uses the same words. The unknown-package test pins the restart and its scope. --- servers/repl/core.py | 5 +++-- tests/test_repl_core_protocol.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 303d058e4..15ac04f51 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -48,8 +48,9 @@ # workspace has that name, as when the REPL is required under another name. _LAKE_UNKNOWN_PACKAGE = "unknown package `" _REPL_REQUIRE_HINT = ( - "Lake could not find the REPL package: name the project's REPL require `repl`, " - 'or set LEAN_REPL_CMD="lake exe @/repl" for a require named ' + "Lake could not find the REPL package: rename the require to `repl`, " + 'or restart the runtime with LEAN_REPL_CMD="lake exe @/repl" for a require ' + "named , which applies to every project the runtime serves" ) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 6f215e4dc..c4cb4edfb 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1255,8 +1255,9 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ assert lake_error in error assert error.startswith("Lake could not find the REPL package: ") is hinted - assert ("name the project's REPL require `repl`" in error) is hinted - assert ('set LEAN_REPL_CMD="lake exe @/repl"' in error) is hinted + assert ("rename the require to `repl`" in error) is hinted + assert ('restart the runtime with LEAN_REPL_CMD="lake exe @/repl"' in error) is hinted + assert ("applies to every project the runtime serves" in error) is hinted assert repl.is_clean() From cb4fc5ea7bf7ab24ad22503d6a557f89c5d2f47d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:35:36 -0400 Subject: [PATCH 054/109] Say that a call refused by a shut-down REPL pool can be retried When one call's cleanup fails, the pool stops admission, and every call queued on it fails with "Lean REPL pool is shut down". Queued calls are likely there, because they share a project and the default capacity is one. Nothing was dispatched for them, so retrying is safe and reaches the replacement pool, but the message said neither. The three places that refuse a call after shutdown now say "Lean REPL pool is shut down; this call did not start and can be retried". The waiter test asserts the full message. --- servers/repl/pool.py | 12 +++++++++--- tests/test_repl_pool_lifecycle.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 638e88e3b..4ba9ba00d 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -130,7 +130,9 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: deadline = time.monotonic() + timeout if timeout is not None else None with self._condition: if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") + raise RuntimeError( + "Lean REPL pool is shut down; this call did not start and can be retried" + ) self._active_calls += 1 repl: LeanRepl | None = None @@ -149,12 +151,16 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: if candidate is _SHUTDOWN: # Hand the wakeup to the next waiter. self._idle.put(_SHUTDOWN) - raise RuntimeError("Lean REPL pool is shut down") + raise RuntimeError( + "Lean REPL pool is shut down; this call did not start and can be retried" + ) repl = candidate with self._condition: if self._shutdown: - raise RuntimeError("Lean REPL pool is shut down") + raise RuntimeError( + "Lean REPL pool is shut down; this call did not start and can be retried" + ) remaining = None if deadline is None else deadline - time.monotonic() if remaining is not None and remaining <= 0: diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index c7b91ff48..be8e7299e 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -402,7 +402,7 @@ def get_memory_usage(self): assert not waiter.is_alive() assert not stopper.is_alive() assert len(errors) == 1 - assert "pool is shut down" in str(errors[0]) + assert str(errors[0]) == "Lean REPL pool is shut down; this call did not start and can be retried" def test_shutdown_waits_for_checked_out_worker_before_closing_or_draining(monkeypatch): From f20f96ae89060377cec4c99f9aaf300a75e7f132 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:36:10 -0400 Subject: [PATCH 055/109] Revert the unrelated reformatting in the REPL pool pool.py carried formatting changes the disposable model did not need, against CONTRIBUTING.md's rule to match the surrounding code instead of reformatting unrelated lines. The num_repls expression was split over four lines although it fit the 120-column limit, a blank line followed every method docstring, including the untouched get_memory_usage, where the rest of servers/ runs docstrings straight into code, and the _close_workers docstring was reworded without a behavior change. The new import also listed ReplCleanupError ahead of LeanRepl. Those lines are back as main had them, and the import is sorted. --- servers/repl/pool.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 4ba9ba00d..bf1774a61 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -9,7 +9,7 @@ from logging import getLogger from typing import Any -from .core import ReplCleanupError, LeanRepl, LeanReplConfig +from .core import LeanRepl, LeanReplConfig, ReplCleanupError logger = getLogger(__name__) @@ -29,10 +29,7 @@ def __post_init__(self) -> None: import psutil total_gb = psutil.virtual_memory().total / (1024**3) - self.num_repls = max( - 1, - int(total_gb * DEFAULT_RAM_FRACTION / self.instance_mem_limit_gb), - ) + self.num_repls = max(1, int(total_gb * DEFAULT_RAM_FRACTION / self.instance_mem_limit_gb)) except ImportError: self.num_repls = 1 @@ -86,13 +83,11 @@ def __init__(self, config: LeanReplPoolConfig) -> None: @property def requires_retirement(self) -> bool: """Whether this generation must leave the resident project cache.""" - with self._condition: return self._requires_retirement def _stop_admission_locked(self, *, require_retirement: bool = False) -> None: """Stop admission and wake every queue waiter; condition is held.""" - if require_retirement: self._requires_retirement = True self._shutdown = True @@ -102,8 +97,7 @@ def _stop_admission_locked(self, *, require_retirement: bool = False) -> None: self._condition.notify_all() def _close_workers(self) -> None: - """Close every constructed worker, preserving cleanup survivors.""" - + """Close every constructed worker, preserving cleanup after one failure.""" survivors: list[LeanRepl] = [] first_error: BaseException | None = None for worker in reversed(self._workers): @@ -126,7 +120,6 @@ def _close_workers(self) -> None: def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: """Run one cold call within a total queue-and-execution timeout.""" - deadline = time.monotonic() + timeout if timeout is not None else None with self._condition: if self._shutdown: @@ -188,14 +181,12 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: def get_memory_usage(self) -> float: """Total memory usage across all REPL instances in GB.""" - with self._lock: workers = tuple(self._workers) return sum(worker.get_memory_usage() for worker in workers) def shutdown(self) -> None: """Stop admission, wait for active calls, then retire every wrapper.""" - with self._condition: self._stop_admission_locked() while self._active_calls: From 125064c3a466092823ad7c62c80fe8cadd022d0c Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 10:36:50 -0400 Subject: [PATCH 056/109] Word the header-command error as the check the runtime makes The runtime refuses to start without LEAN_REPL_HEADER_CMD when LEAN_REPL_CMD "does not invoke a lake executable", but it checks only the basename of the first word. A wrapped command such as "container run lake exe @repl/repl" or "env FOO=1 lake exe @repl/repl" does invoke lake and is still refused. The error now says "does not start with a `lake` executable", the condition servers/README.md states in the same words, and the wrapped command test pins the full message. --- servers/lean_runtime.py | 2 +- tests/test_shared_lean_runtime.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/servers/lean_runtime.py b/servers/lean_runtime.py index c0a3dea2a..732d3c7e9 100644 --- a/servers/lean_runtime.py +++ b/servers/lean_runtime.py @@ -195,7 +195,7 @@ def from_environment(cls) -> "LeanRuntimeConfig": else: raise ValueError( "LEAN_REPL_HEADER_CMD must be set when LEAN_REPL_CMD does not " - "invoke a lake executable" + "start with a `lake` executable" ) if not lsp_command: raise ValueError("LEAN_LSP_CMD must not be empty") diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index b025bf646..629fcff7a 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -1865,7 +1865,10 @@ def test_wrapped_repl_command_requires_an_explicit_header_command(monkeypatch): monkeypatch.setenv("LEAN_REPL_CMD", "container run lake exe @repl/repl") monkeypatch.delenv("LEAN_REPL_HEADER_CMD", raising=False) - with pytest.raises(ValueError, match="LEAN_REPL_HEADER_CMD must be set"): + with pytest.raises( + ValueError, + match="LEAN_REPL_HEADER_CMD must be set when LEAN_REPL_CMD does not start with a `lake` executable", + ): LeanRuntimeConfig.from_environment() monkeypatch.setenv("LEAN_REPL_HEADER_CMD", "container run lake env lean-header") From ffb93cbe85fe08beed8cadadfd3182dafc22df84 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:01:32 -0400 Subject: [PATCH 057/109] Assert no cleanup was refused in the slow-reap timeout test run_disposable now reports a timeout after dispatch with a fixed message, so _run's "process cleanup also failed" note never reaches the result, and the test's check for that note could not fail. If close() stopped reserving cleanup time after an expired deadline, _run's reap was refused, the final close reaped the process, and the test passed. slow_reap now records each reap it refuses, and the test asserts the exact result and that none was refused. --- tests/test_repl_core_protocol.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index c4cb4edfb..f65eccadb 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -2120,10 +2120,12 @@ def test_disposable_timeout_reaps_a_slow_to_exit_process_without_cleanup_failure monkeypatch, ): real_kill = repl_core._kill_subprocesses + refused = [] def slow_reap(process, process_group_id, deadline=None): # A large Lean process needs real time to exit after SIGKILL. if deadline is not None and deadline - time.monotonic() < 0.5: + refused.append(deadline) raise RuntimeError("timed out reaping the Lean REPL process") real_kill(process, process_group_id, deadline) @@ -2138,8 +2140,11 @@ def slow_reap(process, process_group_id, deadline=None): try: response = repl.run_disposable("HANG", timeout=0.5) - assert response.get("outcome_unknown") is True - assert "cleanup also failed" not in response["repl_error"] + assert response == { + "repl_error": "Lean REPL combined command execution timed out after 0.5 seconds", + "outcome_unknown": True, + } + assert refused == [] assert repl.is_clean() finally: repl.close() From 98b4523a4da1b043a019664c8f78d636f9343d7b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:30:30 -0400 Subject: [PATCH 058/109] State the caller's budget when a queued REPL call times out pool.run gives run_disposable only the time left after the wait for an idle slot, and run_disposable put that number in its timeout messages. servers/README.md defines the REPL timeout as one total budget that includes that wait, and calls to one project run one at a time by default, so queued calls are common. A 3-second call that waited a second for the slot reported 1.97585 seconds, and even an uncontended 1-second call could report 0.999998. run_disposable now takes a keyword-only budget, which defaults to its timeout, and states it in the header, startup, command and dispatched timeout messages. pool.run passes its caller's timeout as that budget. A new pool test spends 2 of a call's 10 seconds waiting for a slot and asserts that the timeout says 10 seconds, and the phase test passes a budget unlike its timeout, so each phase message must state the budget. --- servers/repl/core.py | 16 ++++++++++++---- servers/repl/pool.py | 2 +- tests/test_repl_core_protocol.py | 10 +++++----- tests/test_repl_pool_lifecycle.py | 32 +++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 15ac04f51..6f76935e0 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -914,16 +914,24 @@ def run_disposable( self, code: str, timeout: float | None = None, + *, + budget: float | None = None, ) -> dict[str, Any]: - """Run one public call as the only request frame on a fresh process.""" + """Run one public call as the only request frame on a fresh process. + + Timeout messages state ``budget``, which defaults to ``timeout``. A pool + passes its caller's whole budget there and only the part left after + waiting for this slot as ``timeout``. + """ timeout = self.request_timeout if timeout is None else timeout + budget = timeout if budget is None else budget deadline = time.monotonic() + timeout def remaining(phase: str) -> float: value = deadline - time.monotonic() if value <= 0: raise TimeoutError( - f"Lean REPL {phase} timed out after {timeout:g} seconds" + f"Lean REPL {phase} timed out after {budget:g} seconds" ) return value @@ -950,7 +958,7 @@ def remaining(phase: str) -> float: result = {"repl_error": f"Rejected Lean header: {error}"} except TimeoutError: raise TimeoutError( - f"Lean REPL header validation timed out after {timeout:g} seconds" + f"Lean REPL header validation timed out after {budget:g} seconds" ) from None else: modules = header.modules @@ -1018,7 +1026,7 @@ def remaining(phase: str) -> float: if isinstance(error.__cause__, TimeoutError): # _run reports a timeout after dispatch as a transport # failure with the budget left at dispatch. - message = f"Lean REPL combined command execution timed out after {timeout:g} seconds" + message = f"Lean REPL combined command execution timed out after {budget:g} seconds" else: message = _with_repl_require_hint(str(error)) result = { diff --git a/servers/repl/pool.py b/servers/repl/pool.py index bf1774a61..47c1b4344 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -161,7 +161,7 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: f"timed out after {timeout:g}s waiting for an idle Lean REPL" ) try: - return repl.run_disposable(code, timeout=remaining) + return repl.run_disposable(code, timeout=remaining, budget=timeout) except ReplCleanupError as error: # The response is process-free and remains valid. The dirty # generation transfers to retained project-cache retirement. diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index f65eccadb..edb7fdfc7 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -581,13 +581,13 @@ def run(code, env_id, timeout): @pytest.mark.parametrize( ("expired_phase", "expected"), [ - ("header", {"repl_error": "Lean REPL header validation timed out after 1 seconds"}), - ("startup", {"repl_error": "Lean REPL disposable child startup timed out after 1 seconds"}), - ("command", {"repl_error": "Lean REPL combined command execution timed out after 1 seconds"}), + ("header", {"repl_error": "Lean REPL header validation timed out after 3 seconds"}), + ("startup", {"repl_error": "Lean REPL disposable child startup timed out after 3 seconds"}), + ("command", {"repl_error": "Lean REPL combined command execution timed out after 3 seconds"}), ( "dispatched", { - "repl_error": "Lean REPL combined command execution timed out after 1 seconds", + "repl_error": "Lean REPL combined command execution timed out after 3 seconds", "outcome_unknown": True, }, ), @@ -626,7 +626,7 @@ def run(**kwargs): monkeypatch.setattr(repl, "start", start) monkeypatch.setattr(repl, "_run", run) - assert repl.run_disposable("#check Nat", timeout=1) == expected + assert repl.run_disposable("#check Nat", timeout=1, budget=3) == expected def test_disposable_backlog_adjusts_prefixed_positions(monkeypatch): diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index be8e7299e..9261dc9a7 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -170,6 +170,38 @@ def get_memory_usage(self): pool.shutdown() +def test_queued_call_timeout_states_the_callers_budget(monkeypatch): + command_timeouts = [] + pool = repl_pool.LeanReplPool( + repl_pool.LeanReplPoolConfig( + num_repls=1, + validate_imports=False, + warmup_imports=frozenset(), + ) + ) + (repl,) = pool._workers + + def run(code, env_id, timeout): + command_timeouts.append(timeout) + raise repl_core.ReplOutcomeUnknown("transport failed") from TimeoutError() + + monkeypatch.setattr(repl, "close", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "_run", run) + # The wait for an idle slot takes 2 of the caller's 10 seconds. + times = iter((0.0, 0.0)) + monkeypatch.setattr(repl_pool.time, "monotonic", lambda: next(times, 2.0)) + try: + assert pool.run("#check Nat", timeout=10) == { + "repl_error": "Lean REPL combined command execution timed out after 10 seconds", + "outcome_unknown": True, + } + finally: + pool.shutdown() + + assert command_timeouts == [8] + + def test_pool_runs_each_call_disposably_and_reuses_only_a_clean_slot(monkeypatch): workers = [] From 5a1ec6ac88f7eab0c359d1de78b6f4877c091891 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:54:37 -0400 Subject: [PATCH 059/109] Let the cold-slot shutdown test fail instead of hanging test_shutdown_unblocks_a_call_waiting_for_a_cold_slot guards the _SHUTDOWN wakeup, but its waiter called pool.run with no timeout and neither of its threads was a daemon. If the wakeup regressed, the waiter would block on the idle queue, shutdown would wait for it forever, and interpreter exit would join both threads, so the CI job would hit its 30-minute limit and end cancelled instead of failed. The waiter now has a 5-second budget and both threads are daemons. With the wakeup removed from pool.py, the test fails in about 4 seconds. --- tests/test_repl_pool_lifecycle.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 9261dc9a7..a0699a9c4 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -418,15 +418,16 @@ def get_memory_usage(self): errors = [] waiter = threading.Thread( target=lambda: errors.append( - pytest.raises(RuntimeError, pool.run, "#check Nat").value - ) + pytest.raises(RuntimeError, pool.run, "#check Nat", timeout=5).value + ), + daemon=True, ) waiter.start() deadline = time.monotonic() + 2 while pool._active_calls == 0 and time.monotonic() < deadline: time.sleep(0.01) - stopper = threading.Thread(target=pool.shutdown) + stopper = threading.Thread(target=pool.shutdown, daemon=True) stopper.start() waiter.join(timeout=2) stopper.join(timeout=2) From 5cb7e4dd4198025f54db48497b3b578c1819fcb2 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:55:18 -0400 Subject: [PATCH 060/109] Make the runtime warmup test fail when no prefix is added test_runtime_warmup_prefix_preserves_source_line_numbers sent only "#check autoformMissingIdentifier", which fails on line 1 with or without the Mathlib import in front, so a runtime that stopped adding the warmup still passed. The other runtime-level test needs only Init. The LeanRepl cases that check the warmup bypass the runtime's pool config and its header command. The call now checks the fixture's warmup marker on line 1 and the missing identifier on line 2, and the test asserts one error, reported on line 2. Without the prefix the marker is a second error; without the line shift the error is reported on line 3. --- tests/test_real_repl.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_real_repl.py b/tests/test_real_repl.py index c22ce3e41..298e56a96 100644 --- a/tests/test_real_repl.py +++ b/tests/test_real_repl.py @@ -215,7 +215,7 @@ def test_runtime_warmup_prefix_preserves_source_line_numbers(runtime_dir, monkey "repl.run", { "project_dir": str(REPL_FIXTURE), - "code": "#check autoformMissingIdentifier", + "code": "#check autoformWarmupMarker\n#check autoformMissingIdentifier", "timeout": 180, }, ) @@ -223,4 +223,7 @@ def test_runtime_warmup_prefix_preserves_source_line_numbers(runtime_dir, monkey with suppress(LeanRuntimeUnavailable): client.stop() - assert re.search(r"\b1:\d+: error:", response), response + # Without the warmup prefix the marker is unknown as well, and without the + # line shift the remaining error would be reported on line 3. + assert "Compilation Errors (1)" in response + assert re.search(r"\b2:\d+: error:", response), response From 66ca2973a801f4d9cb237e957276dce14a38a69f Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:56:00 -0400 Subject: [PATCH 061/109] Drop two REPL core test cases that repeat other cases test_disposable_call_rejects_import_before_starting_a_process used the same allowlist, empty warmup and Unsafe header report as the first case of test_disposable_call_rejects_what_lean_reports_before_starting, and asserted the same error, no start and a clean slot. In the warmup-prefix test, _header_json("Init", prelude=True) decodes to the same JSON as the v4.27 literal with isPrelude true, with the same code and frame. Both go. The v4.27 literal stays, since it records real parser output. --- tests/test_repl_core_protocol.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index edb7fdfc7..24d066fd9 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -863,24 +863,6 @@ def test_disposable_call_reports_every_malformed_response_as_unknown( assert frames == ["#eval 1"] -def test_disposable_call_rejects_import_before_starting_a_process(monkeypatch): - repl = repl_core.LeanRepl( - repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib"}), - warmup_imports=frozenset(), - header_command=_fake_header_command(_header_json("Unsafe")), - ) - ) - starts = [] - monkeypatch.setattr(repl, "start", lambda **kwargs: starts.append(kwargs)) - - result = repl.run_disposable("import Unsafe\n#check Nat", timeout=10) - - assert "Disallowed imports: Unsafe" in result["repl_error"] - assert starts == [] - assert repl.is_clean() - - def _fake_header_command(stdout: str, returncode: int = 0, stderr: str = "") -> list[str]: script = ( "import sys; sys.stdin.read(); " @@ -1288,7 +1270,6 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ "prelude\nimport REPL.Frontend\n#check Nat", "prelude\nimport REPL.Frontend\n#check Nat", ), - (_header_json("Init", prelude=True), "prelude\nimport Init\n#check Nat", "prelude\nimport Init\n#check Nat"), (_V4_27_INIT_ONLY % "false", "#check Nat", "import Mathlib\n#check Nat"), ( _V4_27_INIT_ONLY % "true", From 899c500f11ab72ea8b79a9dd9a33bf99b05da10e Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:57:42 -0400 Subject: [PATCH 062/109] Remove test stubs and setup that no test exercises Four disposable-call tests stubbed close, but their start never sets a process and _check_header is stubbed, so the real close() already does nothing; the queued-call budget test stubbed it the same way. Eight pool FakeRepls defined get_memory_usage, which only repl.status calls, one kept a start method the pool no longer calls, and the cold-slot shutdown test's FakeRepl defined is_clean, though its waiter only ever takes the shutdown wakeup. The warmup-prefix test allowed Init, which the header decoder drops before validation, so the allowance would also hide a decoder that stopped dropping it; the production allowlist has no Init. The reaping test's script recorded a child pid that nothing read and now records only the process group. It still spawns the child. --- tests/test_repl_core_protocol.py | 26 +++----------------------- tests/test_repl_pool_lifecycle.py | 31 ------------------------------- 2 files changed, 3 insertions(+), 54 deletions(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 24d066fd9..f50cae178 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -549,10 +549,6 @@ def test_disposable_call_uses_one_decreasing_phase_budget(monkeypatch): monkeypatch.setattr(repl_core.time, "monotonic", lambda: clock["now"]) - def close(**kwargs): - repl.process = None - repl._process_group_id = None - def check_header(code, deadline): assert deadline == 10 clock["now"] = 2 @@ -566,7 +562,6 @@ def run(code, env_id, timeout): budgets.append(("command", timeout, code)) return {"env": 1, "messages": []} - monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "_check_header", check_header) monkeypatch.setattr(repl, "start", start) monkeypatch.setattr(repl, "_run", run) @@ -601,10 +596,6 @@ def test_disposable_timeout_names_the_expired_phase(monkeypatch, expired_phase, monkeypatch.setattr(repl_core.time, "monotonic", lambda: clock["now"]) - def close(**kwargs): - repl.process = None - repl._process_group_id = None - def check_header(code, deadline): if expired_phase == "header": raise TimeoutError("timed out checking the Lean header") @@ -621,7 +612,6 @@ def run(**kwargs): pytest.fail("expired budget dispatched the command") raise repl_core.ReplOutcomeUnknown("transport failed") from TimeoutError() - monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "_check_header", check_header) monkeypatch.setattr(repl, "start", start) monkeypatch.setattr(repl, "_run", run) @@ -637,11 +627,6 @@ def test_disposable_backlog_adjusts_prefixed_positions(monkeypatch): ) ) - def close(**kwargs): - repl.process = None - repl._process_group_id = None - - monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "start", lambda **kwargs: None) monkeypatch.setattr( repl, @@ -722,11 +707,6 @@ def test_unvalidated_disposable_composes_only_proven_ordinary_headers( ) ) - def close(**kwargs): - repl.process = None - repl._process_group_id = None - - monkeypatch.setattr(repl, "close", close) monkeypatch.setattr(repl, "start", lambda **kwargs: None) monkeypatch.setattr(repl, "_check_header", lambda code, deadline: analysis) frames = [] @@ -1281,7 +1261,7 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monkeypatch, header_output, code, frame): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( - allowed_imports=frozenset({"Mathlib", "REPL", "Init"}), + allowed_imports=frozenset({"Mathlib", "REPL"}), warmup_imports=frozenset({"Mathlib"}), header_command=_fake_header_command(header_output), ) @@ -1349,11 +1329,11 @@ def test_header_check_reaps_descendants_after_success(tmp_path): process_record = tmp_path / "processes.json" script = ( "import json, os, subprocess, sys; " - "child = subprocess.Popen([sys.executable, '-c', " + "subprocess.Popen([sys.executable, '-c', " "'import time; time.sleep(30)'], stdout=subprocess.DEVNULL, " "stderr=subprocess.DEVNULL); " f"open({str(process_record)!r}, 'w').write(json.dumps(" - "{'child': child.pid, 'group': os.getpgrp()})); " + "{'group': os.getpgrp()})); " "sys.stdin.read(); " f"sys.stdout.write({_header_json('Mathlib')!r})" ) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index a0699a9c4..eef9cb103 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -65,9 +65,6 @@ def __init__(self, config): self.close_calls = 0 workers.append(self) - def start(self): - pass - def close(self): self.close_calls += 1 if self is workers[0] and self.close_calls == 1: @@ -158,9 +155,6 @@ def is_clean(self): def close(self): pass - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) monkeypatch.setattr(repl_pool.time, "monotonic", lambda: next(times)) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) @@ -185,7 +179,6 @@ def run(code, env_id, timeout): command_timeouts.append(timeout) raise repl_core.ReplOutcomeUnknown("transport failed") from TimeoutError() - monkeypatch.setattr(repl, "close", lambda **kwargs: None) monkeypatch.setattr(repl, "start", lambda **kwargs: None) monkeypatch.setattr(repl, "_run", run) # The wait for an idle slot takes 2 of the caller's 10 seconds. @@ -220,9 +213,6 @@ def is_clean(self): def close(self): pass - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) try: @@ -255,9 +245,6 @@ def close(self): self.close_calls += 1 self.dirty = False - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) @@ -288,9 +275,6 @@ def is_clean(self): def close(self): self.dirty = False - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) @@ -319,9 +303,6 @@ def is_clean(self): def close(self): pass - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) held = pool._idle.get_nowait() @@ -370,9 +351,6 @@ def close(self): assert allow_cleanup.wait(timeout=2) self.dirty = False - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) results = [] @@ -403,15 +381,9 @@ class FakeRepl: def __init__(self, config): pass - def is_clean(self): - return True - def close(self): pass - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) pool._idle.get_nowait() @@ -458,9 +430,6 @@ def is_clean(self): def close(self): close_started.set() - def get_memory_usage(self): - return 0.0 - monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) run_thread = threading.Thread(target=lambda: pool.run("#check Nat", timeout=1)) From a69b172eff11fb379bf4dc5a93e13cb3a924f5d5 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 11:58:31 -0400 Subject: [PATCH 063/109] Fix stale test names and an environment-dependent assertion test_shared_runtime_disables_ambiguous_repl_retries lost its max_retries assertion with the resident REPL path and checks only that a pool gets the runtime's header command, so it is now named for that, as is its project. The partial-startup pool test fails during construction like its sibling, which this branch renamed, so it takes the same name form and message. The disposable pool test never presents a dirty slot, so its name says it reuses a clean slot rather than only a clean one. test_default_repl_command_is_package_qualified asserts the 240-second default timeout but cleared neither AUTOFORM_REPL_REQUEST_TIMEOUT nor AUTOFORM_MAX_REPL_REQUEST_SECONDS, so it failed for anyone who exported either documented setting. It now clears both. --- tests/test_repl_pool_lifecycle.py | 8 ++++---- tests/test_shared_lean_runtime.py | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index eef9cb103..4a6a2d225 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -86,7 +86,7 @@ def close(self): assert [worker.close_calls for worker in workers] == [2, 1] -def test_partial_startup_transfers_failed_cleanup_for_retry(monkeypatch): +def test_partial_construction_transfers_failed_cleanup_for_retry(monkeypatch): workers = [] class FakeRepl: @@ -95,7 +95,7 @@ def __init__(self, config): self.close_calls = 0 workers.append(self) if self.number == 2: - raise RuntimeError("second worker failed") + raise RuntimeError("second slot failed") def close(self): self.close_calls += 1 @@ -107,7 +107,7 @@ def close(self): with pytest.raises(repl_pool.LeanReplPoolStartupError) as raised: repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=2)) - assert str(raised.value.cause) == "second worker failed" + assert str(raised.value.cause) == "second slot failed" assert raised.value.pool._workers == [workers[0]] assert [worker.close_calls for worker in workers] == [1, 0] with pytest.raises(RuntimeError, match="shut down"): @@ -195,7 +195,7 @@ def run(code, env_id, timeout): assert command_timeouts == [8] -def test_pool_runs_each_call_disposably_and_reuses_only_a_clean_slot(monkeypatch): +def test_pool_runs_each_call_disposably_and_reuses_a_clean_slot(monkeypatch): workers = [] class FakeRepl: diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 629fcff7a..166c30a02 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -597,10 +597,10 @@ def get_memory_usage(self): services.close() -def test_shared_runtime_disables_ambiguous_repl_retries(tmp_path, monkeypatch): +def test_runtime_passes_the_header_command_to_each_pool(tmp_path, monkeypatch): from servers import lean_runtime - project = make_lake_project(tmp_path, "at-most-once") + project = make_lake_project(tmp_path, "header-command") configs = [] class CapturingPool(FakePool): @@ -1833,6 +1833,8 @@ def test_per_project_workers_cannot_exceed_node_budget(monkeypatch): def test_default_repl_command_is_package_qualified(monkeypatch): monkeypatch.delenv("LEAN_REPL_CMD", raising=False) monkeypatch.delenv("LEAN_REPL_HEADER_CMD", raising=False) + monkeypatch.delenv("AUTOFORM_REPL_REQUEST_TIMEOUT", raising=False) + monkeypatch.delenv("AUTOFORM_MAX_REPL_REQUEST_SECONDS", raising=False) config = LeanRuntimeConfig.from_environment() From ac12a809ac02d605f22402207fa8896d32589efe Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 12:00:16 -0400 Subject: [PATCH 064/109] Model the idle-slot wait with a controlled clock in pool tests test_pool_subtracts_queue_time_from_the_disposable_call replaced time.monotonic, process-wide since repl_pool.time is the time module, with an iterator over exactly the three values pool.run reads today. One more read, from pool.run or any thread, would shift the values or raise StopIteration inside pool.run, failing the test for a reason unrelated to queue time. The queued-call budget test used the same iterator with a fallback value. Both tests now read a clock dict and patch the idle queue's get to advance it by 2 seconds, so only the wait for the slot takes time. The patched get forwards every argument, since shutdown drains the queue through get_nowait. With an extra clock read added to pool.run both tests still pass; with the queue time no longer subtracted both fail. --- tests/test_repl_pool_lifecycle.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 4a6a2d225..6ebc8178b 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -139,7 +139,7 @@ def close(self): def test_pool_subtracts_queue_time_from_the_disposable_call(monkeypatch): observed = [] - times = iter((0.0, 0.0, 2.0)) + clock = {"now": 0.0} class FakeRepl: def __init__(self, config): @@ -156,8 +156,15 @@ def close(self): pass monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) - monkeypatch.setattr(repl_pool.time, "monotonic", lambda: next(times)) + monkeypatch.setattr(repl_pool.time, "monotonic", lambda: clock["now"]) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + take_slot = pool._idle.get + + def wait_for_slot(*args, **kwargs): + clock["now"] += 2 + return take_slot(*args, **kwargs) + + monkeypatch.setattr(pool._idle, "get", wait_for_slot) assert pool.run("#check Nat", timeout=10) == {"messages": []} assert observed == [8] @@ -182,8 +189,15 @@ def run(code, env_id, timeout): monkeypatch.setattr(repl, "start", lambda **kwargs: None) monkeypatch.setattr(repl, "_run", run) # The wait for an idle slot takes 2 of the caller's 10 seconds. - times = iter((0.0, 0.0)) - monkeypatch.setattr(repl_pool.time, "monotonic", lambda: next(times, 2.0)) + clock = {"now": 0.0} + take_slot = pool._idle.get + + def wait_for_slot(*args, **kwargs): + clock["now"] += 2 + return take_slot(*args, **kwargs) + + monkeypatch.setattr(repl_pool.time, "monotonic", lambda: clock["now"]) + monkeypatch.setattr(pool._idle, "get", wait_for_slot) try: assert pool.run("#check Nat", timeout=10) == { "repl_error": "Lean REPL combined command execution timed out after 10 seconds", From c8b63370cc7bec7eec6295b02abf4b69931209fa Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 12:01:03 -0400 Subject: [PATCH 065/109] Assert that the wheel ships the REPL header parser Every REPL call runs servers/repl/parse_header.lean, which LEAN_HEADER_PARSER resolves next to the installed core.py. Without it the header command exits nonzero and every call fails its header check. The wheel-contents test pinned the CLI's Lean probes but not this file, so a packaging change that kept only the Python files under servers/ would still pass CI. The test now asserts the parser too. A wheel built from this branch already ships it. --- tests/test_plugin_runtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_plugin_runtime.py b/tests/test_plugin_runtime.py index fb59b2cdc..0f4bb8510 100644 --- a/tests/test_plugin_runtime.py +++ b/tests/test_plugin_runtime.py @@ -210,6 +210,7 @@ def test_wheel_contains_only_the_minimal_runtime(repo_root, tmp_path): "servers/lean_runtime.py", "servers/lsp/server.py", "servers/repl/core.py", + "servers/repl/parse_header.lean", "servers/repl/server.py", } <= names assert "autoform_cli/lake.py" not in names From b49f78a5b2b157a960e58d5c40024db2bb024e4d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:53:56 -0400 Subject: [PATCH 066/109] Give REPL isolation its own README paragraph The REPL isolation and header validation sentences sat inside the runtime lifecycle paragraph, so "Closing the session that started it does not stop it" followed the `@repl/repl` target and read as being about it. They now form their own paragraph, the lifecycle sentence names the runtime, and the text is reflowed to the file's width. The doc test compares whitespace-normalized text, as its neighbours do, so a reflow no longer breaks a pinned phrase. --- servers/README.md | 30 +++++++++++++++--------------- tests/test_skill_examples.py | 6 +++--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/servers/README.md b/servers/README.md index ebe4abecb..b26946298 100644 --- a/servers/README.md +++ b/servers/README.md @@ -11,21 +11,21 @@ Plugin hosts start the two stdio MCP processes automatically. They are lightweight adapters: the first Lean tool call race-safely starts a detached runtime for the current AutoformBot installation, Unix user, and compute node. That runtime owns one REPL admission pool and LSP session per active Lean -project. LSP sessions stay warm; each public REPL call gets a fresh child and -no process-owned environment or proof-state handle survives the response. -Before that child starts, the selected Lake toolchain runs Lean's own header -parser, which the REPL also uses, on the submitted source. Every import it -reports is validated, and a rejected or -unrecognized parser response fails closed. Allowlisted imports require -plain, nonempty canonical module components; names that remain quoted or -path-like after Lean parsing are refused instead of being reduced to a -misleading allowlisted root. The header check runs as one extra short Lean -process per call and counts toward the same request budget as the rest of -the call. Execution names the -package-qualified `@repl/repl` target so a project target cannot shadow it. -Closing the session that started it does not stop it; after a crash, the next -tool call starts it again. Runtime sockets include a code fingerprint, so an -in-place upgrade gracefully replaces the older build. +project. Closing the session that started the runtime does not stop it; after a +crash, the next tool call starts it again. Runtime sockets include a code +fingerprint, so an in-place upgrade gracefully replaces the older build. + +LSP sessions stay warm; each public REPL call gets a fresh child and no +process-owned environment or proof-state handle survives the response. Before +that child starts, the selected Lake toolchain runs Lean's own header parser, +which the REPL also uses, on the submitted source. Every import it reports is +validated, and a rejected or unrecognized parser response fails closed. +Allowlisted imports require plain, nonempty canonical module components; names +that remain quoted or path-like after Lean parsing are refused instead of being +reduced to a misleading allowlisted root. The header check runs as one extra +short Lean process per call and counts toward the same request budget as the +rest of the call. Execution names the package-qualified `@repl/repl` target so a +project target cannot shadow it. REPL children and LSP processes remain lazy. A cold tool call stays pending while Lean warms up, so no `/repl-start`, `/lsp-start`, or model-side sleep is diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index b03c3166b..7adc425f5 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -244,8 +244,8 @@ def test_quick_start_keeps_the_cli_agent_facing(repo_root: Path) -> None: def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) -> None: - servers = (repo_root / "servers/README.md").read_text(encoding="utf-8") - setup = (repo_root / "skills/setup/SKILL.md").read_text(encoding="utf-8") + servers = " ".join((repo_root / "servers/README.md").read_text(encoding="utf-8").split()) + setup = " ".join((repo_root / "skills/setup/SKILL.md").read_text(encoding="utf-8").split()) lakefile = ( repo_root / "skills/setup/assets/cabannes-thesis-project/lakefile.toml" ).read_text(encoding="utf-8") @@ -254,7 +254,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain runs Lean's own header" in servers assert "unrecognized parser response fails closed" in servers - assert "names that remain quoted or\npath-like after Lean parsing are refused" in servers + assert "names that remain quoted or path-like after Lean parsing are refused" in servers assert "package-qualified `@repl/repl` target" in servers assert "finds the REPL only through a require named `repl`" in servers assert 'needs `LEAN_REPL_CMD="lake exe @/repl"`' in servers From f4be78b38d328419e8ae2b8ccb97c94a56b3e1e1 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:54:27 -0400 Subject: [PATCH 067/109] Say the REPL import allowlist is not a sandbox The servers README described the allowlist only in fail-closed terms, so it read like a security boundary. It now says the allowlist is a policy check: each call runs with the runtime user's privileges, and Init alone provides file and process IO, such as #eval IO.Process.run, without any import. The doc test pins the caveat. --- servers/README.md | 10 ++++++---- tests/test_skill_examples.py | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/servers/README.md b/servers/README.md index b26946298..1fa7be16c 100644 --- a/servers/README.md +++ b/servers/README.md @@ -22,10 +22,12 @@ which the REPL also uses, on the submitted source. Every import it reports is validated, and a rejected or unrecognized parser response fails closed. Allowlisted imports require plain, nonempty canonical module components; names that remain quoted or path-like after Lean parsing are refused instead of being -reduced to a misleading allowlisted root. The header check runs as one extra -short Lean process per call and counts toward the same request budget as the -rest of the call. Execution names the package-qualified `@repl/repl` target so a -project target cannot shadow it. +reduced to a misleading allowlisted root. The allowlist is a policy check, not a +sandbox: each call runs with the runtime user's privileges, and `Init` alone +provides file and process IO, for example `#eval IO.Process.run`. The header +check runs as one extra short Lean process per call and counts toward the same +request budget as the rest of the call. Execution names the package-qualified +`@repl/repl` target so a project target cannot shadow it. REPL children and LSP processes remain lazy. A cold tool call stays pending while Lean warms up, so no `/repl-start`, `/lsp-start`, or model-side sleep is diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 7adc425f5..1352bce78 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -255,6 +255,8 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "toolchain runs Lean's own header" in servers assert "unrecognized parser response fails closed" in servers assert "names that remain quoted or path-like after Lean parsing are refused" in servers + assert "The allowlist is a policy check, not a sandbox" in servers + assert "`Init` alone provides file and process IO" in servers assert "package-qualified `@repl/repl` target" in servers assert "finds the REPL only through a require named `repl`" in servers assert 'needs `LEAN_REPL_CMD="lake exe @/repl"`' in servers From 75336aa2dfea01f174e7ad30e3049fe988a8e1e8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:55:14 -0400 Subject: [PATCH 068/109] State the per-call cost of disposable REPL calls The README still said a cold tool call stays pending while Lean warms up, which on main described only a project's first call. Now every REPL call starts Lean afresh and, unless its header starts with `module` or `prelude`, imports Mathlib, so each call takes at least as long as `import Mathlib` on the node, and with the default of one slot per project calls to the same project queue. The README now says so, and the doc test pins it. --- servers/README.md | 19 +++++++++++-------- tests/test_skill_examples.py | 3 +++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/servers/README.md b/servers/README.md index 1fa7be16c..e3c8ce4f7 100644 --- a/servers/README.md +++ b/servers/README.md @@ -29,14 +29,17 @@ check runs as one extra short Lean process per call and counts toward the same request budget as the rest of the call. Execution names the package-qualified `@repl/repl` target so a project target cannot shadow it. -REPL children and LSP processes remain lazy. A cold tool call stays pending -while Lean warms up, so no `/repl-start`, `/lsp-start`, or model-side sleep is -needed. The REPL `timeout` is one total post-admission budget for an idle-slot -wait, header validation, child startup, generated imports, and submitted code; -the default and maximum are 240 seconds. Project-cache admission uses the -daemon response budget separately. Idle project state is closed after 30 -minutes by default, while the small runtime remains available. Its lifecycle -is also explicit: +REPL children and LSP processes remain lazy. A tool call stays pending while +Lean starts, so no `/repl-start`, `/lsp-start`, or model-side sleep is needed. +Every REPL call starts Lean afresh and, unless its header starts with `module` +or `prelude`, imports Mathlib, so it takes at least as long as `import Mathlib` +on the node. Calls to the same project run one at a time unless +`AUTOFORM_REPL_WORKERS_PER_PROJECT` is raised. The REPL `timeout` is one total +post-admission budget for an idle-slot wait, header validation, child startup, +generated imports, and submitted code; the default and maximum are 240 seconds. +Project-cache admission uses the daemon response budget separately. Idle project +state is closed after 30 minutes by default, while the small runtime remains +available. Its lifecycle is also explicit: ```bash uv run autoform-lean-runtime start diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 1352bce78..7274bb0ca 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -258,6 +258,9 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "The allowlist is a policy check, not a sandbox" in servers assert "`Init` alone provides file and process IO" in servers assert "package-qualified `@repl/repl` target" in servers + assert "Every REPL call starts Lean afresh" in servers + assert "so it takes at least as long as `import Mathlib` on the node" in servers + assert "run one at a time unless `AUTOFORM_REPL_WORKERS_PER_PROJECT` is raised" in servers assert "finds the REPL only through a require named `repl`" in servers assert 'needs `LEAN_REPL_CMD="lake exe @/repl"`' in servers assert "so the header command is still derived from" in servers From 81c70385a9efcd1fe55f053f018f1de25c091eee Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:56:16 -0400 Subject: [PATCH 069/109] Call the per-project REPL pool a slot pool throughout The servers README named one concept three ways: an admission pool, an idle slot, and a cold wrapper pool, where "wrapper" is a Python detail. It now says "REPL slot pool" everywhere and defines a REPL worker, the unit the WORKERS variables count, as one concurrent call slot that holds no Lean process while idle. The doc test pins the terms. --- servers/README.md | 11 ++++++----- tests/test_skill_examples.py | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/servers/README.md b/servers/README.md index e3c8ce4f7..842c611a7 100644 --- a/servers/README.md +++ b/servers/README.md @@ -10,7 +10,7 @@ when useful. Plugin hosts start the two stdio MCP processes automatically. They are lightweight adapters: the first Lean tool call race-safely starts a detached runtime for the current AutoformBot installation, Unix user, and compute node. -That runtime owns one REPL admission pool and LSP session per active Lean +That runtime owns one REPL slot pool and LSP session per active Lean project. Closing the session that started the runtime does not stop it; after a crash, the next tool call starts it again. Runtime sockets include a code fingerprint, so an in-place upgrade gracefully replaces the older build. @@ -54,8 +54,9 @@ The private socket lives below `$XDG_RUNTIME_DIR/autoform`, falling back to a uid-specific directory in `/tmp`; the rotating runtime log is beside it. `AUTOFORM_RUNTIME_DIR` overrides that location. Node-wide limits are controlled by `AUTOFORM_REPL_TOTAL_WORKERS`, `AUTOFORM_REPL_WORKERS_PER_PROJECT`, -`AUTOFORM_MAX_LEAN_PROJECTS`, and `AUTOFORM_LEAN_IDLE_SECONDS`. The first -process to start the runtime supplies those settings until it is stopped. +`AUTOFORM_MAX_LEAN_PROJECTS`, and `AUTOFORM_LEAN_IDLE_SECONDS`. A REPL worker is +one concurrent call slot; an idle slot holds no Lean process. The first process +to start the runtime supplies those settings until it is stopped. `LEAN_REPL_CMD` selects the disposable child command. Its default, `lake exe @repl/repl`, finds the REPL only through a require named `repl`. A require with any other name needs `LEAN_REPL_CMD="lake exe @/repl"`, @@ -73,5 +74,5 @@ so the LSP tools are unavailable as well. `AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, the tool operation, and a safety margin. Failed cleanup transfers to a retained background reaper and may continue past that response budget rather than -allowing an overlapping replacement. A `warm` REPL project means its cold -wrapper pool is cached; it does not mean a REPL subprocess remains resident. +allowing an overlapping replacement. A `warm` REPL project means its REPL +slot pool is cached; it does not mean a REPL subprocess remains resident. diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 7274bb0ca..62a722811 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -250,6 +250,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) repo_root / "skills/setup/assets/cabannes-thesis-project/lakefile.toml" ).read_text(encoding="utf-8") + assert "one REPL slot pool" in servers assert "each public REPL call gets a fresh child" in servers assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain runs Lean's own header" in servers @@ -261,11 +262,13 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "Every REPL call starts Lean afresh" in servers assert "so it takes at least as long as `import Mathlib` on the node" in servers assert "run one at a time unless `AUTOFORM_REPL_WORKERS_PER_PROJECT` is raised" in servers + assert "A REPL worker is one concurrent call slot; an idle slot holds no Lean process" in servers assert "finds the REPL only through a require named `repl`" in servers assert 'needs `LEAN_REPL_CMD="lake exe @/repl"`' in servers assert "so the header command is still derived from" in servers assert "one total post-admission budget" in servers assert "default and maximum are 240 seconds" in servers + assert "its REPL slot pool is cached" in servers assert "does not mean a REPL subprocess remains resident" in servers assert "`LEAN_REPL_HEADER_CMD`" in servers assert "is the lower of 240 seconds and that cap" in servers From 4915e1660be19b988149f36d7d2899636f28dd0b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:57:14 -0400 Subject: [PATCH 070/109] Say the REPL commands apply to every project the runtime serves The README offered LEAN_REPL_CMD="lake exe @/repl" as if it were a per-project fix. The runtime reads LEAN_REPL_CMD and LEAN_REPL_HEADER_CMD once, when it starts, and uses them for every project it serves, so a changed value has no effect until the runtime is stopped, and a renamed REPL command breaks every other project whose require is named `repl`. The README now gives the remedy in the same words as the Lake hint: rename the require, or restart the runtime with the other command, which applies to every project. It also says how to make a change take effect. --- servers/README.md | 16 ++++++++++------ tests/test_skill_examples.py | 8 +++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/servers/README.md b/servers/README.md index 842c611a7..482f6cc25 100644 --- a/servers/README.md +++ b/servers/README.md @@ -58,13 +58,17 @@ by `AUTOFORM_REPL_TOTAL_WORKERS`, `AUTOFORM_REPL_WORKERS_PER_PROJECT`, one concurrent call slot; an idle slot holds no Lean process. The first process to start the runtime supplies those settings until it is stopped. `LEAN_REPL_CMD` selects the disposable child command. Its default, -`lake exe @repl/repl`, finds the REPL only through a require named `repl`. A -require with any other name needs `LEAN_REPL_CMD="lake exe @/repl"`, -which still starts with `lake`, so the header command is still derived from -it. An absolute `lake` executable is reused for header validation; a container -or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs +`lake exe @repl/repl`, finds the REPL only through a require named `repl`. For a +require with any other name, rename the require to `repl`, or restart the +runtime with `LEAN_REPL_CMD="lake exe @/repl"` for a require named +``, which applies to every project the runtime serves. That command still +starts with `lake`, so the header command is still derived from it. An absolute +`lake` executable is reused for header validation; a container or other wrapper +must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, -passing the source on standard input. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps +passing the source on standard input. Like the limits above, both commands are +node-wide and read only when the runtime starts; after changing either, run +`uv run autoform-lean-runtime stop`. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default `timeout` is the lower of 240 seconds and that cap. If `LEAN_REPL_CMD` does not invoke `lake` and `LEAN_REPL_HEADER_CMD` is unset, if diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 62a722811..0404f7175 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -264,8 +264,14 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "run one at a time unless `AUTOFORM_REPL_WORKERS_PER_PROJECT` is raised" in servers assert "A REPL worker is one concurrent call slot; an idle slot holds no Lean process" in servers assert "finds the REPL only through a require named `repl`" in servers - assert 'needs `LEAN_REPL_CMD="lake exe @/repl"`' in servers + assert ( + "rename the require to `repl`, or restart the runtime with " + '`LEAN_REPL_CMD="lake exe @/repl"` for a require named ``, ' + "which applies to every project the runtime serves" + ) in servers assert "so the header command is still derived from" in servers + assert "both commands are node-wide and read only when the runtime starts" in servers + assert "after changing either, run `uv run autoform-lean-runtime stop`" in servers assert "one total post-admission budget" in servers assert "default and maximum are 240 seconds" in servers assert "its REPL slot pool is cached" in servers From 50130f5a9d71d0dee48e5149fc3a836500eec40f Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:58:43 -0400 Subject: [PATCH 071/109] Describe the REPL environment variables as the runtime reads them Three README statements did not match the code. The runtime derives the header command only when the first word of LEAN_REPL_CMD is a `lake` executable, so "does not invoke `lake`" misdescribed a wrapper such as `nice lake ...`, which invokes lake and is still refused; the README now says "does not start with a `lake` executable", in the same words as the startup error. Only that executable is reused for header validation, not the command's other arguments. And AUTOFORM_RUNTIME_RESPONSE_TIMEOUT has regained the 15-minute default that main documented. --- servers/README.md | 16 ++++++++-------- tests/test_skill_examples.py | 4 +++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/servers/README.md b/servers/README.md index 482f6cc25..5d177d47e 100644 --- a/servers/README.md +++ b/servers/README.md @@ -61,9 +61,9 @@ to start the runtime supplies those settings until it is stopped. `lake exe @repl/repl`, finds the REPL only through a require named `repl`. For a require with any other name, rename the require to `repl`, or restart the runtime with `LEAN_REPL_CMD="lake exe @/repl"` for a require named -``, which applies to every project the runtime serves. That command still -starts with `lake`, so the header command is still derived from it. An absolute -`lake` executable is reused for header validation; a container or other wrapper +``, which applies to every project the runtime serves. If `LEAN_REPL_CMD` +starts with a `lake` executable, absolute or not, header validation reuses that +executable without the command's other arguments. A container or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, passing the source on standard input. Like the limits above, both commands are @@ -71,12 +71,12 @@ node-wide and read only when the runtime starts; after changing either, run `uv run autoform-lean-runtime stop`. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default `timeout` is the lower of 240 seconds and that cap. If `LEAN_REPL_CMD` does not -invoke `lake` and `LEAN_REPL_HEADER_CMD` is unset, if +start with a `lake` executable and `LEAN_REPL_HEADER_CMD` is unset, if `AUTOFORM_MAX_REPL_REQUEST_SECONDS` exceeds 240, or if an explicit `AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, the runtime refuses to start, so the LSP tools are unavailable as well. -`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` bounds project admission, the tool -operation, and a safety margin. Failed cleanup transfers to a retained -background reaper and may continue past that response budget rather than -allowing an overlapping replacement. A `warm` REPL project means its REPL +`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` (default 15 minutes) bounds project +admission, the tool operation, and a safety margin. Failed cleanup transfers to +a retained background reaper and may continue past that response budget rather +than allowing an overlapping replacement. A `warm` REPL project means its REPL slot pool is cached; it does not mean a REPL subprocess remains resident. diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 0404f7175..903d8d445 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -269,7 +269,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) '`LEAN_REPL_CMD="lake exe @/repl"` for a require named ``, ' "which applies to every project the runtime serves" ) in servers - assert "so the header command is still derived from" in servers + assert "header validation reuses that executable without the command's other arguments" in servers assert "both commands are node-wide and read only when the runtime starts" in servers assert "after changing either, run `uv run autoform-lean-runtime stop`" in servers assert "one total post-admission budget" in servers @@ -278,7 +278,9 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "does not mean a REPL subprocess remains resident" in servers assert "`LEAN_REPL_HEADER_CMD`" in servers assert "is the lower of 240 seconds and that cap" in servers + assert "If `LEAN_REPL_CMD` does not start with a `lake` executable" in servers assert "`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, the runtime refuses to start" in servers + assert "`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` (default 15 minutes)" in servers assert "`leanprover-community/repl` at an immutable revision" in setup assert "Name the require `repl`" in setup assert "which finds the package only under its require name" in setup From 3c80a3cb75cfa17fbff9edbee801845b55dad46d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 08:59:39 -0400 Subject: [PATCH 072/109] Drop the 240-second REPL cap from the servers README AUTOFORM_MAX_REPL_REQUEST_SECONDS no longer has a hard 240-second ceiling; as on main, and as for the LSP maximum, only the response-budget check bounds it. The README no longer lists exceeding 240 as a reason the runtime refuses to start. It names the check that remains instead, a cap that does not fit the response budget, and it says 240 seconds is the default for the timeout and its cap rather than a fixed maximum. --- servers/README.md | 10 +++++----- tests/test_skill_examples.py | 7 +++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/servers/README.md b/servers/README.md index 5d177d47e..3b514d55c 100644 --- a/servers/README.md +++ b/servers/README.md @@ -36,7 +36,7 @@ or `prelude`, imports Mathlib, so it takes at least as long as `import Mathlib` on the node. Calls to the same project run one at a time unless `AUTOFORM_REPL_WORKERS_PER_PROJECT` is raised. The REPL `timeout` is one total post-admission budget for an idle-slot wait, header validation, child startup, -generated imports, and submitted code; the default and maximum are 240 seconds. +generated imports, and submitted code; it and its cap default to 240 seconds. Project-cache admission uses the daemon response budget separately. Idle project state is closed after 30 minutes by default, while the small runtime remains available. Its lifecycle is also explicit: @@ -71,10 +71,10 @@ node-wide and read only when the runtime starts; after changing either, run `uv run autoform-lean-runtime stop`. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default `timeout` is the lower of 240 seconds and that cap. If `LEAN_REPL_CMD` does not -start with a `lake` executable and `LEAN_REPL_HEADER_CMD` is unset, if -`AUTOFORM_MAX_REPL_REQUEST_SECONDS` exceeds 240, or if an explicit -`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, the runtime refuses to start, -so the LSP tools are unavailable as well. +start with a `lake` executable and `LEAN_REPL_HEADER_CMD` is unset, if an +explicit `AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, or if that cap does +not fit the response budget below, the runtime refuses to start, so the LSP +tools are unavailable as well. `AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` (default 15 minutes) bounds project admission, the tool operation, and a safety margin. Failed cleanup transfers to a retained background reaper and may continue past that response budget rather diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 903d8d445..90bda95a1 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -273,13 +273,16 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "both commands are node-wide and read only when the runtime starts" in servers assert "after changing either, run `uv run autoform-lean-runtime stop`" in servers assert "one total post-admission budget" in servers - assert "default and maximum are 240 seconds" in servers + assert "it and its cap default to 240 seconds" in servers assert "its REPL slot pool is cached" in servers assert "does not mean a REPL subprocess remains resident" in servers assert "`LEAN_REPL_HEADER_CMD`" in servers assert "is the lower of 240 seconds and that cap" in servers assert "If `LEAN_REPL_CMD` does not start with a `lake` executable" in servers - assert "`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, the runtime refuses to start" in servers + assert ( + "`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, or if that cap does not fit " + "the response budget below, the runtime refuses to start" + ) in servers assert "`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` (default 15 minutes)" in servers assert "`leanprover-community/repl` at an immutable revision" in setup assert "Name the require `repl`" in setup From b20638b41d1c58877b2fb5f0fe860ddf27ea78a0 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:01:54 -0400 Subject: [PATCH 073/109] Say how to run the real-REPL tests locally CONTRIBUTING named the opt-in variable but not how to prepare a run, and with the variable set and no built fixture the module fails rather than skips. It now gives the three commands the CI job runs: build the repl-smoke fixture, install the toolchain in tests/fixtures/minimum-lean, then run the module with the variable set. It also says the job checks the header parser on the oldest Lean release Autoform supports, and the paragraph is wrapped at 80 columns again. --- CONTRIBUTING.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86e9f510d..1051b00f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,15 +18,25 @@ CI (`.github/workflows/tests.yml`) runs the same four steps on Python 3.10 and 3.13 for every push and pull request. A separate job runs `tests/test_skeleton.py` and one `tests/test_project_inspect.py` test against a real Lean toolchain; another runs the inspection, bounded-subprocess, and -transactional-output tests on Windows; a third builds `tests/fixtures/repl-smoke` -and runs `tests/test_real_repl.py` against the pinned upstream REPL. That module -skips unless `AUTOFORM_RUN_REAL_REPL_TESTS=1`. Locally, tests that need Lean skip when +transactional-output tests on Windows; a third builds +`tests/fixtures/repl-smoke` and runs `tests/test_real_repl.py` against the +pinned upstream REPL, also checking the header parser on the oldest Lean release +Autoform supports. Locally, tests that need Lean skip when `lake` is not on `PATH`. The `tests/test_skeleton.py` ones also skip when the toolchain pinned in `tests/fixtures/skeleton-project/lean-toolchain` is missing; the others let elan download it. Run `lake build` in `skills/setup/assets/cabannes-thesis-project` when you change the example's Lean sources or declarations. +`tests/test_real_repl.py` skips unless `AUTOFORM_RUN_REAL_REPL_TESTS=1`. To run +it locally, build its fixture and install the oldest supported toolchain first: + +```bash +(cd tests/fixtures/repl-smoke && lake build Mathlib Secret @repl/repl) +elan toolchain install "$(cat tests/fixtures/minimum-lean/lean-toolchain)" +AUTOFORM_RUN_REAL_REPL_TESTS=1 uv run pytest -q tests/test_real_repl.py +``` + ## Pull Requests We actively welcome your pull requests. From e8d5a040a8cc2064c6032d9746b3cd749612ed41 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:03:06 -0400 Subject: [PATCH 074/109] Say which REPL revision to pin for a project's Lean release The setup skill asked for a REPL revision "tested against the project's exact Lean toolchain" and said never to guess, but gave no way to find one for any release but the example's; v4.34.1, the other bundled release, has no such revision anywhere. Upstream tags one REPL commit per Lean release, so the skill now says to pin the commit of the tag naming the project's Lean version or, failing that, its vX.Y.0 release, as the example pins tag v4.32.0 for v4.32.2, how to list the tags, and to ask the user when neither exists. The code-block comment no longer puts the REPL require in the manifest. The doc test pins the rule. --- skills/setup/SKILL.md | 12 +++++++----- tests/test_skill_examples.py | 4 ++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 2c81ff0e4..2069fa8cd 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -97,13 +97,15 @@ run `autoform init` through the same `uv run --project "$AUTOFORM_PLUGIN_ROOT"` prefix only for the Autoform vault/site repair overlay. Neither `project new` nor `init` adds the Lean REPL that the REPL tool runs. -Declare `leanprover-community/repl` at an immutable revision tested against the -project's exact Lean toolchain, such as the bundled example's pin for v4.32.2. +Declare `leanprover-community/repl` at an immutable revision: the commit of the +upstream tag that names the project's Lean version or, failing that, its +`vX.Y.0` release, as the bundled example pins the commit of tag `v4.32.0` for +v4.32.2. `git ls-remote --tags https://github.com/leanprover-community/repl.git` +lists the tags; if neither exists, ask the user instead of guessing. Name the require `repl`, as that example does: the runtime runs `lake exe @repl/repl`, which finds the package only under its require name. Then run `lake update repl` so an existing manifest locks it without moving -Mathlib, commit the manifest, and verify `lake build @repl/repl`; never guess a -compatible revision. +Mathlib, commit the manifest, and verify `lake build @repl/repl`. `autoform init` is the whole vault: `blueprint/` with its landing page, `roadmap/README.md`, `coverage/`, and `sources/`, plus `mkdocs.yml`, the theme @@ -165,7 +167,7 @@ then run the publication sequence: ```bash lake update # only when the project has no lake-manifest.json -lake update repl # after adding the REPL require to an existing manifest +lake update repl # after adding the REPL require when a manifest exists lake exe cache get # skip only when the project has no Mathlib dependency lake build lake build @repl/repl diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 90bda95a1..c1e6492b1 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -285,6 +285,10 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) ) in servers assert "`AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` (default 15 minutes)" in servers assert "`leanprover-community/repl` at an immutable revision" in setup + assert "the commit of the upstream tag that names the project's Lean version" in setup + assert "failing that, its `vX.Y.0` release" in setup + assert "git ls-remote --tags https://github.com/leanprover-community/repl.git" in setup + assert "if neither exists, ask the user instead of guessing" in setup assert "Name the require `repl`" in setup assert "which finds the package only under its require name" in setup assert "run `lake update repl`" in setup From d7e7135ce23e924abcc2ae87e4003017a7843d9e Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:03:45 -0400 Subject: [PATCH 075/109] List the example's lake manifest among its pins The example README said `lean-toolchain`, `lakefile.toml`, and `CabannesThesis/` pin the releases and the REPL commit. The committed `lake-manifest.json` is the lock and was missing from the list, and the Lean sources live in `src/`, not `CabannesThesis/`. The bullet now lists the manifest, names the REPL commit's upstream tag, v4.32.0, and points to `src/`. --- skills/setup/assets/cabannes-thesis-project/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/setup/assets/cabannes-thesis-project/README.md b/skills/setup/assets/cabannes-thesis-project/README.md index 7b251b25c..8a1c036a5 100644 --- a/skills/setup/assets/cabannes-thesis-project/README.md +++ b/skills/setup/assets/cabannes-thesis-project/README.md @@ -8,8 +8,9 @@ slice as a handoff example. Developed with [AutoformBot](https://github.com/facebookresearch/autoform-bot). -- `lean-toolchain`, `lakefile.toml`, and `CabannesThesis/` pin matching stable - Lean and Mathlib `v4.32.2` releases plus the compatible upstream REPL commit. +- `lean-toolchain`, `lakefile.toml`, and `lake-manifest.json` pin matching + stable Lean and Mathlib `v4.32.2` releases plus the upstream REPL commit + tagged `v4.32.0`; the Lean sources are in `src/`. - `blueprint/` is an Obsidian-compatible Markdown vault with roadmap, coverage, sources, and a seven-node theorem DAG spanning two formalization chapters. - `mkdocs.yml` builds the `autoform render` output as a leanblueprint-styled From 4fbb7325d0bfeff6077cdffa6cc1db69388da6c5 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:04:41 -0400 Subject: [PATCH 076/109] Tie the example's REPL pin to the fixture the real-REPL job builds The example's REPL revision is tested only because the real-REPL job builds tests/fixtures/repl-smoke at the same revision on the same Lean toolchain, and nothing tied the two together: the doc test restated the example's hash, so bumping the example alone kept CI green while the pin the setup skill points to went untested. A test now requires the example's and the fixture's `repl` revisions and toolchains to match, in place of the hash literal. --- tests/test_skill_examples.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index c1e6492b1..13ff2742c 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -246,9 +246,6 @@ def test_quick_start_keeps_the_cli_agent_facing(repo_root: Path) -> None: def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) -> None: servers = " ".join((repo_root / "servers/README.md").read_text(encoding="utf-8").split()) setup = " ".join((repo_root / "skills/setup/SKILL.md").read_text(encoding="utf-8").split()) - lakefile = ( - repo_root / "skills/setup/assets/cabannes-thesis-project/lakefile.toml" - ).read_text(encoding="utf-8") assert "one REPL slot pool" in servers assert "each public REPL call gets a fresh child" in servers @@ -294,7 +291,23 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "run `lake update repl`" in setup assert "commit the manifest" in setup assert "lake build @repl/repl" in setup - assert "68a3b3a059787a7db44fb1e6281e4a657efee470" in lakefile + + +def test_setup_example_pins_the_repl_that_the_real_repl_job_tests(repo_root: Path) -> None: + # The real-REPL CI job builds this fixture, so the example's REPL pin is + # tested only while both pin the same revision on the same toolchain. + fixture = repo_root / "tests/fixtures/repl-smoke" + example = repo_root / _EXAMPLE + + def repl_rev(project: Path) -> str: + lakefile = tomllib.loads((project / "lakefile.toml").read_text(encoding="utf-8")) + return next(entry["rev"] for entry in lakefile["require"] if entry["name"] == "repl") + + def toolchain(project: Path) -> str: + return (project / "lean-toolchain").read_text(encoding="utf-8").strip() + + assert repl_rev(example) == repl_rev(fixture) + assert toolchain(example) == toolchain(fixture) def test_setup_guidance_uses_the_offline_atomic_project_creator(repo_root: Path) -> None: From 5c4958c8e1e773d47d2680e9a0739422849fbac8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 09:04:58 -0400 Subject: [PATCH 077/109] Name and bound the real-REPL job's last two steps The real-REPL job's install and test steps had no name and no step timeout, unlike its other steps and the real-Lean job, so only the 15-minute job limit stopped a hung REPL run and the log showed the bare command. They now carry real-Lean's names and conventions: the locked install runs under a 3-minute timeout and the REPL suite under 10 minutes, both with TERM then KILL after 30 seconds. --- .github/workflows/tests.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c6c4381cf..3c237c26d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -143,8 +143,10 @@ jobs: set -euo pipefail timeout --signal=TERM --kill-after=30s 5m \ elan toolchain install "$(tr -d '\r\n' < tests/fixtures/minimum-lean/lean-toolchain)" - - run: uv sync --frozen --extra dev --extra repl - - run: uv run pytest -q tests/test_real_repl.py + - name: Install locked development environment + run: timeout --signal=TERM --kill-after=30s 3m uv sync --frozen --extra dev --extra repl + - name: Run the REPL suite against the pinned upstream REPL + run: timeout --signal=TERM --kill-after=30s 10m uv run pytest -q tests/test_real_repl.py env: AUTOFORM_RUN_REAL_REPL_TESTS: "1" From 590d6e6d0a332b367f52ce417a40c656a602611a Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:54:51 -0400 Subject: [PATCH 078/109] Say what to do when a project has no REPL require Lake reports "unknown package `repl`" both when the REPL is required under another name and when it is not required at all, as `autoform project new` and `init` leave a project. The hint gave only the remedies for a misnamed require, so a project without one was told to rename a require it lacks or to restart the node-wide runtime. It now first says to add `leanprover-community/repl` as a require named `repl` as the setup skill describes, and the hint test, renamed because it no longer checks two remedies, pins that clause. --- servers/repl/core.py | 11 +++++++---- tests/test_repl_core_protocol.py | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 6f76935e0..d85aabcf3 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -44,11 +44,14 @@ "lean = os.path.join(os.environ['LEAN_SYSROOT'], 'bin', 'lean'); " "os.execv(lean, [lean, '--run', sys.argv[1]])" ) -# Lake's error for ``lake exe @/`` when no package in the -# workspace has that name, as when the REPL is required under another name. +# Lake's error for ``lake exe @/`` when no package in +# the workspace has that name: the REPL is required under another name, +# or not at all. _LAKE_UNKNOWN_PACKAGE = "unknown package `" _REPL_REQUIRE_HINT = ( - "Lake could not find the REPL package: rename the require to `repl`, " + "Lake could not find the REPL package: if the project has no REPL require, add " + "`leanprover-community/repl` as a require named `repl` as the Autoform setup skill " + "describes; for a require under another name, rename the require to `repl`, " 'or restart the runtime with LEAN_REPL_CMD="lake exe @/repl" for a require ' "named , which applies to every project the runtime serves" ) @@ -633,7 +636,7 @@ def public_fields(item: dict[str, Any], allowed: frozenset[str]) -> dict[str, An def _with_repl_require_hint(message: str) -> str: - """Lead with both remedies when Lake found no package for the REPL command. + """Lead with the remedies when Lake found no package for the REPL command. A child that fails this way reaches the caller as a process exit or an unknown outcome whose message gives the child's stderr tail only after diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index f50cae178..190602235 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1204,7 +1204,7 @@ def _failing_lake_exe(stderr: str) -> list[str]: ("error: unknown executable @repl/repl", False), ], ) -def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_error, hinted): +def test_failed_repl_start_names_the_remedies_only_for_an_unknown_package(lake_error, hinted): repl = repl_core.LeanRepl( repl_core.LeanReplConfig( validate_imports=False, @@ -1217,6 +1217,7 @@ def test_failed_repl_start_names_both_remedies_only_for_an_unknown_package(lake_ assert lake_error in error assert error.startswith("Lake could not find the REPL package: ") is hinted + assert ("add `leanprover-community/repl` as a require named `repl`" in error) is hinted assert ("rename the require to `repl`" in error) is hinted assert ('restart the runtime with LEAN_REPL_CMD="lake exe @/repl"' in error) is hinted assert ("applies to every project the runtime serves" in error) is hinted From 4ce3ea4a5643775364440c361ac39fe1c7e8d0f3 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:55:57 -0400 Subject: [PATCH 079/109] Test the require hint when Lake exits before the frame is sent run_disposable adds the unknown-package hint at two sites, and only the one for an unknown outcome was tested: the stand-in child reads the whole frame before it fails. A Lake child that exits while a frame larger than the pipe buffer is still being written takes the other site, and dropping the hint there failed no test. A pipe-backed case now closes stdout, or stderr, before the frame is sent and requires the hint without an unknown outcome. --- tests/test_repl_core_protocol.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 190602235..8ee23474c 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1224,6 +1224,27 @@ def test_failed_repl_start_names_the_remedies_only_for_an_unknown_package(lake_e assert repl.is_clean() +# Lake exits without reading the frame when it cannot find the REPL package, so +# a frame larger than the pipe buffer is still unsent when the child's pipes +# close, and either stream can be seen closing first. +@pytest.mark.parametrize("closed_stream", ["stdout", "stderr"]) +def test_failed_repl_start_before_the_frame_is_sent_names_the_remedies(monkeypatch, closed_stream): + with ExitStack() as stack: + process = _PipeProcess(stack, []) + process._stderr_write.write(b"error: unknown package `repl`\n") + if closed_stream == "stdout": + process._stdout_write.close() + else: + process._stderr_write.close() + repl = _disposable_repl_with_process(monkeypatch, process) + + response = repl.run_disposable("#check Nat", timeout=1) + + assert response["repl_error"].startswith("Lake could not find the REPL package: ") + assert "outcome_unknown" not in response + assert repl.is_clean() + + # The header parser on Lean v4.27.0 reports one plain Init import both for an # ordinary header and for ``prelude`` followed by ``import Init``. _V4_27_INIT_ONLY = ( From 51a531e88281ea94b3ac21cff1fd1b1d51a12aa4 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:56:36 -0400 Subject: [PATCH 080/109] Name the import whose module components are refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal of a quoted, path-like or empty module component said only "Lean header imports must use plain, nonempty module components", so an agent with several imports could not tell which one to fix, and after run_disposable's "Rejected Lean header: " prefix it said "Lean header" twice. It now reads "import must use plain, nonempty module components", with the name as Lean's parser escapes it, as the allowlist refusal names its module. The Mathlib.«..».Secret case asserts the name. --- servers/repl/core.py | 2 +- tests/test_repl_core_protocol.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index d85aabcf3..7e8fe4add 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -513,7 +513,7 @@ def _decode_header_analysis(stdout: bytes) -> _LeanHeaderAnalysis: or any(not component for component in components) ): raise ValueError( - "Lean header imports must use plain, nonempty module components" + f"import {module} must use plain, nonempty module components" ) is_meta = item.get("isMeta") if is_meta is not None and type(is_meta) is not bool: diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 8ee23474c..f52887551 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -5,6 +5,7 @@ import asyncio import json import os +import re import select import signal import subprocess @@ -956,7 +957,7 @@ def write_parser(path, module): ), ( _fake_header_command(_header_json("Mathlib.«..».Secret")), - "plain, nonempty module components", + re.escape("import Mathlib.«..».Secret must use plain, nonempty module components"), ), # parse_header.lean prints this name escaped. Unescaped, as Name.toString # prints it, its root would read as the allowlisted Mathlib. From 0a37adc49f3943395cbfd297ef9c81420c96b26e Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:57:02 -0400 Subject: [PATCH 081/109] Say the header output cap counts stdout and stderr _communicate_bounded counts stdout and stderr together against max_buffer_bytes, but its error said the parser's output exceeded the cap. The realistic overflow is a header command, such as Lake or a wrapper, flooding stderr, which is not parser output. The error now reads "Lean header check failed: header command output exceeded N bytes", and the cap test pins it. --- servers/repl/core.py | 2 +- tests/test_repl_core_protocol.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 7e8fe4add..dd29f6d3c 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -406,7 +406,7 @@ def close_stdin() -> None: continue if len(stdout) + len(stderr) + len(chunk) > max_output_bytes: raise RuntimeError( - f"Lean header check failed: parser output exceeded {max_output_bytes} bytes" + f"Lean header check failed: header command output exceeded {max_output_bytes} bytes" ) target.extend(chunk) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index f52887551..9c516270b 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1344,7 +1344,7 @@ def test_header_check_never_spawns_after_its_shared_deadline(monkeypatch): def test_header_check_rejects_output_over_the_combined_limit(): - with pytest.raises(RuntimeError, match="Lean header check failed: parser output exceeded 1024 bytes"): + with pytest.raises(RuntimeError, match="Lean header check failed: header command output exceeded 1024 bytes"): _header_modules(_fake_header_command("x" * 600, stderr="y" * 600), max_buffer_bytes=1024) From b072c9a65daffc5adba05f303a2c6a3657176f5e Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:57:52 -0400 Subject: [PATCH 082/109] Pin the fallback order of a failed header command's detail _header_failure_detail falls back from an error line on stdout to the last non-empty line of stderr, then of stdout. No case paired stdout without an error line with non-empty stderr, and none had output on stdout alone, so swapping the two fallbacks or dropping the stdout one failed no test. Two cases now pin each step. --- tests/test_repl_core_protocol.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 9c516270b..8756af5c0 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1120,6 +1120,10 @@ def test_disposable_call_rejects_what_lean_reports_before_starting( "use `lake update repl` to update it\n", "parse_header.lean:14:47: error: Application type mismatch: The argument", ), + # Without an error line on stdout, stderr's last non-empty line wins... + ("stdout first\nstdout last\n", "stderr first\nstderr last\n\n", "stderr last"), + # ...and stdout's last non-empty line is used only when stderr has none. + ("stdout first\nstdout last\n\n", "", "stdout last"), ("", "", "exit status 1"), ("", "x" * 1000, "x" * repl_core._HEADER_FAILURE_DETAIL_CHARS + "..."), ], From 323d01f8e19d67b1db300ec3c2b8201f38e9e594 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:59:44 -0400 Subject: [PATCH 083/109] Drop the REPL startup timeout that no longer bounds startup With the warm start gone, start() only spawns the child, and its startup_timeout, LeanReplConfig.startup_timeout and DEFAULT_REPL_STARTUP_TIMEOUT (180 s) bounded nothing but the cleanup of a failed spawn, whose own phases stop well inside that; the real startup runs inside the command's budget. start() now takes no timeout and gives that cleanup DEFAULT_REPL_CLEANUP_SECONDS, the knob and constant are gone, and run_disposable still checks the "disposable child startup" budget before spawning. The tests stop pinning the value passed to start() and require instead that an expired budget never calls it. --- servers/repl/core.py | 17 +++++------------ tests/test_repl_core_protocol.py | 15 ++++++++------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index dd29f6d3c..5c7dc7f18 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -23,7 +23,6 @@ logger = getLogger(__name__) DEFAULT_MAX_DIAGNOSTICS = 10 -DEFAULT_REPL_STARTUP_TIMEOUT = 180.0 DEFAULT_REPL_CLEANUP_SECONDS = 2.0 REPL_ABORT_TERM_SECONDS = 0.5 REPL_ABORT_KILL_SECONDS = 1.0 @@ -556,7 +555,6 @@ class LeanReplConfig: env: dict[str, str] = field(default_factory=dict) request_timeout: float = 240.0 - startup_timeout: float = DEFAULT_REPL_STARTUP_TIMEOUT chunk_size: int = 4096 instance_mem_limit_gb: int = 16 @@ -803,23 +801,17 @@ def __init__(self, config: LeanReplConfig) -> None: if config.validate_imports and config.allowed_imports: self._allowed_import_roots = config.allowed_imports - def start(self, startup_timeout: float | None = None) -> None: - """Start a Lean REPL child within one startup deadline.""" + def start(self) -> None: + """Spawn a Lean REPL child; ``DEFAULT_REPL_CLEANUP_SECONDS`` bounds cleanup if spawning fails.""" if os.name != "posix": raise RuntimeError("Lean REPL transport requires a POSIX platform") - timeout = self.config.startup_timeout if startup_timeout is None else min( - self.config.startup_timeout, - startup_timeout, - ) - deadline = time.monotonic() + timeout - try: self._spawn(self.config.repl_command) self._stderr_bytes = 0 self._stderr_tail.clear() except BaseException: try: - self.close(deadline=deadline) + self.close(deadline=time.monotonic() + DEFAULT_REPL_CLEANUP_SECONDS) except BaseException: logger.exception( "failed to retire Lean REPL process after startup failure" @@ -992,7 +984,8 @@ def remaining(phase: str) -> float: f"import {root}" for root in added_imports ) command = f"{prefix}\n{code}" if prefix else code - self.start(startup_timeout=remaining("disposable child startup")) + remaining("disposable child startup") + self.start() response = self._run( code=command, env_id=None, diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 8756af5c0..c98939fc2 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -467,8 +467,8 @@ def close(**kwargs): repl.process = None repl._process_group_id = None - def start(**kwargs): - starts.append(kwargs) + def start(): + starts.append(True) repl.process = object() repl._process_group_id = 7 @@ -534,7 +534,6 @@ def run(code, env_id, timeout): ], } assert len(starts) == 1 - assert 0 < starts[0]["startup_timeout"] <= 3 assert len(calls) == 1 assert calls[0][0] == "import Mathlib\n#check Nat" assert calls[0][1] is None @@ -555,8 +554,8 @@ def check_header(code, deadline): clock["now"] = 2 return repl_core._LeanHeaderAnalysis((), True) - def start(*, startup_timeout): - budgets.append(("startup", startup_timeout)) + def start(): + budgets.append("startup") clock["now"] = 5 def run(code, env_id, timeout): @@ -569,7 +568,7 @@ def run(code, env_id, timeout): assert repl.run_disposable("#check Nat", timeout=10) == {"messages": []} assert budgets == [ - ("startup", 8), + "startup", ("command", 5, "import Mathlib\n#check Nat"), ] @@ -604,7 +603,9 @@ def check_header(code, deadline): clock["now"] = 2 return repl_core._LeanHeaderAnalysis((), True) - def start(**kwargs): + def start(): + if expired_phase == "startup": + pytest.fail("expired budget started a REPL child") if expired_phase == "command": clock["now"] = 2 From a86649ec39bc2e63719533df068c65f19066d233 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:01:09 -0400 Subject: [PATCH 084/109] Catch a stderr backlog at the frame and map it once run_disposable mapped a ReplStderrBacklog's response through its own copy of the validate, shift and strip steps. _run_io retires the process before raising it and no process is sent a second frame, so that response is the call's answer exactly as if _run had returned it. A try around only the frame call now takes error.response as the response, the outer block is gone, and the ReplStderrBacklog docstring says the response still answers the request instead of warning against serving another. --- servers/repl/core.py | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 5c7dc7f18..791d51e7c 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -756,10 +756,9 @@ class ReplOutcomeUnknown(RuntimeError): class ReplStderrBacklog(RuntimeError): """Raised when a response was captured but process stderr is no longer safe. - The response data is valid and travels on ``response`` so a caller need not - recompute it, but any ``env`` belongs to the process being retired and must - not escape. stderr is unframed process output rather than command output, so - an over-budget or undrainable process must not serve another request. + stderr exceeded its ceiling or could not be drained, so the process is + retired before this is raised. The decoded response travels on + ``response`` and still answers the request. """ def __init__(self, message: str, response: Any) -> None: @@ -986,11 +985,16 @@ def remaining(phase: str) -> float: command = f"{prefix}\n{code}" if prefix else code remaining("disposable child startup") self.start() - response = self._run( - code=command, - env_id=None, - timeout=remaining("combined command execution"), - ) + command_timeout = remaining("combined command execution") + try: + response = self._run( + code=command, + env_id=None, + timeout=command_timeout, + ) + except ReplStderrBacklog as error: + # _run_io already retired the process. + response = error.response _validate_command_response( response, context="the requested command", @@ -998,24 +1002,6 @@ def remaining(phase: str) -> float: ) _adjust_line_numbers(response, -len(added_imports)) result = _without_process_handles(response) - except ReplStderrBacklog as error: - try: - _validate_command_response( - error.response, - context="the requested command", - require_environment=True, - ) - except ReplCommandError as command_error: - result = {"repl_error": str(command_error)} - except ReplProtocolError as protocol_error: - result = { - "repl_error": str(protocol_error), - "outcome_unknown": True, - } - else: - response = _without_process_handles(error.response) - _adjust_line_numbers(response, -len(added_imports)) - result = response except ReplCommandError as error: result = {"repl_error": str(error)} except (ReplProtocolError, ReplOutcomeUnknown) as error: From 13bfc71db943f7f0e8bb25cf61910047c311b605 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:02:51 -0400 Subject: [PATCH 085/109] State the budget when a REPL call times out before dispatch A timeout while the frame was still being written reached the caller as _run_io's "REPL command timed out after seconds while writing", with the raw float left at dispatch instead of the budget that run_disposable's docstring promises. The try around the frame call now reports it as "Lean REPL combined command execution timed out after seconds", without outcome_unknown since nothing was sent, and remaining()'s timeouts stay outside that try with their own phase names. The docstring now speaks of each phase's timeout message, since a cleanup that times out names its own step, and a new test pins the pre-send message with a budget unlike the timeout. --- servers/repl/core.py | 11 ++++++++--- tests/test_repl_core_protocol.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index 791d51e7c..e55a01eeb 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -913,9 +913,9 @@ def run_disposable( ) -> dict[str, Any]: """Run one public call as the only request frame on a fresh process. - Timeout messages state ``budget``, which defaults to ``timeout``. A pool - passes its caller's whole budget there and only the part left after - waiting for this slot as ``timeout``. + Each phase's timeout message states ``budget``, which defaults to + ``timeout``. A pool passes its caller's whole budget there and only the + part left after waiting for this slot as ``timeout``. """ timeout = self.request_timeout if timeout is None else timeout budget = timeout if budget is None else budget @@ -995,6 +995,11 @@ def remaining(phase: str) -> float: except ReplStderrBacklog as error: # _run_io already retired the process. response = error.response + except TimeoutError: + # _run passes a timeout through only before the frame is fully sent. + raise TimeoutError( + f"Lean REPL combined command execution timed out after {budget:g} seconds" + ) from None _validate_command_response( response, context="the requested command", diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index c98939fc2..6fb5666a6 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1433,6 +1433,19 @@ def cancel(*args, **kwargs): assert repl.is_clean() +def test_disposable_timeout_before_the_frame_is_sent_states_the_budget(monkeypatch): + with ExitStack() as stack: + process = _PipeProcess(stack, []) + repl = _disposable_repl_with_process(monkeypatch, process) + # Nothing becomes ready, so the write times out before the frame is sent. + monkeypatch.setattr(repl_core.select, "select", lambda r, w, x, timeout=None: ([], [], [])) + + response = repl.run_disposable("#check Nat", timeout=1, budget=3) + + assert response == {"repl_error": "Lean REPL combined command execution timed out after 3 seconds"} + assert repl.is_clean() + + def test_disposable_timeout_after_full_write_is_not_retried(monkeypatch): repl = repl_core.LeanRepl( repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) From a56d4761d9f6f60890f2b23b5b47ef5510cc5b57 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:05:12 -0400 Subject: [PATCH 086/109] Stop describing a REPL process that serves another request Each REPL process answers one frame, but two tests still sent a second frame through one process, and comments and three messages still described a process that goes on to another request: a pre-send failure told the agent "the process was recycled". The cumulative stderr-quota test is gone, the delayed-stdout test is cut to its first half, a real-pipe exchange that now also checks the frame's bytes, and the comments drop their clauses about later requests, other commands and reuse. The three messages now say the process was retired. --- servers/repl/core.py | 17 +++++------ tests/test_repl_core_protocol.py | 52 ++++++++------------------------ 2 files changed, 20 insertions(+), 49 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index e55a01eeb..e054186fd 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -1155,7 +1155,7 @@ def retire_before_request() -> None: raise ReplProcessExited( f"REPL process-generation stderr became unsafe before the request " f"frame was fully sent ({reason}; {stderr_bytes} bytes observed); " - f"the process was recycled. Tail: {stderr_tail!r}" + f"the process was retired. Tail: {stderr_tail!r}" ) def reject_unsolicited_stdout() -> None: @@ -1173,7 +1173,7 @@ def reject_unsolicited_stdout() -> None: if chunk: raise ReplProcessExited( "Lean REPL emitted unsolicited stdout before the request frame " - "was fully sent; the process was recycled" + "was fully sent; the process was retired" ) # A child that exits before reading the frame, as Lake does when it # cannot find the REPL package, says why only on stderr. @@ -1381,22 +1381,19 @@ def drain_stderr(*, max_reads: int | None = None, after_response: bool = False) response_bytes = bytes(response_buffer[:separator]).strip() # The frame is complete, so this command's remaining queued # stderr can be drained without starving stdout. Leaving it in - # the pipe would let a command exceed the stderr ceiling - # unnoticed, misattribute diagnostics to the next command, and - # eventually block the child on a full stderr pipe. + # the pipe would let a command exceed the stderr ceiling unnoticed. stderr_drained = drain_stderr(after_response=True) break if not stderr_drained: stderr_bytes, stderr_tail = stderr_details() stderr_reason = stderr_poison_reason or "stderr could not be drained" - # stderr is accounted to the process generation, never to whichever - # command happened to observe it. Once that generation exceeds its - # quota or cannot be drained, retire it before another request. + # stderr is accounted to the process generation. Once that generation + # exceeds its quota or cannot be drained, retire it. self.close(deadline=end_time) # Retire a desynchronized process before parsing. Malformed JSON must not - # bypass the stream-safety invariant and leave stale stderr reusable. + # bypass the stream-safety invariant. try: response = _decode_repl_json(response_bytes) except (json.JSONDecodeError, ReplProtocolError) as error: @@ -1412,7 +1409,7 @@ def drain_stderr(*, max_reads: int | None = None, after_response: bool = False) raise ReplStderrBacklog( f"REPL process-generation stderr became unsafe ({stderr_reason}; " f"{stderr_bytes} bytes observed); " - f"the process was recycled. Tail: {stderr_tail!r}", + f"the process was retired. Tail: {stderr_tail!r}", response, ) return response diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 6fb5666a6..21630ae5d 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -6,7 +6,6 @@ import json import os import re -import select import signal import subprocess import sys @@ -1631,30 +1630,23 @@ def test_wire_protocol_retires_process_on_unsolicited_second_frame(monkeypatch): assert repl.process is None -def test_wire_protocol_rejects_delayed_stdout_before_the_next_request(): +def test_wire_protocol_exchanges_one_frame_over_real_pipes(): with ExitStack() as stack: process = _PipeProcess(stack, []) repl = _repl_with_process(process) - first_request = bytearray() + request = bytearray() - def serve_first_request() -> None: - while b"\n\n" not in first_request: - first_request.extend(os.read(process._stdin_read.fileno(), 4096)) + def serve_request() -> None: + while b"\n\n" not in request: + request.extend(os.read(process._stdin_read.fileno(), 4096)) os.write(process._stdout_write.fileno(), b'{"env":1}\n\n') - worker = threading.Thread(target=serve_first_request, daemon=True) + worker = threading.Thread(target=serve_request, daemon=True) worker.start() - assert repl._run("first", env_id=None, timeout=1) == {"env": 1} + assert repl._run("#check Nat", env_id=None, timeout=1) == {"env": 1} worker.join(timeout=1) assert not worker.is_alive() - - os.write(process._stdout_write.fileno(), b'{"env":999}\n\n') - with pytest.raises(repl_core.ReplProcessExited, match="unsolicited stdout"): - repl._run("second", env_id=None, timeout=1) - - readable, _, _ = select.select([process._stdin_read.fileno()], [], [], 0) - assert readable == [] - assert repl.process is None + assert bytes(request) == b'{"cmd": "#check Nat"}\n\n' def test_wire_protocol_rechecks_stdout_before_dispatching_a_complete_request_body( @@ -1733,8 +1725,8 @@ def fake_read(fd: int, size: int) -> bytes: monkeypatch.setattr(repl_core.os, "read", fake_read) # Endlessly readable stderr never starves stdout, so the response is - # captured. It cannot be drained to a boundary though, so the process is - # reported as unusable rather than silently reused. + # captured. stderr cannot be drained to a boundary though, so the + # process is retired and the response travels on the backlog error. with pytest.raises(repl_core.ReplStderrBacklog) as error: repl._run("#check Nat", env_id=None, timeout=1) @@ -1918,7 +1910,7 @@ def test_wire_protocol_drains_queued_stderr_after_the_response_frame_completes(m assert repl._run("#check Nat", env_id=None, timeout=5) == {"messages": []} - # Nothing is left to be charged against, or misattributed to, the next command. + # Unlike a fairness cycle, the post-response drain reads until stderr is empty. assert process.stderr_bytes == b"" @@ -1931,9 +1923,9 @@ def test_wire_protocol_reports_a_backlog_when_the_cap_ends_the_post_response_dra with pytest.raises(repl_core.ReplStderrBacklog) as error: repl._run("#check Nat", env_id=None, timeout=5) - # The response survives, so the caller need not recompute it... + # The response survives as the request's answer... assert error.value.response == {"messages": []} - # ...but the over-budget process generation must not serve another request. + # ...and the drain stops once the generation exceeds its stderr cap. assert process.stderr_bytes == b"e" * 4 @@ -1949,24 +1941,6 @@ def test_wire_protocol_accepts_stderr_that_ends_exactly_at_the_cap(monkeypatch): assert process.stderr_bytes == b"" -def test_stderr_quota_is_cumulative_across_a_process_generation(monkeypatch): - with ExitStack() as stack: - process = _PipeProcess(stack, [b"{}\n\n"], stderr=b"a" * 6) - repl = _repl_with_process(process, chunk_size=6, max_buffer_bytes=10) - _patch_pipe_reads(monkeypatch, process) - - assert repl._run("first", env_id=None, timeout=1) == {} - assert repl._stderr_bytes == 6 - - process.stdout_chunks.append(b"{}\n\n") - process.stderr_bytes = b"b" * 6 - with pytest.raises(repl_core.ReplStderrBacklog) as error: - repl._run("second", env_id=None, timeout=1) - - assert error.value.response == {} - assert repl.process is None - - def test_wire_protocol_keeps_the_response_when_the_deadline_ends_the_stderr_drain(monkeypatch): with ExitStack() as stack: process = _PipeProcess(stack, [b'{"messages": []}\n\n'], stderr=b"x") From 3a6281c6f1fc99b8d54ff4d324d8383ce19b6d44 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:05:48 -0400 Subject: [PATCH 087/109] Let the post-send timeout test use the real close test_disposable_timeout_after_full_write_is_not_retried stubbed close, but its stand-in process has no pid, so the real close() already clears the handle without signalling anything. The stub only kept the test from running the cleanup that _run and run_disposable perform, so it is gone. --- tests/test_repl_core_protocol.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 21630ae5d..b74fa329a 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1451,7 +1451,6 @@ def test_disposable_timeout_after_full_write_is_not_retried(monkeypatch): ) calls = [] monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", object())) - monkeypatch.setattr(repl, "close", lambda **kwargs: setattr(repl, "process", None)) def fail_after_send(code, env_id, timeout, mark_sent): calls.append((code, env_id)) From 375d833c29eeaaa508a5e3e12cfafe7866652348 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:36:44 -0400 Subject: [PATCH 088/109] Say a frame Lake never reads is not fully sent The comment on the pre-send require-hint test said a frame larger than the pipe buffer is still unsent when Lake's pipes close. Such a frame partly fills the pipe buffer first, so the comment now says it is not fully sent, as the transport's messages do. --- tests/test_repl_core_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index b74fa329a..09bf741c2 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1230,7 +1230,7 @@ def test_failed_repl_start_names_the_remedies_only_for_an_unknown_package(lake_e # Lake exits without reading the frame when it cannot find the REPL package, so -# a frame larger than the pipe buffer is still unsent when the child's pipes +# a frame larger than the pipe buffer is not fully sent when the child's pipes # close, and either stream can be seen closing first. @pytest.mark.parametrize("closed_stream", ["stdout", "stderr"]) def test_failed_repl_start_before_the_frame_is_sent_names_the_remedies(monkeypatch, closed_stream): From 00a0f1e0d771cd7bdb1b3a945454b52cb6633f86 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:37:36 -0400 Subject: [PATCH 089/109] Make the REPL start stubs take no arguments start() no longer takes a startup timeout, but thirteen stubs in the core protocol tests still accepted **kwargs or *args, **kwargs, which were there only to absorb that keyword. They now take no arguments, like start() and the three stubs already changed, so a call that passes arguments again fails instead of being swallowed by a stub. --- tests/test_repl_core_protocol.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index 09bf741c2..c6534e82e 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -409,7 +409,7 @@ def _repl_with_process(process: _PipeProcess, *, chunk_size: int = 4096, max_buf def _disposable_repl_with_process(monkeypatch, process: _PipeProcess, **config): """Return a repl whose disposable call starts on ``process``.""" repl = _repl_with_process(process, **config) - monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", process)) + monkeypatch.setattr(repl, "start", lambda: setattr(repl, "process", process)) return repl @@ -628,7 +628,7 @@ def test_disposable_backlog_adjusts_prefixed_positions(monkeypatch): ) ) - monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) monkeypatch.setattr( repl, "_check_header", @@ -708,7 +708,7 @@ def test_unvalidated_disposable_composes_only_proven_ordinary_headers( ) ) - monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) monkeypatch.setattr(repl, "_check_header", lambda code, deadline: analysis) frames = [] @@ -737,7 +737,7 @@ def close(**kwargs): repl._process_group_id = None monkeypatch.setattr(repl, "close", close) - monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) monkeypatch.setattr( repl, "_run", @@ -787,7 +787,7 @@ def run(**kwargs): raise outcome return outcome - monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) monkeypatch.setattr(repl, "_run", run) assert repl.run_disposable("#eval 1", timeout=1) == expected @@ -831,7 +831,7 @@ def test_disposable_call_reports_every_malformed_response_as_unknown( repl_core.LeanReplConfig(validate_imports=False, warmup_imports=frozenset()) ) frames = [] - monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) monkeypatch.setattr( repl, "_run", @@ -1071,7 +1071,7 @@ def test_disposable_call_rejects_what_lean_reports_before_starting( monkeypatch.setattr( repl, "start", - lambda *args, **kwargs: pytest.fail("invalid input must not start Lean"), + lambda: pytest.fail("invalid input must not start Lean"), ) response = repl.run_disposable("/- hidden -/ import Unsafe\n#check Nat") @@ -1138,7 +1138,7 @@ def test_failed_header_command_reports_its_error_line(monkeypatch, stdout, stder monkeypatch.setattr( repl, "start", - lambda *args, **kwargs: pytest.fail("a failed header check must not start Lean"), + lambda: pytest.fail("a failed header check must not start Lean"), ) assert repl.run_disposable("#check Nat") == {"repl_error": f"Lean header check failed: {detail}"} @@ -1294,7 +1294,7 @@ def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monke ) ) frames = [] - monkeypatch.setattr(repl, "start", lambda *args, **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) def run_frame(code, env_id, timeout): frames.append(code) @@ -1317,7 +1317,7 @@ def test_disposable_call_rejects_disallowed_warmup_import(monkeypatch): monkeypatch.setattr( repl, "start", - lambda *args, **kwargs: pytest.fail("disallowed warmup must not start Lean"), + lambda: pytest.fail("disallowed warmup must not start Lean"), ) response = repl.run_disposable("#check Nat") @@ -1391,7 +1391,7 @@ def fail_once(process, process_group_id, deadline=None): def test_disposable_call_retries_header_cleanup_before_reuse(monkeypatch): repl, cleanup_calls = _repl_failing_first_header_cleanup(monkeypatch, RuntimeError("injected cleanup failure")) - monkeypatch.setattr(repl, "start", lambda *args, **kwargs: pytest.fail("unclean header must not start Lean")) + monkeypatch.setattr(repl, "start", lambda: pytest.fail("unclean header must not start Lean")) response = repl.run_disposable("import Mathlib\n#check Nat") @@ -1450,7 +1450,7 @@ def test_disposable_timeout_after_full_write_is_not_retried(monkeypatch): repl_core.LeanReplConfig(warmup_imports=frozenset(), validate_imports=False) ) calls = [] - monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", object())) + monkeypatch.setattr(repl, "start", lambda: setattr(repl, "process", object())) def fail_after_send(code, env_id, timeout, mark_sent): calls.append((code, env_id)) @@ -1486,7 +1486,7 @@ def fail_after_send(code, env_id, timeout, mark_sent): raise TimeoutError("response timed out") monkeypatch.setattr(repl, "close", close) - monkeypatch.setattr(repl, "start", lambda **kwargs: setattr(repl, "process", object())) + monkeypatch.setattr(repl, "start", lambda: setattr(repl, "process", object())) monkeypatch.setattr(repl, "_run_io", fail_after_send) with pytest.raises(repl_core.ReplCleanupError, match="retirement is required: cleanup failed") as error: From e1f49f41077b0ee4c63266d7541eb5c58bd172e8 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:37:53 -0400 Subject: [PATCH 090/109] Drop the second-request name from the stdout recheck test The test that rechecks stdout before dispatching a complete request body sends one request, but its code string was "second", which implies an earlier request on the same process, and no process now serves more than one frame. It sends "#check Nat" like the neighbouring tests. --- tests/test_repl_core_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index c6534e82e..c5ebd70cd 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -1669,7 +1669,7 @@ def write_body_then_emit_stdout(fd: int, data: bytes) -> int: monkeypatch.setattr(repl_core.os, "write", write_body_then_emit_stdout) with pytest.raises(repl_core.ReplProcessExited, match="unsolicited stdout"): - repl._run("second", env_id=None, timeout=1) + repl._run("#check Nat", env_id=None, timeout=1) os.set_blocking(process._stdin_read.fileno(), False) written = os.read(process._stdin_read.fileno(), 4096) From 4bac7da3e468f9edf4f2b0f6c3ab34fee909667f Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:38:32 -0400 Subject: [PATCH 091/109] Drop the command-boundary rationale from the REPL stderr comments Three transport comments still argued against a process that serves several commands: stderr "has no command boundary", EAGAIN "is never treated as a command boundary", and stderr seen while writing "is never assigned to this command", though a pre-send failure reports exactly that tail. Each process answers one frame, so those clauses are gone and the comments say only what the code does. --- servers/repl/core.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index e054186fd..d19d82b7f 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -791,8 +791,8 @@ def __init__(self, config: LeanReplConfig) -> None: self.chunk_size: int = config.chunk_size self._process_lock = threading.Lock() - # stderr has no command boundary. Account for it monotonically across one - # process generation and retain only a bounded tail for diagnostics. + # Account for stderr monotonically across one process generation and + # retain only a bounded tail for diagnostics. self._stderr_bytes = 0 self._stderr_tail = bytearray() @@ -1194,8 +1194,7 @@ def drain_stderr(*, max_reads: int | None = None, after_response: bool = False) without destroying a response already captured. Returns whether stderr is currently empty and the process generation - remains within budget. EAGAIN is never treated as a command boundary; - the byte count and tail persist until the process is replaced. + remains within budget. """ nonlocal stderr_open, stderr_poison_reason @@ -1252,8 +1251,7 @@ def drain_stderr(*, max_reads: int | None = None, after_response: bool = False) # stdout and stderr are independent pipes. A child blocked on a full # stderr pipe may be unable to read its stdin, so service stderr fairly - # while writing instead of waiting on stdin alone. Any stderr observed - # here remains process-scoped; it is never assigned to this command. + # while writing instead of waiting on stdin alone. encoded_command = command.encode("utf-8") # Hold back the final blank-line byte. The REPL cannot dispatch this # command until that delimiter arrives, which gives us a stdout check From af6c3eb44d868b3a21bbebe6e9f3f8fac497edcd Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:54:49 -0400 Subject: [PATCH 092/109] Pin the shutdown wording at every pool raise site pool.run refuses a call on a shut-down pool in three places, but only the refusal at the shutdown wakeup had its full message asserted. The entry check was matched loosely by "shut down", and the re-check after a call takes a slot had no test at all, so deleting it left every test passing. The entry-check tests now assert the whole message, and a new test starts shutdown just after a call leaves the queue and checks that the call is refused before it runs. --- tests/test_repl_pool_lifecycle.py | 49 ++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 6ebc8178b..00109d90d 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -11,6 +11,8 @@ from servers.repl import core as repl_core from servers.repl import pool as repl_pool +_SHUTDOWN_REFUSAL = "Lean REPL pool is shut down; this call did not start and can be retried" + def test_partial_pool_construction_closes_all_constructed_workers(monkeypatch): workers = [] @@ -110,7 +112,7 @@ def close(self): assert str(raised.value.cause) == "second slot failed" assert raised.value.pool._workers == [workers[0]] assert [worker.close_calls for worker in workers] == [1, 0] - with pytest.raises(RuntimeError, match="shut down"): + with pytest.raises(RuntimeError, match=_SHUTDOWN_REFUSAL): raised.value.pool.run("#check Nat", timeout=0.1) raised.value.pool.shutdown() @@ -266,7 +268,7 @@ def close(self): assert workers[0].close_calls == 0 assert not workers[0].is_clean() assert pool.requires_retirement - with pytest.raises(RuntimeError, match="shut down"): + with pytest.raises(RuntimeError, match=_SHUTDOWN_REFUSAL): pool.run("#check Int", timeout=1) pool.shutdown() @@ -421,7 +423,46 @@ def close(self): assert not waiter.is_alive() assert not stopper.is_alive() assert len(errors) == 1 - assert str(errors[0]) == "Lean REPL pool is shut down; this call did not start and can be retried" + assert str(errors[0]) == _SHUTDOWN_REFUSAL + + +def test_shutdown_refuses_a_call_that_already_took_a_slot(monkeypatch): + calls = [] + + class FakeRepl: + def __init__(self, config): + pass + + def run_disposable(self, code, **kwargs): + calls.append(code) + return {"messages": []} + + def is_clean(self): + return True + + def close(self): + pass + + monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) + pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) + take_slot = pool._idle.get + + def take_slot_as_shutdown_begins(*args, **kwargs): + slot = take_slot(*args, **kwargs) + # Shutdown stops admission after the call has left the queue. + with pool._condition: + pool._stop_admission_locked() + return slot + + monkeypatch.setattr(pool._idle, "get", take_slot_as_shutdown_begins) + + with pytest.raises(RuntimeError) as refused: + pool.run("#check Nat", timeout=1) + + assert str(refused.value) == _SHUTDOWN_REFUSAL + assert calls == [] + assert pool._active_calls == 0 + pool.shutdown() def test_shutdown_waits_for_checked_out_worker_before_closing_or_draining(monkeypatch): @@ -462,7 +503,7 @@ def close(self): assert close_started.is_set() assert pool._workers == [] assert pool._idle.empty() - with pytest.raises(RuntimeError, match="shut down"): + with pytest.raises(RuntimeError, match=_SHUTDOWN_REFUSAL): pool.run("#check Int", timeout=0.1) From 4061b6af94746dbc66a4d6cf1666327ba19a0a5c Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:55:40 -0400 Subject: [PATCH 093/109] Say seconds and slot in the REPL pool's queue timeout A call that timed out waiting for a free slot read "timed out after 240s waiting for an idle Lean REPL", the only REPL timeout message that abbreviated seconds and the only place that called a slot a REPL. All three raise sites now say "timed out after 240 seconds waiting for an idle Lean REPL slot", and the idle-worker test pins the whole message. --- servers/repl/pool.py | 6 +++--- tests/test_repl_pool_lifecycle.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 47c1b4344..816276447 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -133,13 +133,13 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: remaining = None if deadline is None else deadline - time.monotonic() if remaining is not None and remaining <= 0: raise TimeoutError( - f"timed out after {timeout:g}s waiting for an idle Lean REPL" + f"timed out after {timeout:g} seconds waiting for an idle Lean REPL slot" ) try: candidate = self._idle.get(timeout=remaining) except queue.Empty as error: raise TimeoutError( - f"timed out after {timeout:g}s waiting for an idle Lean REPL" + f"timed out after {timeout:g} seconds waiting for an idle Lean REPL slot" ) from error if candidate is _SHUTDOWN: # Hand the wakeup to the next waiter. @@ -158,7 +158,7 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: remaining = None if deadline is None else deadline - time.monotonic() if remaining is not None and remaining <= 0: raise TimeoutError( - f"timed out after {timeout:g}s waiting for an idle Lean REPL" + f"timed out after {timeout:g} seconds waiting for an idle Lean REPL slot" ) try: return repl.run_disposable(code, timeout=remaining, budget=timeout) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 00109d90d..f76bc66b2 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -132,7 +132,7 @@ def close(self): pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) borrowed = pool._idle.get_nowait() try: - with pytest.raises(TimeoutError, match="waiting for an idle Lean REPL"): + with pytest.raises(TimeoutError, match=r"timed out after 0\.01 seconds waiting for an idle Lean REPL slot"): pool.run("#check Nat", timeout=0.01) finally: pool._idle.put(borrowed) From 20873586da78ceb902a6622efdcfbb4512a09d4d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:56:59 -0400 Subject: [PATCH 094/109] Start each FIFO pool waiter only after the last one has parked The arrival-order test started the next waiter as soon as _active_calls counted the previous one, but pool.run counts a call before it parks on the idle queue, so a thread switch in between could let a later waiter park first and fail the test with no regression. The test now counts calls to the idle queue's Condition.wait, which Queue.get makes with the queue's mutex held, so each waiter is queued before the next one starts. --- tests/test_repl_pool_lifecycle.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index f76bc66b2..1a608429e 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -322,6 +322,18 @@ def close(self): monkeypatch.setattr(repl_pool, "LeanRepl", FakeRepl) pool = repl_pool.LeanReplPool(repl_pool.LeanReplPoolConfig(num_repls=1)) held = pool._idle.get_nowait() + # A call counts as active before it parks on the idle queue, so the loop + # below waits for each waiter to park. Queue.get calls wait with the queue's + # mutex held, and Condition.wait queues the waiter before releasing that + # mutex, so no waiter started after the count can park ahead. + parked = [] + wait_on_queue = pool._idle.not_empty.wait + + def park(timeout=None): + parked.append(threading.current_thread()) + return wait_on_queue(timeout) + + monkeypatch.setattr(pool._idle.not_empty, "wait", park) waiters = [] try: for index in range(6): @@ -329,7 +341,7 @@ def close(self): waiter.start() waiters.append(waiter) deadline = time.monotonic() + 1 - while pool._active_calls < index + 1 and time.monotonic() < deadline: + while len(parked) < index + 1 and time.monotonic() < deadline: time.sleep(0.001) pool._idle.put(held) for waiter in waiters: From 01e2aa38d3d16f60d8a2b4d1b35106d150fbee3b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:57:12 -0400 Subject: [PATCH 095/109] Say what the REPL pool's memory figure counts get_memory_usage still described "all REPL instances", but a slot holds a process only while a call runs, so the sum covers just the live child processes of calls in progress and is zero between calls. The docstring now says so. --- servers/repl/pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/repl/pool.py b/servers/repl/pool.py index 816276447..23c7182f5 100644 --- a/servers/repl/pool.py +++ b/servers/repl/pool.py @@ -180,7 +180,7 @@ def run(self, code: str, timeout: float | None = None) -> dict[str, Any]: self._condition.notify_all() def get_memory_usage(self) -> float: - """Total memory usage across all REPL instances in GB.""" + """Total memory usage in GB of the live child processes of calls in progress.""" with self._lock: workers = tuple(self._workers) return sum(worker.get_memory_usage() for worker in workers) From c8f32615c51bc96c3cddb0feb4ae91c869f9152b Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:57:31 -0400 Subject: [PATCH 096/109] Stop calling the REPL pools expensive process pools The runtime client's module docstring said the daemon owns "the expensive Lean REPL pools", and the servers.repl package docstring called its pool a process pool, but a pool now holds cold slots with no Lean process between calls. Both now say per-project REPL slot pool, and the client docstring calls the LSP sessions, which do stay resident, warm. --- servers/lean_client.py | 4 ++-- servers/repl/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/servers/lean_client.py b/servers/lean_client.py index c1f1df638..028e0b2c6 100644 --- a/servers/lean_client.py +++ b/servers/lean_client.py @@ -1,8 +1,8 @@ """Client and bootstrap logic for Autoform's node-local Lean runtime. The two public MCP processes are intentionally short-lived stdio adapters. A -small detached process owns the expensive Lean REPL pools and LSP sessions and -is reached through a private Unix-domain socket. +small detached process owns the warm LSP sessions and the per-project REPL slot +pools, and is reached through a private Unix-domain socket. """ from __future__ import annotations diff --git a/servers/repl/__init__.py b/servers/repl/__init__.py index f47619fef..598552205 100644 --- a/servers/repl/__init__.py +++ b/servers/repl/__init__.py @@ -1,4 +1,4 @@ -"""Lean REPL server and process pool.""" +"""Lean REPL server and per-project slot pool.""" from .core import LeanRepl, LeanReplConfig from .pool import LeanReplPool, LeanReplPoolConfig, LeanReplPoolStartupError From 0556603577f5a624f7e43759276e2afd34ae3940 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:57:57 -0400 Subject: [PATCH 097/109] Say in run_lean_code what a call costs and what timeout covers The description said the timeout covers the whole call, but the runtime first admits the project to its project cache and starts the timeout only after that wait. It also left out that every call starts Lean and loads its imports from scratch. It now says the timeout starts once the project is admitted, and that even a one-line snippet takes at least as long as the added import Mathlib. --- servers/repl/server.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/servers/repl/server.py b/servers/repl/server.py index 7f5b239e8..934294a04 100644 --- a/servers/repl/server.py +++ b/servers/repl/server.py @@ -21,13 +21,18 @@ def run_lean_code(project_dir: str, code: str, timeout: float | None = None) -> ``module`` or ``prelude`` or already has ``import Mathlib``. Imports must come from Mathlib, Aesop, Batteries, or LeanSearchClient. + Each call starts Lean and loads its imports from scratch, including an + added ``import Mathlib``, so even a one-line snippet takes at least as + long as that import. + Args: project_dir: Absolute path to the Lake project root. code: Lean code to execute. - timeout: Optional seconds for the whole call: waiting for a free - slot, the header check, starting Lean, the imports, and the - code. The default and maximum are 240 seconds unless the - operator changes them. + timeout: Optional seconds for the call once the runtime admits its + project: waiting for a free slot, the header check, starting + Lean, the imports, and the code. Waiting for admission comes + first and is not counted. The default and maximum are 240 + seconds unless the operator changes them. """ return runtime.request( "repl.run", From 5eb1ca6b6130ea8cee3b1abd88e11e606b4722ba Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:59:09 -0400 Subject: [PATCH 098/109] Start the runtime tests from the default configuration The configuration tests read the developer's environment, so an exported value changed which check they reached: with AUTOFORM_MAX_REPL_REQUEST_SECONDS=120 the pool-replacement budget test hit the LSP budget check, and a wrapper LEAN_REPL_CMD failed five tests at the header-command check. An autouse fixture now clears every variable from_environment reads, in place of the per-test deletions, and the invalid-configuration table covers the empty LEAN_REPL_HEADER_CMD refusal, which no test reached. --- tests/test_shared_lean_runtime.py | 41 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index 166c30a02..a4ffd0762 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -29,6 +29,36 @@ from servers.lsp.server import LeanLspSession, LspConfig from servers.repl.core import LEAN_HEADER_LAUNCHER, LEAN_HEADER_PARSER +# Every variable LeanRuntimeConfig.from_environment reads. +_RUNTIME_CONFIG_VARIABLES = ( + "AUTOFORM_MAX_LEAN_PROJECTS", + "AUTOFORM_LEAN_IDLE_SECONDS", + "AUTOFORM_REPL_TOTAL_WORKERS", + "LEAN_NUM_REPLS", + "AUTOFORM_REPL_WORKERS_PER_PROJECT", + "LEAN_REPL_CMD", + "LEAN_REPL_HEADER_CMD", + "LEAN_LSP_CMD", + "AUTOFORM_MAX_REPL_REQUEST_SECONDS", + "AUTOFORM_REPL_REQUEST_TIMEOUT", + "LEAN_LSP_TIMEOUT", + "AUTOFORM_MAX_LSP_REQUEST_SECONDS", + "AUTOFORM_RUNTIME_RESPONSE_TIMEOUT", + "AUTOFORM_RUNTIME_READ_TIMEOUT", + "AUTOFORM_RUNTIME_MAX_CONNECTIONS", +) + + +@pytest.fixture(autouse=True) +def default_runtime_configuration(monkeypatch): + """Start every test, and every runtime it boots, from the defaults. + + Otherwise a value exported in the developer's shell, valid or not, changes + which check a configuration test reaches. + """ + for name in _RUNTIME_CONFIG_VARIABLES: + monkeypatch.delenv(name, raising=False) + def make_lake_project(tmp_path, name: str): project = tmp_path / name @@ -1811,6 +1841,7 @@ def close(self): [ ("LEAN_NUM_REPLS", "-1", "nonnegative integer"), ("LEAN_REPL_CMD", " ", "must not be empty"), + ("LEAN_REPL_HEADER_CMD", " ", "LEAN_REPL_HEADER_CMD must not be empty"), ("AUTOFORM_LEAN_IDLE_SECONDS", "nan", "finite nonnegative"), ("AUTOFORM_MAX_REPL_REQUEST_SECONDS", "900", "too small"), ("LEAN_LSP_TIMEOUT", "601", "cannot exceed"), @@ -1830,12 +1861,7 @@ def test_per_project_workers_cannot_exceed_node_budget(monkeypatch): LeanRuntimeConfig.from_environment() -def test_default_repl_command_is_package_qualified(monkeypatch): - monkeypatch.delenv("LEAN_REPL_CMD", raising=False) - monkeypatch.delenv("LEAN_REPL_HEADER_CMD", raising=False) - monkeypatch.delenv("AUTOFORM_REPL_REQUEST_TIMEOUT", raising=False) - monkeypatch.delenv("AUTOFORM_MAX_REPL_REQUEST_SECONDS", raising=False) - +def test_default_repl_command_is_package_qualified(): config = LeanRuntimeConfig.from_environment() assert config.repl_command == ( @@ -1849,7 +1875,6 @@ def test_default_repl_command_is_package_qualified(monkeypatch): def test_absolute_lake_command_is_reused_for_header_validation(monkeypatch): monkeypatch.setenv("LEAN_REPL_CMD", "/opt/lean/bin/lake exe @repl/repl") - monkeypatch.delenv("LEAN_REPL_HEADER_CMD", raising=False) config = LeanRuntimeConfig.from_environment() @@ -1865,7 +1890,6 @@ def test_absolute_lake_command_is_reused_for_header_validation(monkeypatch): def test_wrapped_repl_command_requires_an_explicit_header_command(monkeypatch): monkeypatch.setenv("LEAN_REPL_CMD", "container run lake exe @repl/repl") - monkeypatch.delenv("LEAN_REPL_HEADER_CMD", raising=False) with pytest.raises( ValueError, @@ -1884,7 +1908,6 @@ def test_wrapped_repl_command_requires_an_explicit_header_command(monkeypatch): def test_lowered_repl_maximum_lowers_only_the_default_timeout(monkeypatch): - monkeypatch.delenv("AUTOFORM_REPL_REQUEST_TIMEOUT", raising=False) monkeypatch.setenv("AUTOFORM_MAX_REPL_REQUEST_SECONDS", "120") config = LeanRuntimeConfig.from_environment() From 90353759047b0ebf1d77216ca48d8dc138bb115c Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 13:59:53 -0400 Subject: [PATCH 099/109] Name the allowlisted import roots in the servers README The README referred to the allowlist three times without saying which roots it allows; only the agent-facing tool description named them, and the set is fixed in code with no override. The README now names Mathlib, Aesop, Batteries and LeanSearchClient and says that a plain Init import is ignored, and the doc test checks every root in ALLOWED_IMPORTS against the README, so a root added in code must also be documented. --- servers/README.md | 6 ++++-- tests/test_skill_examples.py | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/servers/README.md b/servers/README.md index 3b514d55c..35703adf0 100644 --- a/servers/README.md +++ b/servers/README.md @@ -18,8 +18,10 @@ fingerprint, so an in-place upgrade gracefully replaces the older build. LSP sessions stay warm; each public REPL call gets a fresh child and no process-owned environment or proof-state handle survives the response. Before that child starts, the selected Lake toolchain runs Lean's own header parser, -which the REPL also uses, on the submitted source. Every import it reports is -validated, and a rejected or unrecognized parser response fails closed. +which the REPL also uses, on the submitted source. Every import it reports must +have one of the allowlisted roots `Mathlib`, `Aesop`, `Batteries`, or +`LeanSearchClient` (a plain `Init` import is ignored), and a rejected or +unrecognized parser response fails closed. Allowlisted imports require plain, nonempty canonical module components; names that remain quoted or path-like after Lean parsing are refused instead of being reduced to a misleading allowlisted root. The allowlist is a policy check, not a diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 13ff2742c..ccf78a3fc 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -14,6 +14,7 @@ from autoform_cli.lean import build_linker, declaration_names from autoform_cli.render import render_site from autoform_cli.status import derive +from servers.repl.core import ALLOWED_IMPORTS _HREF = re.compile(r'href="([^"]+)"') @@ -252,6 +253,9 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) assert "no process-owned environment or proof-state handle survives" in servers assert "toolchain runs Lean's own header" in servers assert "unrecognized parser response fails closed" in servers + for root in sorted(ALLOWED_IMPORTS): + assert f"`{root}`" in servers, root + assert "(a plain `Init` import is ignored)" in servers assert "names that remain quoted or path-like after Lean parsing are refused" in servers assert "The allowlist is a policy check, not a sandbox" in servers assert "`Init` alone provides file and process IO" in servers From 1ad27877b4b49815712aa2cd31a97c41c3c878b4 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:01:07 -0400 Subject: [PATCH 100/109] Break up the runtime configuration text in the servers README Everything from the socket location to the meaning of `warm` ran together as one 30-line paragraph, the restart sentence called LEAN_REPL_CMD and LEAN_REPL_HEADER_CMD "both commands", and the header check's share of the request budget was stated twice. The text is now split into paragraphs for the node-wide limits, the REPL commands, the timeout cap and the response budget; the restart sentence names both variables, and the repeated budget clause is gone, since the timeout sentence already counts header validation. --- servers/README.md | 27 +++++++++++++++------------ tests/test_skill_examples.py | 5 ++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/servers/README.md b/servers/README.md index 35703adf0..3b12e380a 100644 --- a/servers/README.md +++ b/servers/README.md @@ -27,9 +27,8 @@ that remain quoted or path-like after Lean parsing are refused instead of being reduced to a misleading allowlisted root. The allowlist is a policy check, not a sandbox: each call runs with the runtime user's privileges, and `Init` alone provides file and process IO, for example `#eval IO.Process.run`. The header -check runs as one extra short Lean process per call and counts toward the same -request budget as the rest of the call. Execution names the package-qualified -`@repl/repl` target so a project target cannot shadow it. +check runs as one extra short Lean process per call. Execution names the +package-qualified `@repl/repl` target so a project target cannot shadow it. REPL children and LSP processes remain lazy. A tool call stays pending while Lean starts, so no `/repl-start`, `/lsp-start`, or model-side sleep is needed. @@ -59,6 +58,7 @@ by `AUTOFORM_REPL_TOTAL_WORKERS`, `AUTOFORM_REPL_WORKERS_PER_PROJECT`, `AUTOFORM_MAX_LEAN_PROJECTS`, and `AUTOFORM_LEAN_IDLE_SECONDS`. A REPL worker is one concurrent call slot; an idle slot holds no Lean process. The first process to start the runtime supplies those settings until it is stopped. + `LEAN_REPL_CMD` selects the disposable child command. Its default, `lake exe @repl/repl`, finds the REPL only through a require named `repl`. For a require with any other name, rename the require to `repl`, or restart the @@ -68,15 +68,18 @@ starts with a `lake` executable, absolute or not, header validation reuses that executable without the command's other arguments. A container or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, -passing the source on standard input. Like the limits above, both commands are -node-wide and read only when the runtime starts; after changing either, run -`uv run autoform-lean-runtime stop`. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps -the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default -`timeout` is the lower of 240 seconds and that cap. If `LEAN_REPL_CMD` does not -start with a `lake` executable and `LEAN_REPL_HEADER_CMD` is unset, if an -explicit `AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, or if that cap does -not fit the response budget below, the runtime refuses to start, so the LSP -tools are unavailable as well. +passing the source on standard input. Like the limits above, `LEAN_REPL_CMD` and +`LEAN_REPL_HEADER_CMD` are node-wide and read only when the runtime starts; +after changing either, run `uv run autoform-lean-runtime stop`. + +`AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps the REPL `timeout`; when +`AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default `timeout` is the lower of +240 seconds and that cap. If `LEAN_REPL_CMD` does not start with a `lake` +executable and `LEAN_REPL_HEADER_CMD` is unset, if an explicit +`AUTOFORM_REPL_REQUEST_TIMEOUT` exceeds that cap, or if that cap does not fit +the response budget below, the runtime refuses to start, so the LSP tools are +unavailable as well. + `AUTOFORM_RUNTIME_RESPONSE_TIMEOUT` (default 15 minutes) bounds project admission, the tool operation, and a safety margin. Failed cleanup transfers to a retained background reaper and may continue past that response budget rather diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index ccf78a3fc..46d468c1a 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -271,7 +271,10 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) "which applies to every project the runtime serves" ) in servers assert "header validation reuses that executable without the command's other arguments" in servers - assert "both commands are node-wide and read only when the runtime starts" in servers + assert ( + "`LEAN_REPL_CMD` and `LEAN_REPL_HEADER_CMD` are node-wide and read only " + "when the runtime starts" + ) in servers assert "after changing either, run `uv run autoform-lean-runtime stop`" in servers assert "one total post-admission budget" in servers assert "it and its cap default to 240 seconds" in servers From a5020ddbef3f2a27c12b88c8372df362121e1bbd Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:02:40 -0400 Subject: [PATCH 101/109] Say that a set LEAN_REPL_HEADER_CMD overrides the lake default The README said that header validation reuses the `lake` executable whenever LEAN_REPL_CMD starts with one, but the runtime uses a set LEAN_REPL_HEADER_CMD first, so a leftover header command still runs next to a plain `lake exe @repl/repl`. The README now states the condition, and a test pins that an explicit header command wins over a `lake` REPL command. --- servers/README.md | 7 ++++--- tests/test_shared_lean_runtime.py | 9 +++++++++ tests/test_skill_examples.py | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/servers/README.md b/servers/README.md index 3b12e380a..5b3e7be1d 100644 --- a/servers/README.md +++ b/servers/README.md @@ -63,9 +63,10 @@ to start the runtime supplies those settings until it is stopped. `lake exe @repl/repl`, finds the REPL only through a require named `repl`. For a require with any other name, rename the require to `repl`, or restart the runtime with `LEAN_REPL_CMD="lake exe @/repl"` for a require named -``, which applies to every project the runtime serves. If `LEAN_REPL_CMD` -starts with a `lake` executable, absolute or not, header validation reuses that -executable without the command's other arguments. A container or other wrapper +``, which applies to every project the runtime serves. +When `LEAN_REPL_HEADER_CMD` is unset and `LEAN_REPL_CMD` starts with a `lake` +executable, absolute or not, header validation reuses that executable without +the command's other arguments. A container or other wrapper must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, passing the source on standard input. Like the limits above, `LEAN_REPL_CMD` and diff --git a/tests/test_shared_lean_runtime.py b/tests/test_shared_lean_runtime.py index a4ffd0762..fc50111f9 100644 --- a/tests/test_shared_lean_runtime.py +++ b/tests/test_shared_lean_runtime.py @@ -1888,6 +1888,15 @@ def test_absolute_lake_command_is_reused_for_header_validation(monkeypatch): ) +def test_explicit_header_command_overrides_a_lake_repl_command(monkeypatch): + monkeypatch.setenv("LEAN_REPL_CMD", "lake exe @repl/repl") + monkeypatch.setenv("LEAN_REPL_HEADER_CMD", "lake env lean-header") + + config = LeanRuntimeConfig.from_environment() + + assert config.repl_header_command == ("lake", "env", "lean-header") + + def test_wrapped_repl_command_requires_an_explicit_header_command(monkeypatch): monkeypatch.setenv("LEAN_REPL_CMD", "container run lake exe @repl/repl") diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 46d468c1a..c37e66797 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -270,6 +270,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) '`LEAN_REPL_CMD="lake exe @/repl"` for a require named ``, ' "which applies to every project the runtime serves" ) in servers + assert "When `LEAN_REPL_HEADER_CMD` is unset and `LEAN_REPL_CMD` starts with a `lake` executable" in servers assert "header validation reuses that executable without the command's other arguments" in servers assert ( "`LEAN_REPL_CMD` and `LEAN_REPL_HEADER_CMD` are node-wide and read only " From 38936deaa6d69bf8074f14426def3e6b7cdd7779 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:03:14 -0400 Subject: [PATCH 102/109] Say how a changed REPL command reaches the restarted runtime The README said only to stop the runtime after changing LEAN_REPL_CMD or LEAN_REPL_HEADER_CMD, which reads as if stopping were enough. The daemon inherits the environment of whichever process starts it next, usually the MCP adapter of the next tool call, whose environment predates a change made in a shell. The README now says that process must have the new values, and that `uv run autoform-lean-runtime status` shows the resolved commands under `config`. --- servers/README.md | 6 +++++- tests/test_skill_examples.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/servers/README.md b/servers/README.md index 5b3e7be1d..dd0458d75 100644 --- a/servers/README.md +++ b/servers/README.md @@ -71,7 +71,11 @@ must also set `LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, passing the source on standard input. Like the limits above, `LEAN_REPL_CMD` and `LEAN_REPL_HEADER_CMD` are node-wide and read only when the runtime starts; -after changing either, run `uv run autoform-lean-runtime stop`. +after changing either, run `uv run autoform-lean-runtime stop`. The next +process to start the runtime, an MCP adapter serving a tool call or +`uv run autoform-lean-runtime start`, must have the new values in its +environment, and `uv run autoform-lean-runtime status` shows the resolved +commands under `config`. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default `timeout` is the lower of diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index c37e66797..7c19309c0 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -277,6 +277,11 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) "when the runtime starts" ) in servers assert "after changing either, run `uv run autoform-lean-runtime stop`" in servers + assert ( + "The next process to start the runtime, an MCP adapter serving a tool call or " + "`uv run autoform-lean-runtime start`, must have the new values in its environment" + ) in servers + assert "`uv run autoform-lean-runtime status` shows the resolved commands under `config`" in servers assert "one total post-admission budget" in servers assert "it and its cap default to 240 seconds" in servers assert "its REPL slot pool is cached" in servers From 35c02f88f3ff3f992b4db5f55b89395eedc12286 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:03:35 -0400 Subject: [PATCH 103/109] Say that a project without a REPL require fails every REPL call The README covered a REPL require under another name but not a missing one, which is how `autoform project new` and `init` leave every project, and Lake reports both with the same unknown-package error. The README now says that such a project fails every REPL call until it adds the require as the setup skill describes, and links the skill. --- servers/README.md | 4 +++- tests/test_skill_examples.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/servers/README.md b/servers/README.md index dd0458d75..13b7083ee 100644 --- a/servers/README.md +++ b/servers/README.md @@ -63,7 +63,9 @@ to start the runtime supplies those settings until it is stopped. `lake exe @repl/repl`, finds the REPL only through a require named `repl`. For a require with any other name, rename the require to `repl`, or restart the runtime with `LEAN_REPL_CMD="lake exe @/repl"` for a require named -``, which applies to every project the runtime serves. +``, which applies to every project the runtime serves. A project with no +REPL require, as `autoform project new` and `init` leave it, fails every REPL +call until it adds one as the [setup skill](../skills/setup/SKILL.md) describes. When `LEAN_REPL_HEADER_CMD` is unset and `LEAN_REPL_CMD` starts with a `lake` executable, absolute or not, header validation reuses that executable without the command's other arguments. A container or other wrapper diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index 7c19309c0..f3de9b988 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -270,6 +270,11 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) '`LEAN_REPL_CMD="lake exe @/repl"` for a require named ``, ' "which applies to every project the runtime serves" ) in servers + assert ( + "A project with no REPL require, as `autoform project new` and `init` leave it, " + "fails every REPL call until it adds one as the " + "[setup skill](../skills/setup/SKILL.md) describes" + ) in servers assert "When `LEAN_REPL_HEADER_CMD` is unset and `LEAN_REPL_CMD` starts with a `lake` executable" in servers assert "header validation reuses that executable without the command's other arguments" in servers assert ( From 067db5fbba281b3263ccec09f67b46401a5c9815 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:03:51 -0400 Subject: [PATCH 104/109] Tell example adopters to move the REPL pin with the Lean version The example README listed the pins to refresh when adapting it and allowed another Lean version, but left out the REPL revision, which the setup skill ties to the project's Lean version, and the manifest that locks it. It now says to move the REPL pin as the setup skill describes and to regenerate lake-manifest.json with `lake update` when the Lean version changes. --- skills/setup/assets/cabannes-thesis-project/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/setup/assets/cabannes-thesis-project/README.md b/skills/setup/assets/cabannes-thesis-project/README.md index 8a1c036a5..4857f1f08 100644 --- a/skills/setup/assets/cabannes-thesis-project/README.md +++ b/skills/setup/assets/cabannes-thesis-project/README.md @@ -32,4 +32,5 @@ mathematics through Roadmap, merge existing ignore/workflow files, and keep the recommended release from `autoform project versions` unless another Lean version is needed. Refresh the default branch, repository URL, and immutable Autoform/Action pins rather than copying them -blindly. +blindly; for another Lean version, also move the REPL pin as the setup skill +describes and regenerate `lake-manifest.json` with `lake update`. From 48dae8d45f161b21bb767fa58156a3c79a24373d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:04:51 -0400 Subject: [PATCH 105/109] Check the REPL locks and the tag the docs name against one commit The pin test compared only the two lakefile revisions, so the fixture's lake-manifest.json, which Lake actually locks to, could drift from its lakefile unnoticed, and nothing tied the tag v4.32.0 that the setup skill and the example README name to the pinned hash. Both lakefiles and both manifests must now name the commit that upstream tag v4.32.0 points at, kept in a constant whose comment cites the tag listing, and both docs must still name that tag. --- tests/test_skill_examples.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index f3de9b988..ae2e0891e 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -19,6 +19,10 @@ _HREF = re.compile(r'href="([^"]+)"') _EXAMPLE = Path("skills/setup/assets/cabannes-thesis-project") +# Upstream tag v4.32.0 of leanprover-community/repl points at this commit: +# `git ls-remote --tags https://github.com/leanprover-community/repl.git` +# lists refs/tags/v4.32.0 at it. +_REPL_V4_32_0_COMMIT = "68a3b3a059787a7db44fb1e6281e4a657efee470" def test_root_readme_uses_the_canonical_repository(repo_root: Path) -> None: @@ -317,15 +321,25 @@ def test_setup_example_pins_the_repl_that_the_real_repl_job_tests(repo_root: Pat fixture = repo_root / "tests/fixtures/repl-smoke" example = repo_root / _EXAMPLE - def repl_rev(project: Path) -> str: + def repl_revs(project: Path) -> tuple[str, str]: lakefile = tomllib.loads((project / "lakefile.toml").read_text(encoding="utf-8")) - return next(entry["rev"] for entry in lakefile["require"] if entry["name"] == "repl") + manifest = json.loads((project / "lake-manifest.json").read_text(encoding="utf-8")) + required = next(entry["rev"] for entry in lakefile["require"] if entry["name"] == "repl") + locked = next(entry["rev"] for entry in manifest["packages"] if entry["name"] == "repl") + return required, locked def toolchain(project: Path) -> str: return (project / "lean-toolchain").read_text(encoding="utf-8").strip() - assert repl_rev(example) == repl_rev(fixture) + for project in (example, fixture): + assert repl_revs(project) == (_REPL_V4_32_0_COMMIT, _REPL_V4_32_0_COMMIT), project assert toolchain(example) == toolchain(fixture) + # The docs name the tag rather than the commit, so they hold only while + # every pin above is that tag's commit. + setup = " ".join((repo_root / "skills/setup/SKILL.md").read_text(encoding="utf-8").split()) + readme = " ".join((example / "README.md").read_text(encoding="utf-8").split()) + assert "the bundled example pins the commit of tag `v4.32.0`" in setup + assert "the upstream REPL commit tagged `v4.32.0`" in readme def test_setup_guidance_uses_the_offline_atomic_project_creator(repo_root: Path) -> None: From 4d63549498d47b8c728737b382652052a8317499 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:38:38 -0400 Subject: [PATCH 106/109] Scope the post-slot shutdown patch to the call under test The wrapper that stops admission once the call has its slot stayed installed through the test's closing pool.shutdown(). Its drain calls get_nowait, which calls get, so the wrapper took the pool lock and stopped admission again inside shutdown, which would hang the test if the drain ever ran under that lock. The patch now covers only the call. --- tests/test_repl_pool_lifecycle.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 1a608429e..2334cb980 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -466,10 +466,11 @@ def take_slot_as_shutdown_begins(*args, **kwargs): pool._stop_admission_locked() return slot - monkeypatch.setattr(pool._idle, "get", take_slot_as_shutdown_begins) - - with pytest.raises(RuntimeError) as refused: - pool.run("#check Nat", timeout=1) + # The patch ends with the call: shutdown drains the queue through get too. + with monkeypatch.context() as patch: + patch.setattr(pool._idle, "get", take_slot_as_shutdown_begins) + with pytest.raises(RuntimeError) as refused: + pool.run("#check Nat", timeout=1) assert str(refused.value) == _SHUTDOWN_REFUSAL assert calls == [] From e3d7b65dd2127aec0f8a359176dc0fed4b502b4d Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:39:25 -0400 Subject: [PATCH 107/109] Put the node-wide README pin on one line The pin naming both REPL commands was split into two literals although it fits within the 120-column line limit, so ruff format would have joined it. It is now one line, like the other pins that fit. --- tests/test_skill_examples.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_skill_examples.py b/tests/test_skill_examples.py index ae2e0891e..8b436af62 100644 --- a/tests/test_skill_examples.py +++ b/tests/test_skill_examples.py @@ -281,10 +281,7 @@ def test_public_repl_calls_are_documented_as_process_disposable(repo_root: Path) ) in servers assert "When `LEAN_REPL_HEADER_CMD` is unset and `LEAN_REPL_CMD` starts with a `lake` executable" in servers assert "header validation reuses that executable without the command's other arguments" in servers - assert ( - "`LEAN_REPL_CMD` and `LEAN_REPL_HEADER_CMD` are node-wide and read only " - "when the runtime starts" - ) in servers + assert "`LEAN_REPL_CMD` and `LEAN_REPL_HEADER_CMD` are node-wide and read only when the runtime starts" in servers assert "after changing either, run `uv run autoform-lean-runtime stop`" in servers assert ( "The next process to start the runtime, an MCP adapter serving a tool call or " From 80e0631517fcd00558c11f53c1fc9cfece303c14 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:45:44 -0400 Subject: [PATCH 108/109] Drop the REPL environment id that no call passes Every public call now sends one frame to a fresh child, so _run and _run_io always received env_id=None and never put "env" in the frame. Remove the parameter and the dead branch, and stop the tests passing it. --- servers/repl/core.py | 11 ++--- tests/test_repl_core_protocol.py | 73 +++++++++++++++---------------- tests/test_repl_pool_lifecycle.py | 6 +-- 3 files changed, 41 insertions(+), 49 deletions(-) diff --git a/servers/repl/core.py b/servers/repl/core.py index d19d82b7f..d9c4d28a1 100644 --- a/servers/repl/core.py +++ b/servers/repl/core.py @@ -989,7 +989,6 @@ def remaining(phase: str) -> float: try: response = self._run( code=command, - env_id=None, timeout=command_timeout, ) except ReplStderrBacklog as error: @@ -1051,7 +1050,7 @@ def remaining(phase: str) -> float: raise request_error return result - def _run(self, code: str, env_id: int | None, timeout: float) -> dict[str, Any]: + def _run(self, code: str, timeout: float) -> dict[str, Any]: """Run one frame and distinguish safe pre-send failures from unknown outcomes.""" request_sent = False cleanup_deadline = time.monotonic() + timeout @@ -1061,7 +1060,7 @@ def mark_sent() -> None: request_sent = True try: - return self._run_io(code, env_id, timeout, mark_sent) + return self._run_io(code, timeout, mark_sent) except ReplOutcomeUnknown as error: message = str(error) try: @@ -1101,15 +1100,11 @@ def mark_sent() -> None: def _run_io( self, code: str, - env_id: int | None, timeout: float, mark_sent: Callable[[], None], ) -> dict[str, Any]: """Send code to the REPL via stdin JSON-RPC, read response via non-blocking I/O.""" - cmd_obj: dict[str, Any] = {"cmd": code} - if env_id is not None: - cmd_obj["env"] = env_id - command = json.dumps(cmd_obj) + "\n\n" + command = json.dumps({"cmd": code}) + "\n\n" if ( self.process is None diff --git a/tests/test_repl_core_protocol.py b/tests/test_repl_core_protocol.py index c5ebd70cd..2a6c4748b 100644 --- a/tests/test_repl_core_protocol.py +++ b/tests/test_repl_core_protocol.py @@ -479,8 +479,8 @@ def start(): lambda code, deadline: repl_core._LeanHeaderAnalysis((), True), ) - def run(code, env_id, timeout): - calls.append((code, env_id, timeout)) + def run(code, timeout): + calls.append(code) return { "env": 4, "messages": [ @@ -533,9 +533,7 @@ def run(code, env_id, timeout): ], } assert len(starts) == 1 - assert len(calls) == 1 - assert calls[0][0] == "import Mathlib\n#check Nat" - assert calls[0][1] is None + assert calls == ["import Mathlib\n#check Nat"] assert len(closes) == 2 @@ -557,7 +555,7 @@ def start(): budgets.append("startup") clock["now"] = 5 - def run(code, env_id, timeout): + def run(code, timeout): budgets.append(("command", timeout, code)) return {"env": 1, "messages": []} @@ -1296,7 +1294,7 @@ def test_disposable_call_prefixes_warmup_only_onto_proven_ordinary_headers(monke frames = [] monkeypatch.setattr(repl, "start", lambda: None) - def run_frame(code, env_id, timeout): + def run_frame(code, timeout): frames.append(code) return {"env": 1, "messages": [], "sorries": []} @@ -1452,8 +1450,8 @@ def test_disposable_timeout_after_full_write_is_not_retried(monkeypatch): calls = [] monkeypatch.setattr(repl, "start", lambda: setattr(repl, "process", object())) - def fail_after_send(code, env_id, timeout, mark_sent): - calls.append((code, env_id)) + def fail_after_send(code, timeout, mark_sent): + calls.append(code) mark_sent() raise TimeoutError("response timed out") @@ -1464,7 +1462,7 @@ def fail_after_send(code, env_id, timeout, mark_sent): "repl_error": "Lean REPL combined command execution timed out after 1 seconds", "outcome_unknown": True, } - assert calls == [("#eval 1", None)] + assert calls == ["#eval 1"] assert repl.process is None @@ -1481,7 +1479,7 @@ def close(**kwargs): if close_calls > 1: raise RuntimeError("cleanup failed") - def fail_after_send(code, env_id, timeout, mark_sent): + def fail_after_send(code, timeout, mark_sent): mark_sent() raise TimeoutError("response timed out") @@ -1508,7 +1506,7 @@ def test_run_closes_and_reraises_cancellation(monkeypatch, request_sent): repl.process = object() retired = [] - def cancel(code, env_id, timeout, mark_sent): + def cancel(code, timeout, mark_sent): if request_sent: mark_sent() raise asyncio.CancelledError @@ -1517,7 +1515,7 @@ def cancel(code, env_id, timeout, mark_sent): monkeypatch.setattr(repl, "close", lambda **kwargs: retired.append(True)) with pytest.raises(asyncio.CancelledError): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert retired == [True] @@ -1540,7 +1538,7 @@ def test_run_preserves_cancellation_when_cleanup_fails(monkeypatch): ) with pytest.raises(asyncio.CancelledError) as raised: - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) if hasattr(raised.value, "add_note"): assert raised.value.__notes__ == [ @@ -1554,10 +1552,10 @@ def test_wire_protocol_accepts_response_split_across_reads(monkeypatch): repl = _repl_with_process(process, chunk_size=8) _patch_pipe_reads(monkeypatch, process) - assert repl._run("#check Nat", env_id=3, timeout=1) == {"messages": []} + assert repl._run("#check Nat", timeout=1) == {"messages": []} request = process._stdin_read.read(4096) - assert json.loads(request.decode().strip()) == {"cmd": "#check Nat", "env": 3} + assert json.loads(request.decode().strip()) == {"cmd": "#check Nat"} def test_wire_protocol_rechecks_deadline_before_dispatch_delimiter(monkeypatch): @@ -1583,7 +1581,6 @@ def fake_select(readable, writable, exceptional, timeout=None): with pytest.raises(TimeoutError, match="while writing"): repl._run_io( "#check Nat", - env_id=None, timeout=1, mark_sent=lambda: sent.append(True), ) @@ -1610,7 +1607,7 @@ def fail_final_delimiter(fd: int, data) -> int: monkeypatch.setattr(repl, "close", lambda **kwargs: retired.append(True)) with pytest.raises(repl_core.ReplOutcomeUnknown, match="fully sent"): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) request = os.read(process._stdin_read.fileno(), 4096) assert request.endswith(b"\n") and not request.endswith(b"\n\n") @@ -1624,7 +1621,7 @@ def test_wire_protocol_retires_process_on_unsolicited_second_frame(monkeypatch): _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplOutcomeUnknown, match="unsolicited bytes"): - repl._run("#check Nat", env_id=3, timeout=1) + repl._run("#check Nat", timeout=1) assert repl.process is None @@ -1642,7 +1639,7 @@ def serve_request() -> None: worker = threading.Thread(target=serve_request, daemon=True) worker.start() - assert repl._run("#check Nat", env_id=None, timeout=1) == {"env": 1} + assert repl._run("#check Nat", timeout=1) == {"env": 1} worker.join(timeout=1) assert not worker.is_alive() assert bytes(request) == b'{"cmd": "#check Nat"}\n\n' @@ -1669,7 +1666,7 @@ def write_body_then_emit_stdout(fd: int, data: bytes) -> int: monkeypatch.setattr(repl_core.os, "write", write_body_then_emit_stdout) with pytest.raises(repl_core.ReplProcessExited, match="unsolicited stdout"): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) os.set_blocking(process._stdin_read.fileno(), False) written = os.read(process._stdin_read.fileno(), 4096) @@ -1689,7 +1686,7 @@ def test_wire_protocol_preserves_utf8_split_across_reads(monkeypatch): repl = _repl_with_process(process, chunk_size=7) _patch_pipe_reads(monkeypatch, process) - result = repl._run("#check Nat", env_id=None, timeout=1) + result = repl._run("#check Nat", timeout=1) assert result["messages"][0]["data"] == "Nat → Nat" @@ -1702,7 +1699,7 @@ def test_wire_protocol_reports_a_bounded_stderr_tail_on_premature_eof(monkeypatc _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplOutcomeUnknown) as error: - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert str(error.value).endswith(stderr[-repl_core._STDERR_TAIL_BYTES :].decode()) assert "discarded-prefix" not in str(error.value) @@ -1727,7 +1724,7 @@ def fake_read(fd: int, size: int) -> bytes: # captured. stderr cannot be drained to a boundary though, so the # process is retired and the response travels on the backlog error. with pytest.raises(repl_core.ReplStderrBacklog) as error: - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert error.value.response == {"messages": []} @@ -1756,7 +1753,7 @@ def fake_monotonic() -> float: monkeypatch.setattr(repl_core.time, "monotonic", fake_monotonic) with pytest.raises(repl_core.ReplOutcomeUnknown, match="timed out"): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) def test_wire_protocol_retires_a_process_generation_that_exceeds_its_stderr_quota(monkeypatch): @@ -1766,7 +1763,7 @@ def test_wire_protocol_retires_a_process_generation_that_exceeds_its_stderr_quot _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplStderrBacklog, match="process-generation stderr") as error: - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert error.value.response == {} assert repl.process is None @@ -1812,7 +1809,7 @@ def block_stdin_until_stderr_is_drained( block_stdin_until_stderr_is_drained, ) - assert repl._run("#check Nat", env_id=None, timeout=1) == {} + assert repl._run("#check Nat", timeout=1) == {} assert process.stderr_bytes == b"" assert repl._stderr_bytes == 8 @@ -1824,7 +1821,7 @@ def test_wire_protocol_retires_a_process_with_closed_stderr_before_writing(): repl = _repl_with_process(process) with pytest.raises(repl_core.ReplProcessExited, match="before the request frame"): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert repl.process is None @@ -1839,7 +1836,7 @@ def test_stdout_eof_before_the_frame_is_sent_reports_the_stderr_tail(): repl = _repl_with_process(process) with pytest.raises(repl_core.ReplProcessExited, match="closed stdout before") as error: - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert repl.process is None assert "error: unknown package `repl`" in str(error.value) @@ -1860,7 +1857,7 @@ def test_stdout_eof_keeps_only_a_fixed_stderr_tail(monkeypatch): _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplProcessExited) as error: - repl._run_io("#check Nat", env_id=None, timeout=1, mark_sent=lambda: None) + repl._run_io("#check Nat", timeout=1, mark_sent=lambda: None) assert len(repl._stderr_tail) == repl_core._STDERR_TAIL_BYTES assert bytes(repl._stderr_tail) == b"b" * repl_core._STDERR_TAIL_BYTES @@ -1895,7 +1892,7 @@ def fake_select(readable, writable, exceptional, timeout=None): monkeypatch.setattr(repl_core.select, "select", fake_select) with pytest.raises(repl_core.ReplProcessExited, match="diagnostdiagnost"): - repl._run_io("#check Nat", env_id=None, timeout=1, mark_sent=lambda: None) + repl._run_io("#check Nat", timeout=1, mark_sent=lambda: None) assert stderr_reads == 2 @@ -1907,7 +1904,7 @@ def test_wire_protocol_drains_queued_stderr_after_the_response_frame_completes(m repl = _repl_with_process(process, chunk_size=18, max_buffer_bytes=1024) _patch_pipe_reads(monkeypatch, process) - assert repl._run("#check Nat", env_id=None, timeout=5) == {"messages": []} + assert repl._run("#check Nat", timeout=5) == {"messages": []} # Unlike a fairness cycle, the post-response drain reads until stderr is empty. assert process.stderr_bytes == b"" @@ -1920,7 +1917,7 @@ def test_wire_protocol_reports_a_backlog_when_the_cap_ends_the_post_response_dra _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplStderrBacklog) as error: - repl._run("#check Nat", env_id=None, timeout=5) + repl._run("#check Nat", timeout=5) # The response survives as the request's answer... assert error.value.response == {"messages": []} @@ -1934,7 +1931,7 @@ def test_wire_protocol_accepts_stderr_that_ends_exactly_at_the_cap(monkeypatch): repl = _repl_with_process(process, chunk_size=18, max_buffer_bytes=20) _patch_pipe_reads(monkeypatch, process) - assert repl._run("#check Nat", env_id=None, timeout=5) == {"messages": []} + assert repl._run("#check Nat", timeout=5) == {"messages": []} assert repl.process is process assert process.stderr_bytes == b"" @@ -1966,7 +1963,7 @@ def fake_monotonic() -> float: # Endlessly readable stderr must not hang the post-response drain, and the # deadline must not discard a response that was already captured. with pytest.raises(repl_core.ReplStderrBacklog) as error: - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) assert error.value.response == {"messages": []} @@ -1995,7 +1992,7 @@ def test_run_never_leaves_a_reusable_process_when_a_backlog_stops_the_drain(monk _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplStderrBacklog): - repl._run("#check Nat", env_id=None, timeout=5) + repl._run("#check Nat", timeout=5) assert repl.process is None assert repl.is_clean() @@ -2022,7 +2019,7 @@ def test_wire_protocol_rejects_invalid_json(monkeypatch): _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplOutcomeUnknown, match="fully sent"): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) @pytest.mark.parametrize( @@ -2051,7 +2048,7 @@ def test_wire_protocol_rejects_oversized_response(monkeypatch): _patch_pipe_reads(monkeypatch, process) with pytest.raises(repl_core.ReplOutcomeUnknown, match="response exceeded 8 bytes"): - repl._run("#check Nat", env_id=None, timeout=1) + repl._run("#check Nat", timeout=1) _RESPONSIVE_FAKE_REPL = ( diff --git a/tests/test_repl_pool_lifecycle.py b/tests/test_repl_pool_lifecycle.py index 2334cb980..e8fe5f652 100644 --- a/tests/test_repl_pool_lifecycle.py +++ b/tests/test_repl_pool_lifecycle.py @@ -184,11 +184,11 @@ def test_queued_call_timeout_states_the_callers_budget(monkeypatch): ) (repl,) = pool._workers - def run(code, env_id, timeout): + def run(code, timeout): command_timeouts.append(timeout) raise repl_core.ReplOutcomeUnknown("transport failed") from TimeoutError() - monkeypatch.setattr(repl, "start", lambda **kwargs: None) + monkeypatch.setattr(repl, "start", lambda: None) monkeypatch.setattr(repl, "_run", run) # The wait for an idle slot takes 2 of the caller's 10 seconds. clock = {"now": 0.0} @@ -556,7 +556,7 @@ def poll(self): repl.process = process try: with pytest.raises(TimeoutError, match="while writing"): - repl._run("#check Nat", env_id=None, timeout=0.02) + repl._run("#check Nat", timeout=0.02) finally: stdin.close() process.stdout.close() From 4da89ed5e3665961ec5944c8875afb9865829322 Mon Sep 17 00:00:00 2001 From: Jack McCarthy Date: Tue, 13 Oct 2026 14:53:29 -0400 Subject: [PATCH 109/109] Reflow the servers README's REPL command paragraph Earlier edits left a short line in the middle of the paragraph. Rewrap it to the file's width without changing any words. --- servers/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/servers/README.md b/servers/README.md index 13b7083ee..ead031a7b 100644 --- a/servers/README.md +++ b/servers/README.md @@ -68,16 +68,16 @@ REPL require, as `autoform project new` and `init` leave it, fails every REPL call until it adds one as the [setup skill](../skills/setup/SKILL.md) describes. When `LEAN_REPL_HEADER_CMD` is unset and `LEAN_REPL_CMD` starts with a `lake` executable, absolute or not, header validation reuses that executable without -the command's other arguments. A container or other wrapper -must also set `LEAN_REPL_HEADER_CMD` to a command that runs -`servers/repl/parse_header.lean` with the matching toolchain's `lean --run`, -passing the source on standard input. Like the limits above, `LEAN_REPL_CMD` and -`LEAN_REPL_HEADER_CMD` are node-wide and read only when the runtime starts; -after changing either, run `uv run autoform-lean-runtime stop`. The next -process to start the runtime, an MCP adapter serving a tool call or -`uv run autoform-lean-runtime start`, must have the new values in its -environment, and `uv run autoform-lean-runtime status` shows the resolved -commands under `config`. +the command's other arguments. A container or other wrapper must also set +`LEAN_REPL_HEADER_CMD` to a command that runs `servers/repl/parse_header.lean` +with the matching toolchain's `lean --run`, passing the source on standard +input. Like the limits above, `LEAN_REPL_CMD` and `LEAN_REPL_HEADER_CMD` are +node-wide and read only when the runtime starts; after changing either, run +`uv run autoform-lean-runtime stop`. The next process to start the runtime, an +MCP adapter serving a tool call or `uv run autoform-lean-runtime start`, must +have the new values in its environment, and +`uv run autoform-lean-runtime status` shows the resolved commands under +`config`. `AUTOFORM_MAX_REPL_REQUEST_SECONDS` caps the REPL `timeout`; when `AUTOFORM_REPL_REQUEST_TIMEOUT` is unset, the default `timeout` is the lower of