leanback 0.1.3__tar.gz → 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {leanback-0.1.3 → leanback-0.1.4}/PKG-INFO +7 -2
- {leanback-0.1.3 → leanback-0.1.4}/README.md +6 -1
- {leanback-0.1.3 → leanback-0.1.4}/pyproject.toml +1 -1
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/README.md +5 -2
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/common/env.py +41 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/common/errors.py +28 -0
- leanback-0.1.4/src/leanback/common/lsp_client.py +412 -0
- leanback-0.1.4/src/leanback/common/warm.py +171 -0
- leanback-0.1.4/src/leanback/common/watchdog.py +182 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/mcp_server.py +76 -24
- {leanback-0.1.3 → leanback-0.1.4}/.gitignore +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/LICENSE +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/__init__.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/check.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/common/__init__.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/common/path_manager.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/common/project_context.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/common/util.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/goals.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/mathlib_search.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/mathlib_search_simple.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/mcp_test.py +0 -0
- {leanback-0.1.3 → leanback-0.1.4}/src/leanback/prove.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leanback
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: MCP server for Lean 4 theorem proving — check, prove, goals, eval, search
|
|
5
5
|
Author: Stefan Szeider
|
|
6
6
|
License: Apache-2.0
|
|
@@ -92,6 +92,8 @@ check(project="/home/user/lean/myproject", file="MyProof.lean")
|
|
|
92
92
|
check(project="/home/user/lean/myproject", expr="#check Nat.add", imports=["Mathlib.Tactic"])
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
Repeated `check(file=...)` calls on the same file reuse a warm Lean server: the first check pays the import cost, subsequent checks after edits take seconds (as long as the file's import lines are unchanged). Idle servers shut down automatically after 10 minutes; set `LEANBACK_NO_WARM=1` to disable warm sessions entirely.
|
|
96
|
+
|
|
95
97
|
### prove
|
|
96
98
|
|
|
97
99
|
Verify theorem proofs with tactics, or check proof files.
|
|
@@ -121,8 +123,11 @@ Execute Lean expressions and see results.
|
|
|
121
123
|
eval(project="/home/user/lean/myproject", code="#eval 2 + 2")
|
|
122
124
|
eval(project="/home/user/lean/myproject", code="#check List.map")
|
|
123
125
|
eval(project="/home/user/lean/myproject", code="#eval Nat.gcd 12 18", mathlib=True)
|
|
126
|
+
eval(project="/home/user/lean/myproject", code="#eval myFunc 3", imports=["MyProject.Definitions"], build=True)
|
|
124
127
|
```
|
|
125
128
|
|
|
129
|
+
With `build=True`, imported modules of the project itself are compiled first (`lake build`), so they don't need to be built manually before use.
|
|
130
|
+
|
|
126
131
|
### search
|
|
127
132
|
|
|
128
133
|
Find declarations in Mathlib by pattern.
|
|
@@ -135,7 +140,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
|
|
|
135
140
|
|
|
136
141
|
## All Tools Are Read-Only
|
|
137
142
|
|
|
138
|
-
LeanBack never modifies files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools.
|
|
143
|
+
LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. The one exception: `eval` with `build=True` runs `lake build`, which writes build artifacts to the project's `.lake` directory.
|
|
139
144
|
|
|
140
145
|
## Response Format
|
|
141
146
|
|
|
@@ -73,6 +73,8 @@ check(project="/home/user/lean/myproject", file="MyProof.lean")
|
|
|
73
73
|
check(project="/home/user/lean/myproject", expr="#check Nat.add", imports=["Mathlib.Tactic"])
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
Repeated `check(file=...)` calls on the same file reuse a warm Lean server: the first check pays the import cost, subsequent checks after edits take seconds (as long as the file's import lines are unchanged). Idle servers shut down automatically after 10 minutes; set `LEANBACK_NO_WARM=1` to disable warm sessions entirely.
|
|
77
|
+
|
|
76
78
|
### prove
|
|
77
79
|
|
|
78
80
|
Verify theorem proofs with tactics, or check proof files.
|
|
@@ -102,8 +104,11 @@ Execute Lean expressions and see results.
|
|
|
102
104
|
eval(project="/home/user/lean/myproject", code="#eval 2 + 2")
|
|
103
105
|
eval(project="/home/user/lean/myproject", code="#check List.map")
|
|
104
106
|
eval(project="/home/user/lean/myproject", code="#eval Nat.gcd 12 18", mathlib=True)
|
|
107
|
+
eval(project="/home/user/lean/myproject", code="#eval myFunc 3", imports=["MyProject.Definitions"], build=True)
|
|
105
108
|
```
|
|
106
109
|
|
|
110
|
+
With `build=True`, imported modules of the project itself are compiled first (`lake build`), so they don't need to be built manually before use.
|
|
111
|
+
|
|
107
112
|
### search
|
|
108
113
|
|
|
109
114
|
Find declarations in Mathlib by pattern.
|
|
@@ -116,7 +121,7 @@ search(project="/home/user/lean/myproject", batch="add_assoc,mul_assoc,add_comm"
|
|
|
116
121
|
|
|
117
122
|
## All Tools Are Read-Only
|
|
118
123
|
|
|
119
|
-
LeanBack never modifies files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools.
|
|
124
|
+
LeanBack never modifies your source files. It only reads project structure and runs Lean to check/evaluate code. File creation and editing is left to your agent's own tools. The one exception: `eval` with `build=True` runs `lake build`, which writes build artifacts to the project's `.lake` directory.
|
|
120
125
|
|
|
121
126
|
## Response Format
|
|
122
127
|
|
|
@@ -12,8 +12,11 @@ MCP server exposing Lean 4 development tools via Model Context Protocol.
|
|
|
12
12
|
- `mathlib_search_simple.py`: Regex-based Mathlib search backend
|
|
13
13
|
- `mcp_test.py`: E2E test client
|
|
14
14
|
- `common/`: Shared utilities
|
|
15
|
-
- `env.py`: Lean environment handling (LeanRunner)
|
|
16
|
-
- `errors.py`: Error parsing and formatting
|
|
15
|
+
- `env.py`: Lean environment handling (LeanRunner, lake build helpers)
|
|
16
|
+
- `errors.py`: Error parsing and formatting (CLI JSON and LSP diagnostics)
|
|
17
|
+
- `lsp_client.py`: Synchronous Lean LSP client for warm file checks
|
|
18
|
+
- `warm.py`: Warm worker registry (staleness, idle timeout, atexit cleanup)
|
|
19
|
+
- `watchdog.py`: Process wrapper that reaps workers if the server dies
|
|
17
20
|
- `project_context.py`: Project validation and context
|
|
18
21
|
- `path_manager.py`: Path resolution and management
|
|
19
22
|
- `util.py`: Misc utilities (temp file creation)
|
|
@@ -29,6 +29,47 @@ def _find_native_dynlibs(project_path: Path) -> list[Path]:
|
|
|
29
29
|
return sorted(packages_dir.glob(f"*/.lake/build/lib/lib*.{ext}"))
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def find_local_modules(project_path: Path, modules: list[str]) -> list[str]:
|
|
33
|
+
"""Filter module names to those with source files in the project.
|
|
34
|
+
|
|
35
|
+
A module like ``Foo.Bar`` is local if ``<project>/Foo/Bar.lean`` exists.
|
|
36
|
+
Dependency modules (e.g. Mathlib.*) and core modules are excluded — they
|
|
37
|
+
are already built or come from the cache.
|
|
38
|
+
"""
|
|
39
|
+
local = []
|
|
40
|
+
for module in modules:
|
|
41
|
+
source = project_path / (module.replace(".", "/") + ".lean")
|
|
42
|
+
if source.is_file():
|
|
43
|
+
local.append(module)
|
|
44
|
+
return local
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_modules(
|
|
48
|
+
project_path: Path, modules: list[str], timeout: float = 600
|
|
49
|
+
) -> tuple[bool, str]:
|
|
50
|
+
"""Build the given modules with ``lake build`` in the project directory.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (success, output). On failure, output contains the
|
|
54
|
+
combined stdout/stderr from lake.
|
|
55
|
+
"""
|
|
56
|
+
if not modules:
|
|
57
|
+
return True, ""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["lake", "build", *modules],
|
|
61
|
+
cwd=project_path,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
check=False,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
)
|
|
67
|
+
except subprocess.TimeoutExpired:
|
|
68
|
+
return False, f"lake build timed out after {timeout:.0f} seconds"
|
|
69
|
+
output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
|
|
70
|
+
return result.returncode == 0, output
|
|
71
|
+
|
|
72
|
+
|
|
32
73
|
class LeanRunner:
|
|
33
74
|
"""
|
|
34
75
|
Utility for running Lean commands in the appropriate environment.
|
|
@@ -489,6 +489,34 @@ def _get_suggestion_for_message(message: str) -> str | None:
|
|
|
489
489
|
return None
|
|
490
490
|
|
|
491
491
|
|
|
492
|
+
def lsp_diagnostics_to_errors(diagnostics: list[dict], file_name: str) -> list:
|
|
493
|
+
"""Convert LSP publishDiagnostics entries to LeanError objects.
|
|
494
|
+
|
|
495
|
+
LSP positions are 0-based for both line and character; Lean's CLI JSON
|
|
496
|
+
output (which LeanError follows) uses 1-based lines and 0-based columns,
|
|
497
|
+
so lines are shifted by one.
|
|
498
|
+
"""
|
|
499
|
+
severity_names = {1: "error", 2: "warning", 3: "info", 4: "info"}
|
|
500
|
+
errors = []
|
|
501
|
+
for diag in diagnostics:
|
|
502
|
+
start = diag.get("range", {}).get("start", {})
|
|
503
|
+
end = diag.get("range", {}).get("end", {})
|
|
504
|
+
msg_text = diag.get("message", "Unknown error")
|
|
505
|
+
errors.append(
|
|
506
|
+
LeanError(
|
|
507
|
+
file_name=file_name,
|
|
508
|
+
line=start.get("line", 0) + 1,
|
|
509
|
+
column=start.get("character", 0),
|
|
510
|
+
end_line=end.get("line", 0) + 1 if end else None,
|
|
511
|
+
end_column=end.get("character") if end else None,
|
|
512
|
+
severity=severity_names.get(diag.get("severity", 1), "error"),
|
|
513
|
+
message=msg_text,
|
|
514
|
+
suggestion=_get_suggestion_for_message(msg_text),
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
return errors
|
|
518
|
+
|
|
519
|
+
|
|
492
520
|
def parse_lean_output(output: str, json_format: bool = False) -> list:
|
|
493
521
|
"""
|
|
494
522
|
Parse Lean's output into structured errors.
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synchronous Lean LSP client for warm-environment re-checking.
|
|
3
|
+
|
|
4
|
+
LeanBack keeps a warm ``lake serve`` process per Lean project so that
|
|
5
|
+
re-checking an edited file skips the expensive Mathlib import: the imported
|
|
6
|
+
environment stays resident across edits, so a body-only edit re-checks in a
|
|
7
|
+
fraction of a second instead of tens of seconds.
|
|
8
|
+
|
|
9
|
+
This module drives a single ``lake serve`` process over the Language Server
|
|
10
|
+
Protocol (LSP). It is deliberately single-threaded: there is no background
|
|
11
|
+
reader thread. Every read happens inside a blocking wait bounded by a deadline,
|
|
12
|
+
using ``select.select`` on the server's stdout (POSIX only — the warm path is
|
|
13
|
+
POSIX-only; other platforms fall back to the cold path elsewhere).
|
|
14
|
+
|
|
15
|
+
Framing follows the LSP base protocol: ``Content-Length: N\\r\\n\\r\\n`` followed
|
|
16
|
+
by an N-byte UTF-8 JSON body. The client tolerates all interleaved server noise
|
|
17
|
+
(``$/lean/fileProgress`` notifications, intermediate ``publishDiagnostics``,
|
|
18
|
+
and server-to-client ``workspace/*/refresh`` requests).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import select
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Timeout (seconds) for the initialize handshake. The server answers
|
|
31
|
+
# ``initialize`` quickly; this only guards against a server that never starts.
|
|
32
|
+
_HANDSHAKE_TIMEOUT = 60.0
|
|
33
|
+
|
|
34
|
+
# Whole-file replacement range for an Incremental (sync kind 2) didChange: a
|
|
35
|
+
# single content-change event spanning the entire document. The end line is
|
|
36
|
+
# deliberately huge so it covers any current file length.
|
|
37
|
+
_WHOLE_FILE_RANGE = {
|
|
38
|
+
"start": {"line": 0, "character": 0},
|
|
39
|
+
"end": {"line": 10_000_000, "character": 0},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LspTimeoutError(Exception):
|
|
44
|
+
"""Raised when an LSP operation does not complete within its deadline."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LspProtocolError(Exception):
|
|
48
|
+
"""Raised on a protocol failure: dead process, malformed frame, or an
|
|
49
|
+
error response from the server."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LeanLspWorker:
|
|
53
|
+
"""Drive a single warm ``lake serve`` process over LSP.
|
|
54
|
+
|
|
55
|
+
The worker spawns the server, performs the LSP handshake, and lets callers
|
|
56
|
+
re-check the on-disk content of files. The first check of a path issues a
|
|
57
|
+
``didOpen``; later checks of the same path issue a whole-file ``didChange``,
|
|
58
|
+
which reuses the warm imported environment.
|
|
59
|
+
|
|
60
|
+
The worker is single-threaded and not safe for concurrent use.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
project_path: Path,
|
|
66
|
+
server_command: list[str] | None = None,
|
|
67
|
+
use_watchdog: bool = True,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Spawn the server and perform the LSP handshake.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
project_path: Absolute path to the Lean project. Used as the
|
|
73
|
+
server's working directory and as its ``rootUri``.
|
|
74
|
+
server_command: Full command to launch the server. Defaults to
|
|
75
|
+
``["lake", "serve"]``. Tests override this with a fake server.
|
|
76
|
+
use_watchdog: On POSIX, wrap the command in
|
|
77
|
+
``leanback.common.watchdog`` so the server is killed if this
|
|
78
|
+
process is SIGKILLed. Ignored on non-POSIX platforms.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
LspProtocolError: If the server dies or returns an error during the
|
|
82
|
+
handshake.
|
|
83
|
+
LspTimeoutError: If the handshake does not complete in time.
|
|
84
|
+
"""
|
|
85
|
+
self._project_path = Path(project_path).resolve()
|
|
86
|
+
self._closed = False
|
|
87
|
+
self._id = 0
|
|
88
|
+
self._buf = b""
|
|
89
|
+
# uri -> latest publishDiagnostics payload {"version": int|None,
|
|
90
|
+
# "diagnostics": list[dict]}.
|
|
91
|
+
self._diagnostics: dict[str, dict] = {}
|
|
92
|
+
# uris that have received a didOpen, and their current version int.
|
|
93
|
+
self._versions: dict[str, int] = {}
|
|
94
|
+
self._last_used = time.monotonic()
|
|
95
|
+
|
|
96
|
+
command = list(server_command) if server_command else ["lake", "serve"]
|
|
97
|
+
if use_watchdog and os.name == "posix":
|
|
98
|
+
command = [
|
|
99
|
+
sys.executable,
|
|
100
|
+
"-m",
|
|
101
|
+
"leanback.common.watchdog",
|
|
102
|
+
str(os.getpid()),
|
|
103
|
+
"--",
|
|
104
|
+
*command,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# stderr goes to DEVNULL: it must never be a pipe, which would fill and
|
|
108
|
+
# deadlock the server. stdout is unbuffered (bufsize=0) so a single
|
|
109
|
+
# read() returns whatever bytes are available without waiting to fill a
|
|
110
|
+
# buffer — this pairs cleanly with select() on the raw fd.
|
|
111
|
+
self._proc = subprocess.Popen(
|
|
112
|
+
command,
|
|
113
|
+
cwd=self._project_path,
|
|
114
|
+
stdin=subprocess.PIPE,
|
|
115
|
+
stdout=subprocess.PIPE,
|
|
116
|
+
stderr=subprocess.DEVNULL,
|
|
117
|
+
bufsize=0,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
self._handshake()
|
|
122
|
+
except BaseException:
|
|
123
|
+
self.shutdown()
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def alive(self) -> bool:
|
|
128
|
+
"""Whether the server process is still running and not shut down."""
|
|
129
|
+
return not self._closed and self._proc.poll() is None
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def last_used(self) -> float:
|
|
133
|
+
"""``time.monotonic()`` timestamp of the most recent ``check_file``."""
|
|
134
|
+
return self._last_used
|
|
135
|
+
|
|
136
|
+
def check_file(self, file_path: Path, timeout: float = 600.0) -> list[dict]:
|
|
137
|
+
"""Check the current on-disk content of ``file_path``.
|
|
138
|
+
|
|
139
|
+
The first call for a given path issues a ``didOpen``; later calls issue
|
|
140
|
+
a whole-file ``didChange``. The method then waits for the server to
|
|
141
|
+
finalize diagnostics for the new version and returns them.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
file_path: Absolute path to a ``.lean`` file that exists on disk.
|
|
145
|
+
timeout: Maximum seconds to wait for diagnostics to finalize.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The list of diagnostic entries (raw LSP dicts) from the
|
|
149
|
+
``publishDiagnostics`` payload for the checked version. Empty if the
|
|
150
|
+
file is clean.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
LspProtocolError: If the server has exited or returns an error.
|
|
154
|
+
LspTimeoutError: If diagnostics do not finalize within ``timeout``.
|
|
155
|
+
"""
|
|
156
|
+
self._ensure_alive()
|
|
157
|
+
self._last_used = time.monotonic()
|
|
158
|
+
|
|
159
|
+
path = Path(file_path).resolve()
|
|
160
|
+
uri = path.as_uri()
|
|
161
|
+
text = path.read_text(encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
if uri not in self._versions:
|
|
164
|
+
version = 1
|
|
165
|
+
self._versions[uri] = version
|
|
166
|
+
self._notify(
|
|
167
|
+
"textDocument/didOpen",
|
|
168
|
+
{
|
|
169
|
+
"textDocument": {
|
|
170
|
+
"uri": uri,
|
|
171
|
+
"languageId": "lean4",
|
|
172
|
+
"version": version,
|
|
173
|
+
"text": text,
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
version = self._versions[uri] + 1
|
|
179
|
+
self._versions[uri] = version
|
|
180
|
+
self._notify(
|
|
181
|
+
"textDocument/didChange",
|
|
182
|
+
{
|
|
183
|
+
"textDocument": {"uri": uri, "version": version},
|
|
184
|
+
"contentChanges": [{"range": _WHOLE_FILE_RANGE, "text": text}],
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
deadline = time.monotonic() + timeout
|
|
189
|
+
rid = self._request(
|
|
190
|
+
"textDocument/waitForDiagnostics", {"uri": uri, "version": version}
|
|
191
|
+
)
|
|
192
|
+
self._await_response(rid, deadline)
|
|
193
|
+
|
|
194
|
+
payload = self._diagnostics.get(uri)
|
|
195
|
+
if payload is None:
|
|
196
|
+
return []
|
|
197
|
+
return payload["diagnostics"]
|
|
198
|
+
|
|
199
|
+
def shutdown(self, timeout: float = 5.0) -> None:
|
|
200
|
+
"""Shut the server down and terminate the process. Idempotent.
|
|
201
|
+
|
|
202
|
+
Sends a best-effort ``shutdown`` request and ``exit`` notification with
|
|
203
|
+
a short deadline, then terminates (and, if needed, kills) the process.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
timeout: Maximum seconds to wait for the ``shutdown`` response.
|
|
207
|
+
"""
|
|
208
|
+
if self._closed:
|
|
209
|
+
return
|
|
210
|
+
self._closed = True
|
|
211
|
+
|
|
212
|
+
if self._proc.poll() is None:
|
|
213
|
+
try:
|
|
214
|
+
rid = self._request("shutdown", None)
|
|
215
|
+
self._await_response(rid, time.monotonic() + timeout)
|
|
216
|
+
except (LspProtocolError, LspTimeoutError):
|
|
217
|
+
pass
|
|
218
|
+
try:
|
|
219
|
+
self._notify("exit", None)
|
|
220
|
+
except LspProtocolError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
self._terminate()
|
|
224
|
+
|
|
225
|
+
# -- internals ---------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _handshake(self) -> None:
|
|
228
|
+
"""Perform the ``initialize`` / ``initialized`` LSP handshake."""
|
|
229
|
+
rid = self._request(
|
|
230
|
+
"initialize",
|
|
231
|
+
{
|
|
232
|
+
"processId": os.getpid(),
|
|
233
|
+
"rootUri": self._project_path.as_uri(),
|
|
234
|
+
"capabilities": {
|
|
235
|
+
"textDocument": {
|
|
236
|
+
"synchronization": {
|
|
237
|
+
"didSave": True,
|
|
238
|
+
"dynamicRegistration": False,
|
|
239
|
+
},
|
|
240
|
+
"publishDiagnostics": {"relatedInformation": True},
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
self._await_response(rid, time.monotonic() + _HANDSHAKE_TIMEOUT)
|
|
246
|
+
self._notify("initialized", {})
|
|
247
|
+
|
|
248
|
+
def _terminate(self) -> None:
|
|
249
|
+
"""Terminate the process: SIGTERM, then SIGKILL after a short grace."""
|
|
250
|
+
if self._proc.poll() is not None:
|
|
251
|
+
return
|
|
252
|
+
try:
|
|
253
|
+
self._proc.terminate()
|
|
254
|
+
self._proc.wait(timeout=5.0)
|
|
255
|
+
except subprocess.TimeoutExpired:
|
|
256
|
+
self._proc.kill()
|
|
257
|
+
try:
|
|
258
|
+
self._proc.wait(timeout=5.0)
|
|
259
|
+
except subprocess.TimeoutExpired:
|
|
260
|
+
pass
|
|
261
|
+
except ProcessLookupError:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def _ensure_alive(self) -> None:
|
|
265
|
+
"""Raise if the worker is closed or the process has exited."""
|
|
266
|
+
if self._closed:
|
|
267
|
+
raise LspProtocolError("worker has been shut down")
|
|
268
|
+
if self._proc.poll() is not None:
|
|
269
|
+
raise LspProtocolError("Lean server process has exited")
|
|
270
|
+
|
|
271
|
+
def _next_id(self) -> int:
|
|
272
|
+
self._id += 1
|
|
273
|
+
return self._id
|
|
274
|
+
|
|
275
|
+
def _send(self, obj: dict) -> None:
|
|
276
|
+
"""Frame and write a JSON-RPC message to the server's stdin."""
|
|
277
|
+
if self._proc.poll() is not None:
|
|
278
|
+
raise LspProtocolError("Lean server process has exited")
|
|
279
|
+
data = json.dumps(obj).encode("utf-8")
|
|
280
|
+
header = f"Content-Length: {len(data)}\r\n\r\n".encode("ascii")
|
|
281
|
+
try:
|
|
282
|
+
self._proc.stdin.write(header + data)
|
|
283
|
+
self._proc.stdin.flush()
|
|
284
|
+
except (BrokenPipeError, OSError) as exc:
|
|
285
|
+
raise LspProtocolError(f"failed to write to Lean server: {exc}")
|
|
286
|
+
|
|
287
|
+
def _request(self, method: str, params: object) -> int:
|
|
288
|
+
"""Send a JSON-RPC request and return its id."""
|
|
289
|
+
rid = self._next_id()
|
|
290
|
+
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
|
291
|
+
return rid
|
|
292
|
+
|
|
293
|
+
def _notify(self, method: str, params: object) -> None:
|
|
294
|
+
"""Send a JSON-RPC notification (no id, no response expected)."""
|
|
295
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
296
|
+
|
|
297
|
+
def _respond_null(self, rid: object) -> None:
|
|
298
|
+
"""Answer a server-to-client request with a null result (politeness)."""
|
|
299
|
+
self._send({"jsonrpc": "2.0", "id": rid, "result": None})
|
|
300
|
+
|
|
301
|
+
def _await_response(self, rid: int, deadline: float) -> object:
|
|
302
|
+
"""Read and dispatch messages until the response for ``rid`` arrives.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
rid: The request id to wait for.
|
|
306
|
+
deadline: ``time.monotonic()`` value past which to give up.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
The ``result`` field of the matching response.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
LspProtocolError: On a dead process, malformed frame, or error
|
|
313
|
+
response.
|
|
314
|
+
LspTimeoutError: If the response does not arrive by ``deadline``.
|
|
315
|
+
"""
|
|
316
|
+
while True:
|
|
317
|
+
msg = self._read_message(deadline)
|
|
318
|
+
if "method" in msg:
|
|
319
|
+
if "id" in msg:
|
|
320
|
+
# Server-to-client request (e.g. semanticTokens/refresh):
|
|
321
|
+
# answer politely and keep waiting.
|
|
322
|
+
self._respond_null(msg["id"])
|
|
323
|
+
else:
|
|
324
|
+
self._handle_notification(msg)
|
|
325
|
+
continue
|
|
326
|
+
if msg.get("id") != rid:
|
|
327
|
+
# A response to some other (already abandoned) request.
|
|
328
|
+
continue
|
|
329
|
+
if "error" in msg:
|
|
330
|
+
raise LspProtocolError(f"LSP error response: {msg['error']}")
|
|
331
|
+
return msg.get("result")
|
|
332
|
+
|
|
333
|
+
def _handle_notification(self, msg: dict) -> None:
|
|
334
|
+
"""Store publishDiagnostics payloads; ignore all other notifications."""
|
|
335
|
+
if msg.get("method") != "textDocument/publishDiagnostics":
|
|
336
|
+
return
|
|
337
|
+
params = msg.get("params", {})
|
|
338
|
+
uri = params.get("uri")
|
|
339
|
+
if uri is None:
|
|
340
|
+
return
|
|
341
|
+
version = params.get("version")
|
|
342
|
+
diagnostics = params.get("diagnostics", [])
|
|
343
|
+
existing = self._diagnostics.get(uri)
|
|
344
|
+
# Keep the latest payload for the uri: overwrite unless this one is
|
|
345
|
+
# tagged with a strictly older version.
|
|
346
|
+
if (
|
|
347
|
+
existing is None
|
|
348
|
+
or version is None
|
|
349
|
+
or existing["version"] is None
|
|
350
|
+
or version >= existing["version"]
|
|
351
|
+
):
|
|
352
|
+
self._diagnostics[uri] = {
|
|
353
|
+
"version": version,
|
|
354
|
+
"diagnostics": diagnostics,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
def _read_message(self, deadline: float) -> dict:
|
|
358
|
+
"""Read one complete JSON-RPC message before ``deadline``.
|
|
359
|
+
|
|
360
|
+
Accumulates bytes from the server's stdout into an internal buffer and
|
|
361
|
+
parses framed messages out of it.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
LspProtocolError: If the process exits or a frame is malformed.
|
|
365
|
+
LspTimeoutError: If no complete message arrives by ``deadline``.
|
|
366
|
+
"""
|
|
367
|
+
while True:
|
|
368
|
+
frame = self._try_parse_frame()
|
|
369
|
+
if frame is not None:
|
|
370
|
+
return frame
|
|
371
|
+
|
|
372
|
+
remaining = deadline - time.monotonic()
|
|
373
|
+
if remaining <= 0:
|
|
374
|
+
raise LspTimeoutError("timed out waiting for LSP message")
|
|
375
|
+
|
|
376
|
+
readable, _, _ = select.select([self._proc.stdout], [], [], remaining)
|
|
377
|
+
if not readable:
|
|
378
|
+
if self._proc.poll() is not None:
|
|
379
|
+
raise LspProtocolError("Lean server process has exited")
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
chunk = self._proc.stdout.read(65536)
|
|
383
|
+
if not chunk:
|
|
384
|
+
raise LspProtocolError("Lean server closed its output stream")
|
|
385
|
+
self._buf += chunk
|
|
386
|
+
|
|
387
|
+
def _try_parse_frame(self) -> dict | None:
|
|
388
|
+
"""Parse one framed message from the buffer, or return None if partial.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
LspProtocolError: If the header lacks Content-Length or the body is
|
|
392
|
+
not valid JSON.
|
|
393
|
+
"""
|
|
394
|
+
if b"\r\n\r\n" not in self._buf:
|
|
395
|
+
return None
|
|
396
|
+
head, _, rest = self._buf.partition(b"\r\n\r\n")
|
|
397
|
+
|
|
398
|
+
length = None
|
|
399
|
+
for line in head.split(b"\r\n"):
|
|
400
|
+
if line.lower().startswith(b"content-length:"):
|
|
401
|
+
length = int(line.split(b":", 1)[1].strip())
|
|
402
|
+
if length is None:
|
|
403
|
+
raise LspProtocolError(f"missing Content-Length in header: {head!r}")
|
|
404
|
+
|
|
405
|
+
if len(rest) < length:
|
|
406
|
+
return None
|
|
407
|
+
body = rest[:length]
|
|
408
|
+
self._buf = rest[length:]
|
|
409
|
+
try:
|
|
410
|
+
return json.loads(body.decode("utf-8"))
|
|
411
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
412
|
+
raise LspProtocolError(f"malformed LSP message body: {exc}")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Warm Lean LSP worker registry.
|
|
3
|
+
|
|
4
|
+
Keeps one persistent ``lake serve`` worker per project so that re-checking an
|
|
5
|
+
edited file reuses the in-memory import environment instead of re-loading all
|
|
6
|
+
``.olean`` files in a fresh process (~0.2 s instead of 20-120 s for
|
|
7
|
+
Mathlib-scale imports; the speedup applies when the file's import header is
|
|
8
|
+
unchanged between calls).
|
|
9
|
+
|
|
10
|
+
Lifecycle safeguards:
|
|
11
|
+
- Workers are spawned under the watchdog wrapper (see ``watchdog.py``), so
|
|
12
|
+
they die even if LeanBack is SIGKILLed.
|
|
13
|
+
- A worker is invalidated when the project configuration changes
|
|
14
|
+
(lean-toolchain, lakefile, lake-manifest mtimes).
|
|
15
|
+
- An idle worker is shut down after ``IDLE_TIMEOUT`` seconds (workers hold
|
|
16
|
+
the imported environment in RAM — several GB for full Mathlib).
|
|
17
|
+
- All workers are shut down atexit.
|
|
18
|
+
|
|
19
|
+
The warm path is POSIX-only and can be disabled with LEANBACK_NO_WARM=1;
|
|
20
|
+
callers must fall back to the cold ``lake env lean`` path whenever
|
|
21
|
+
``check_file_warm`` returns None.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import atexit
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from leanback.common.lsp_client import (
|
|
32
|
+
LeanLspWorker,
|
|
33
|
+
LspProtocolError,
|
|
34
|
+
LspTimeoutError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
# Seconds a worker may sit unused before the sweeper shuts it down.
|
|
41
|
+
IDLE_TIMEOUT = 600.0
|
|
42
|
+
|
|
43
|
+
# Seconds between sweeper runs.
|
|
44
|
+
SWEEP_INTERVAL = 60.0
|
|
45
|
+
|
|
46
|
+
# Project files whose modification invalidates a warm worker.
|
|
47
|
+
_CONFIG_FILES = (
|
|
48
|
+
"lean-toolchain",
|
|
49
|
+
"lakefile.lean",
|
|
50
|
+
"lakefile.toml",
|
|
51
|
+
"lake-manifest.json",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def warm_enabled() -> bool:
|
|
56
|
+
"""Whether the warm LSP path may be used on this platform/configuration."""
|
|
57
|
+
return os.name == "posix" and os.environ.get("LEANBACK_NO_WARM") != "1"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _config_snapshot(project_path: Path) -> tuple:
|
|
61
|
+
"""Snapshot mtimes of the files that invalidate a warm worker."""
|
|
62
|
+
snapshot = []
|
|
63
|
+
for name in _CONFIG_FILES:
|
|
64
|
+
path = project_path / name
|
|
65
|
+
try:
|
|
66
|
+
snapshot.append((name, path.stat().st_mtime_ns))
|
|
67
|
+
except OSError:
|
|
68
|
+
snapshot.append((name, None))
|
|
69
|
+
return tuple(snapshot)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _WorkerRegistry:
|
|
73
|
+
"""Registry of warm workers, keyed by resolved project path."""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
self._workers: dict[Path, tuple[LeanLspWorker, tuple]] = {}
|
|
78
|
+
self._project_locks: dict[Path, threading.Lock] = {}
|
|
79
|
+
self._sweeper: threading.Thread | None = None
|
|
80
|
+
|
|
81
|
+
def project_lock(self, project_path: Path) -> threading.Lock:
|
|
82
|
+
"""Per-project lock serializing warm checks (the worker is single-threaded)."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
return self._project_locks.setdefault(project_path, threading.Lock())
|
|
85
|
+
|
|
86
|
+
def get(self, project_path: Path) -> LeanLspWorker:
|
|
87
|
+
"""Return a live, non-stale worker for the project, spawning if needed."""
|
|
88
|
+
snapshot = _config_snapshot(project_path)
|
|
89
|
+
with self._lock:
|
|
90
|
+
entry = self._workers.get(project_path)
|
|
91
|
+
if entry is not None:
|
|
92
|
+
worker, spawn_snapshot = entry
|
|
93
|
+
if worker.alive and spawn_snapshot == snapshot:
|
|
94
|
+
return worker
|
|
95
|
+
del self._workers[project_path]
|
|
96
|
+
else:
|
|
97
|
+
worker = None
|
|
98
|
+
# Shut down the stale/dead worker outside the registry lock.
|
|
99
|
+
if worker is not None:
|
|
100
|
+
worker.shutdown()
|
|
101
|
+
logger.info("Respawning warm worker for %s (stale or dead)", project_path)
|
|
102
|
+
new_worker = LeanLspWorker(project_path)
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._workers[project_path] = (new_worker, snapshot)
|
|
105
|
+
self._ensure_sweeper()
|
|
106
|
+
return new_worker
|
|
107
|
+
|
|
108
|
+
def discard(self, project_path: Path) -> None:
|
|
109
|
+
"""Shut down and forget the project's worker (if any)."""
|
|
110
|
+
with self._lock:
|
|
111
|
+
entry = self._workers.pop(project_path, None)
|
|
112
|
+
if entry is not None:
|
|
113
|
+
entry[0].shutdown()
|
|
114
|
+
|
|
115
|
+
def shutdown_all(self) -> None:
|
|
116
|
+
"""Shut down every worker. Registered atexit."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
entries = list(self._workers.values())
|
|
119
|
+
self._workers.clear()
|
|
120
|
+
for worker, _ in entries:
|
|
121
|
+
worker.shutdown()
|
|
122
|
+
|
|
123
|
+
def _ensure_sweeper(self) -> None:
|
|
124
|
+
# Caller holds self._lock.
|
|
125
|
+
if self._sweeper is None or not self._sweeper.is_alive():
|
|
126
|
+
self._sweeper = threading.Thread(
|
|
127
|
+
target=self._sweep_loop, name="leanback-warm-sweeper", daemon=True
|
|
128
|
+
)
|
|
129
|
+
self._sweeper.start()
|
|
130
|
+
|
|
131
|
+
def _sweep_loop(self) -> None:
|
|
132
|
+
while True:
|
|
133
|
+
time.sleep(SWEEP_INTERVAL)
|
|
134
|
+
now = time.monotonic()
|
|
135
|
+
with self._lock:
|
|
136
|
+
idle = [
|
|
137
|
+
path
|
|
138
|
+
for path, (worker, _) in self._workers.items()
|
|
139
|
+
if now - worker.last_used > IDLE_TIMEOUT
|
|
140
|
+
]
|
|
141
|
+
entries = [self._workers.pop(path) for path in idle]
|
|
142
|
+
for worker, _ in entries:
|
|
143
|
+
logger.info("Shutting down idle warm worker")
|
|
144
|
+
worker.shutdown()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_registry = _WorkerRegistry()
|
|
148
|
+
atexit.register(_registry.shutdown_all)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_file_warm(
|
|
152
|
+
project_path: Path, file_path: Path, timeout: float = 600.0
|
|
153
|
+
) -> list[dict] | None:
|
|
154
|
+
"""Check a file via the warm LSP worker.
|
|
155
|
+
|
|
156
|
+
Returns the LSP diagnostics list on success, or None when the warm path
|
|
157
|
+
is unavailable or failed — the caller must then fall back to the cold
|
|
158
|
+
path. On failure the project's worker is discarded, so the next call
|
|
159
|
+
starts fresh.
|
|
160
|
+
"""
|
|
161
|
+
if not warm_enabled():
|
|
162
|
+
return None
|
|
163
|
+
project_path = project_path.resolve()
|
|
164
|
+
with _registry.project_lock(project_path):
|
|
165
|
+
try:
|
|
166
|
+
worker = _registry.get(project_path)
|
|
167
|
+
return worker.check_file(file_path, timeout=timeout)
|
|
168
|
+
except (LspTimeoutError, LspProtocolError, OSError) as exc:
|
|
169
|
+
logger.warning("Warm check failed (%s); falling back to cold path", exc)
|
|
170
|
+
_registry.discard(project_path)
|
|
171
|
+
return None
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Watchdog wrapper that kills a child process when its grandparent dies.
|
|
3
|
+
|
|
4
|
+
LeanBack spawns long-lived ``lake serve`` workers. If LeanBack itself is
|
|
5
|
+
SIGKILLed, those workers would be orphaned and keep multiple GB of RAM
|
|
6
|
+
resident. To defend against this, LeanBack does not spawn the worker
|
|
7
|
+
directly; it spawns this watchdog instead::
|
|
8
|
+
|
|
9
|
+
python -m leanback.common.watchdog <parent_pid> -- <cmd> [args...]
|
|
10
|
+
|
|
11
|
+
The watchdog execs the command as its own child, passing its stdin, stdout,
|
|
12
|
+
and stderr straight through (so the grandparent's LSP pipes reach the worker
|
|
13
|
+
unchanged). It then polls ``<parent_pid>`` and, when that process disappears,
|
|
14
|
+
terminates the child (SIGTERM, then SIGKILL after a grace period).
|
|
15
|
+
|
|
16
|
+
POSIX-only. Stdlib-only, no third-party imports.
|
|
17
|
+
|
|
18
|
+
Interface notes:
|
|
19
|
+
- The watchdog NEVER writes to stdout — that stream belongs to the child's
|
|
20
|
+
LSP protocol and any extra byte would corrupt it. All diagnostics go to
|
|
21
|
+
stderr.
|
|
22
|
+
- At startup the watchdog prints ``watchdog: child pid <N>`` to stderr.
|
|
23
|
+
This is a stable, documented interface: callers (and tests) may parse it
|
|
24
|
+
to learn the child's PID.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# How often (in seconds) to poll the parent PID. Overridable via
|
|
35
|
+
# --poll-interval so tests can run quickly.
|
|
36
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
37
|
+
|
|
38
|
+
# How long (in seconds) to wait for the child to exit after SIGTERM before
|
|
39
|
+
# escalating to SIGKILL.
|
|
40
|
+
TERM_GRACE = 5.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _terminate_child(proc: subprocess.Popen, grace: float = TERM_GRACE) -> None:
|
|
44
|
+
"""Terminate the child: SIGTERM, wait up to ``grace`` seconds, then SIGKILL.
|
|
45
|
+
|
|
46
|
+
Safe to call when the child has already exited.
|
|
47
|
+
"""
|
|
48
|
+
if proc.poll() is not None:
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
proc.terminate()
|
|
52
|
+
except ProcessLookupError:
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
proc.wait(timeout=grace)
|
|
56
|
+
return
|
|
57
|
+
except subprocess.TimeoutExpired:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
proc.kill()
|
|
61
|
+
except ProcessLookupError:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
proc.wait(timeout=grace)
|
|
65
|
+
except subprocess.TimeoutExpired:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_args(argv: list[str]) -> tuple[int, float, list[str]]:
|
|
70
|
+
"""Parse ``<parent_pid> [--poll-interval S] -- <cmd> [args...]``.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (parent_pid, poll_interval, command).
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If the arguments are malformed.
|
|
77
|
+
"""
|
|
78
|
+
poll_interval = DEFAULT_POLL_INTERVAL
|
|
79
|
+
parent_pid: int | None = None
|
|
80
|
+
i = 0
|
|
81
|
+
while i < len(argv):
|
|
82
|
+
arg = argv[i]
|
|
83
|
+
if arg == "--":
|
|
84
|
+
i += 1
|
|
85
|
+
break
|
|
86
|
+
elif arg == "--poll-interval":
|
|
87
|
+
if i + 1 >= len(argv):
|
|
88
|
+
raise ValueError("--poll-interval requires a value")
|
|
89
|
+
poll_interval = float(argv[i + 1])
|
|
90
|
+
i += 2
|
|
91
|
+
elif arg.startswith("--poll-interval="):
|
|
92
|
+
poll_interval = float(arg.split("=", 1)[1])
|
|
93
|
+
i += 1
|
|
94
|
+
elif parent_pid is None:
|
|
95
|
+
parent_pid = int(arg)
|
|
96
|
+
i += 1
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"unexpected argument before '--': {arg!r}")
|
|
99
|
+
|
|
100
|
+
command = argv[i:]
|
|
101
|
+
if parent_pid is None:
|
|
102
|
+
raise ValueError("missing parent_pid")
|
|
103
|
+
if not command:
|
|
104
|
+
raise ValueError("missing command after '--'")
|
|
105
|
+
return parent_pid, poll_interval, command
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str]) -> int:
|
|
109
|
+
"""Run the watchdog.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
argv: Arguments after the program name, i.e.
|
|
113
|
+
``[<parent_pid>, [--poll-interval, S,] '--', <cmd>, args...]``.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The child's return code if it exits on its own or via a forwarded
|
|
117
|
+
signal; ``1`` if the parent died first.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
parent_pid, poll_interval, command = _parse_args(argv)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
print(f"watchdog: {exc}", file=sys.stderr)
|
|
123
|
+
print(
|
|
124
|
+
"watchdog: usage: python -m leanback.common.watchdog "
|
|
125
|
+
"<parent_pid> [--poll-interval S] -- <cmd> [args...]",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
return 2
|
|
129
|
+
|
|
130
|
+
# Spawn the child, inheriting our stdin/stdout/stderr. We deliberately do
|
|
131
|
+
# NOT create pipes: the grandparent's LSP pipes must pass straight through.
|
|
132
|
+
proc = subprocess.Popen(command)
|
|
133
|
+
print(f"watchdog: child pid {proc.pid}", file=sys.stderr, flush=True)
|
|
134
|
+
|
|
135
|
+
# Forward SIGTERM/SIGINT that we receive to the child, then exit with its
|
|
136
|
+
# return code. A small flag records that we were signalled.
|
|
137
|
+
signalled = False
|
|
138
|
+
|
|
139
|
+
def _forward(signum: int, _frame: object) -> None:
|
|
140
|
+
nonlocal signalled
|
|
141
|
+
signalled = True
|
|
142
|
+
|
|
143
|
+
signal.signal(signal.SIGTERM, _forward)
|
|
144
|
+
signal.signal(signal.SIGINT, _forward)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
while True:
|
|
148
|
+
# Did the child exit on its own?
|
|
149
|
+
rc = proc.poll()
|
|
150
|
+
if rc is not None:
|
|
151
|
+
return rc
|
|
152
|
+
|
|
153
|
+
# Were we asked to shut down?
|
|
154
|
+
if signalled:
|
|
155
|
+
_terminate_child(proc)
|
|
156
|
+
rc = proc.poll()
|
|
157
|
+
return rc if rc is not None else 1
|
|
158
|
+
|
|
159
|
+
# Is the parent still alive?
|
|
160
|
+
try:
|
|
161
|
+
os.kill(parent_pid, 0)
|
|
162
|
+
except ProcessLookupError:
|
|
163
|
+
print(
|
|
164
|
+
f"watchdog: parent {parent_pid} gone, killing child {proc.pid}",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
flush=True,
|
|
167
|
+
)
|
|
168
|
+
_terminate_child(proc)
|
|
169
|
+
return 1
|
|
170
|
+
except PermissionError:
|
|
171
|
+
# Parent exists but is owned by another user — still alive.
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
time.sleep(poll_interval)
|
|
175
|
+
finally:
|
|
176
|
+
# Belt and suspenders: never leave the child running if we unwind.
|
|
177
|
+
if proc.poll() is None:
|
|
178
|
+
_terminate_child(proc)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -55,8 +55,10 @@ logger = logging.getLogger(__name__)
|
|
|
55
55
|
mcp = FastMCP(
|
|
56
56
|
"LeanBack",
|
|
57
57
|
instructions="""\
|
|
58
|
-
LeanBack provides tools for Lean 4 theorem proving. All tools are read-only
|
|
59
|
-
modify files. Use your own file tools to create/edit .lean files, then use
|
|
58
|
+
LeanBack provides tools for Lean 4 theorem proving. All tools are read-only for source files — \
|
|
59
|
+
they never modify your .lean files. Use your own file tools to create/edit .lean files, then use \
|
|
60
|
+
LeanBack to verify them. (Exception: eval with build=true compiles project modules, writing build \
|
|
61
|
+
artifacts to .lake/.)
|
|
60
62
|
|
|
61
63
|
## Getting Started
|
|
62
64
|
1. Call project_info(project="/path/to/project") to check if a project is ready
|
|
@@ -84,7 +86,9 @@ modify files. Use your own file tools to create/edit .lean files, then use LeanB
|
|
|
84
86
|
- All tools require 'project' as an absolute path (e.g., "/home/user/lean/myproject")
|
|
85
87
|
- check has NO mathlib flag — use imports=["Mathlib.Tactic"] instead
|
|
86
88
|
- goals, prove, and eval with mathlib=True all import Mathlib.Tactic (simp, ring, omega, linarith, norm_num, decide, aesop)
|
|
89
|
+
- eval can import modules of the project itself (imports=["MyProject.Definitions"]); pass build=true so they are compiled first
|
|
87
90
|
- Tool calls typically take 5-30 seconds. The first call may be slower (Lean server startup).
|
|
91
|
+
- Iterating on a file? check(file=...) is the fast loop: repeated checks of the same file reuse a warm Lean server (seconds instead of a fresh import each time). Keep the import lines unchanged between edits to stay warm.
|
|
88
92
|
""",
|
|
89
93
|
)
|
|
90
94
|
|
|
@@ -446,7 +450,7 @@ def check(
|
|
|
446
450
|
|
|
447
451
|
Parameters:
|
|
448
452
|
- project: Absolute path to the Lean project directory (e.g., "/home/user/lean/myproject")
|
|
449
|
-
- file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean")
|
|
453
|
+
- file: Path to a .lean file within the project (e.g., "Basic.lean", "src/Logic.lean"). Repeated checks of the same file reuse a warm Lean server: the first check pays the import cost (~20-30s with Mathlib), subsequent checks after edits take seconds as long as the file's import lines are unchanged.
|
|
450
454
|
- check_all: Check each file individually for detailed error reporting (slower but more thorough)
|
|
451
455
|
- expr: A Lean expression or command to check (e.g., "2 + 2 = 4", "Nat.add", "#eval 1 + 1"). Bare expressions are auto-wrapped in #check.
|
|
452
456
|
- imports: List of modules to import when checking expressions (e.g., ["Mathlib.Tactic"]). First Mathlib import takes ~20-30s.
|
|
@@ -574,25 +578,39 @@ def check(
|
|
|
574
578
|
}
|
|
575
579
|
return _json(result)
|
|
576
580
|
|
|
577
|
-
success, errors = check_file(
|
|
578
|
-
file_path=file_path,
|
|
579
|
-
project_path=project_path,
|
|
580
|
-
console=console,
|
|
581
|
-
)
|
|
582
|
-
|
|
583
|
-
error_count = sum(1 for e in errors if e.severity == "error")
|
|
584
|
-
warning_count = sum(1 for e in errors if e.severity == "warning")
|
|
585
|
-
info_count = sum(1 for e in errors if e.severity == "info")
|
|
586
|
-
|
|
587
581
|
try:
|
|
588
582
|
display_path = file_path.relative_to(project_path)
|
|
589
583
|
except ValueError:
|
|
590
584
|
display_path = file
|
|
591
585
|
|
|
586
|
+
# Warm path: a persistent per-project Lean LSP worker makes
|
|
587
|
+
# repeated checks of the same file fast (the import environment
|
|
588
|
+
# stays in memory). Falls back to the cold path on any failure.
|
|
589
|
+
from leanback.common.errors import lsp_diagnostics_to_errors
|
|
590
|
+
from leanback.common.warm import check_file_warm
|
|
591
|
+
|
|
592
|
+
engine = "cold"
|
|
593
|
+
warm_diags = check_file_warm(project_path, file_path)
|
|
594
|
+
if warm_diags is not None:
|
|
595
|
+
errors = lsp_diagnostics_to_errors(warm_diags, str(display_path))
|
|
596
|
+
success = not any(e.severity == "error" for e in errors)
|
|
597
|
+
engine = "warm"
|
|
598
|
+
else:
|
|
599
|
+
success, errors = check_file(
|
|
600
|
+
file_path=file_path,
|
|
601
|
+
project_path=project_path,
|
|
602
|
+
console=console,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
error_count = sum(1 for e in errors if e.severity == "error")
|
|
606
|
+
warning_count = sum(1 for e in errors if e.severity == "warning")
|
|
607
|
+
info_count = sum(1 for e in errors if e.severity == "info")
|
|
608
|
+
|
|
592
609
|
result = {
|
|
593
610
|
"success": success,
|
|
594
611
|
"type": "file_check",
|
|
595
612
|
"file_path": str(display_path),
|
|
613
|
+
"engine": engine,
|
|
596
614
|
"messages": _serialize_errors(errors),
|
|
597
615
|
"statistics": {
|
|
598
616
|
"total_errors": error_count,
|
|
@@ -918,6 +936,7 @@ def eval(
|
|
|
918
936
|
file: str | None = None,
|
|
919
937
|
imports: list[str] | None = None,
|
|
920
938
|
mathlib: bool = False,
|
|
939
|
+
build: bool = False,
|
|
921
940
|
) -> str:
|
|
922
941
|
"""Execute Lean expressions and see their results.
|
|
923
942
|
|
|
@@ -930,6 +949,7 @@ def eval(
|
|
|
930
949
|
- file: Path to a .lean file to execute instead of inline code
|
|
931
950
|
- imports: Additional modules to import (e.g., ["Mathlib.Data.Finset.Basic"])
|
|
932
951
|
- mathlib: Import Mathlib.Tactic for all standard tactics (simp, ring, omega, linarith, norm_num, decide, aesop, etc.). First call takes ~8-11s for import.
|
|
952
|
+
- build: Compile imported project modules first (runs `lake build` on them). Use when importing a module of THIS project (e.g., imports=["MyProject.Definitions"]) that may not be compiled yet — otherwise the import fails with "object file does not exist". Adds build time on first use; near-instant when already up to date. Note: this writes build artifacts to the project's .lake directory.
|
|
933
953
|
|
|
934
954
|
Use either 'code' OR 'file', not both. The tool can handle multi-line code.
|
|
935
955
|
|
|
@@ -1016,6 +1036,30 @@ def eval(
|
|
|
1016
1036
|
if "Mathlib.Tactic" not in resolved_imports:
|
|
1017
1037
|
resolved_imports.insert(0, "Mathlib.Tactic")
|
|
1018
1038
|
|
|
1039
|
+
built_modules: list[str] = []
|
|
1040
|
+
if build:
|
|
1041
|
+
from leanback.common.env import build_modules, find_local_modules
|
|
1042
|
+
|
|
1043
|
+
# Consider both the imports parameter and import lines inside
|
|
1044
|
+
# the code itself (relevant when executing a file).
|
|
1045
|
+
candidates = list(resolved_imports)
|
|
1046
|
+
candidates += re.findall(
|
|
1047
|
+
r"^import\s+([\w.]+)", code_to_execute, re.MULTILINE
|
|
1048
|
+
)
|
|
1049
|
+
local_modules = find_local_modules(project_path, candidates)
|
|
1050
|
+
build_ok, build_output = build_modules(project_path, local_modules)
|
|
1051
|
+
if not build_ok:
|
|
1052
|
+
return _json(
|
|
1053
|
+
{
|
|
1054
|
+
"success": False,
|
|
1055
|
+
"error_code": "BUILD_FAILED",
|
|
1056
|
+
"message": f"lake build failed for modules {local_modules}. "
|
|
1057
|
+
"Fix the errors in those modules first.",
|
|
1058
|
+
"build_output": build_output[:2000],
|
|
1059
|
+
}
|
|
1060
|
+
)
|
|
1061
|
+
built_modules = local_modules
|
|
1062
|
+
|
|
1019
1063
|
success, errors = check_lean_expr(
|
|
1020
1064
|
expr=code_to_execute,
|
|
1021
1065
|
imports=resolved_imports,
|
|
@@ -1023,17 +1067,25 @@ def eval(
|
|
|
1023
1067
|
console=console,
|
|
1024
1068
|
)
|
|
1025
1069
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1070
|
+
result = {
|
|
1071
|
+
"success": success,
|
|
1072
|
+
"type": "code_execution",
|
|
1073
|
+
"code": code_to_execute,
|
|
1074
|
+
"imports": resolved_imports,
|
|
1075
|
+
"messages": _serialize_errors(
|
|
1076
|
+
errors, sanitize_paths=True, source_file=file
|
|
1077
|
+
),
|
|
1078
|
+
}
|
|
1079
|
+
if built_modules:
|
|
1080
|
+
result["built"] = built_modules
|
|
1081
|
+
if not build and any(
|
|
1082
|
+
".olean" in e.message and "does not exist" in e.message for e in errors
|
|
1083
|
+
):
|
|
1084
|
+
result["hint"] = (
|
|
1085
|
+
"An imported module is not compiled. "
|
|
1086
|
+
"Retry with build=true to compile project modules automatically."
|
|
1087
|
+
)
|
|
1088
|
+
return _json(result)
|
|
1037
1089
|
|
|
1038
1090
|
except Exception as e:
|
|
1039
1091
|
return _json(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|