rocq-solid-mcp 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,980 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import codecs
5
+ import json
6
+ import os
7
+ import signal
8
+ import shlex
9
+ import shutil
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from .config import rocq_lsp_command, rocq_lsp_settings
16
+ from .notifications import NotificationStore
17
+
18
+
19
+ TimeoutKind = Literal["request", "command", "after_error"]
20
+ STDERR_CHUNK_SIZE = 64 * 1024
21
+
22
+
23
+ class RocqLspResponseError(RuntimeError):
24
+ def __init__(self, error: dict[str, Any]) -> None:
25
+ self.error = error
26
+ super().__init__(str(error))
27
+
28
+
29
+ class RocqLspHardTimeout(TimeoutError):
30
+ def __init__(
31
+ self,
32
+ message: str,
33
+ *,
34
+ timeout_kind: TimeoutKind = "request",
35
+ timed_out_range: dict[str, Any] | None = None,
36
+ ) -> None:
37
+ self.timeout_kind = timeout_kind
38
+ self.timed_out_range = timed_out_range
39
+ super().__init__(message)
40
+
41
+
42
+ class RocqLspProcessExited(RuntimeError):
43
+ pass
44
+
45
+
46
+ class RocqLspUnavailableError(RuntimeError):
47
+ pass
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class GetDocumentResult:
52
+ document: dict[str, Any]
53
+ timed_out: bool = False
54
+ timeout_kind: TimeoutKind | None = None
55
+ timed_out_range: dict[str, Any] | None = None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class WatchdogTimeout:
60
+ """The single, immutable outcome a watchdog publishes when it cancels."""
61
+
62
+ kind: TimeoutKind
63
+ seconds: float
64
+ timed_out_range: dict[str, Any] | None
65
+
66
+
67
+ def language_id_for_path(path: Path) -> str:
68
+ name = path.name
69
+ if name.endswith(".mv"):
70
+ return "markdown"
71
+ if name.endswith(".lv") or name.endswith(".v.tex"):
72
+ return "latex"
73
+ return "rocq"
74
+
75
+
76
+ def _end_of_content_position(content: str | None) -> tuple[int, int] | None:
77
+ """0-based (line, character) just past the last non-whitespace character.
78
+
79
+ A sentence whose range ends at or beyond this position is the document's
80
+ final sentence: only whitespace follows it. Precomputing it keeps the
81
+ watchdog's final-sentence check O(1) per sentence instead of re-splitting
82
+ the document content on every wake-up.
83
+ """
84
+ if content is None:
85
+ return None
86
+ stripped = content.rstrip()
87
+ if not stripped:
88
+ return (0, 0)
89
+ lines = stripped.splitlines()
90
+ return (len(lines) - 1, len(lines[-1]))
91
+
92
+
93
+ class RocqLspClient:
94
+ def __init__(
95
+ self,
96
+ *,
97
+ binary: str,
98
+ use_opam: bool,
99
+ workspace: Path,
100
+ max_errors: int,
101
+ request_timeout: float,
102
+ notifications: NotificationStore,
103
+ ) -> None:
104
+ self.command = rocq_lsp_command(binary, use_opam=use_opam)
105
+ self.workspace = workspace
106
+ self.max_errors = max_errors
107
+ self.request_timeout = request_timeout
108
+ self.notifications = notifications
109
+ self.process: asyncio.subprocess.Process | None = None
110
+ self._next_request_id = 1
111
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
112
+ self._reader_task: asyncio.Task[None] | None = None
113
+ self._stderr_task: asyncio.Task[None] | None = None
114
+ self._reader_failure: RocqLspProcessExited | None = None
115
+ self.stderr_lines: list[str] = []
116
+ self.restart_required_after_external_cancellation = False
117
+
118
+ async def start(self) -> None:
119
+ self._ensure_launcher_available()
120
+ self._reader_failure = None
121
+ try:
122
+ self.process = await asyncio.create_subprocess_exec(
123
+ *self.command,
124
+ cwd=self.workspace,
125
+ stdin=asyncio.subprocess.PIPE,
126
+ stdout=asyncio.subprocess.PIPE,
127
+ stderr=asyncio.subprocess.PIPE,
128
+ )
129
+ self._reader_task = asyncio.create_task(self._reader_loop())
130
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
131
+
132
+ await self.request(
133
+ "initialize",
134
+ {
135
+ "processId": os.getpid(),
136
+ "clientInfo": {"name": "rocq-solid-mcp"},
137
+ "rootPath": str(self.workspace),
138
+ "rootUri": self.workspace.as_uri(),
139
+ "workspaceFolders": [
140
+ {"uri": self.workspace.as_uri(), "name": self.workspace.name}
141
+ ],
142
+ "capabilities": {
143
+ "textDocument": {
144
+ "publishDiagnostics": {
145
+ "relatedInformation": True,
146
+ "dataSupport": True,
147
+ }
148
+ },
149
+ "workspace": {"workspaceFolders": True},
150
+ },
151
+ "initializationOptions": rocq_lsp_settings(self.max_errors),
152
+ "trace": "off",
153
+ },
154
+ timeout=self.request_timeout,
155
+ )
156
+ await self.notify("initialized", {})
157
+ except asyncio.CancelledError:
158
+ raise
159
+ except FileNotFoundError as exc:
160
+ program = str(exc.filename or self.command[0])
161
+ raise RocqLspUnavailableError(
162
+ self._command_not_found_message(program)
163
+ ) from exc
164
+ except Exception as exc:
165
+ raise RocqLspUnavailableError(
166
+ await self._startup_failure_message(exc)
167
+ ) from exc
168
+
169
+ async def close(self, *, hard: bool = False) -> None:
170
+ if hard:
171
+ await self.kill_server()
172
+ return
173
+
174
+ with suppress(Exception):
175
+ await self.request("shutdown", None, timeout=2)
176
+ await self.notify("exit", None)
177
+ await self.kill_server()
178
+
179
+ async def kill_server(self) -> None:
180
+ for future in self._pending.values():
181
+ if not future.done():
182
+ future.cancel()
183
+ self._pending.clear()
184
+
185
+ for task in (self._reader_task, self._stderr_task):
186
+ if task is not None:
187
+ task.cancel()
188
+
189
+ process = self.process
190
+ if process is None:
191
+ return
192
+ if process.returncode is None:
193
+ process.terminate()
194
+ try:
195
+ await asyncio.wait_for(process.wait(), timeout=2)
196
+ except TimeoutError:
197
+ process.kill()
198
+ await process.wait()
199
+
200
+ def clear_pending_requests(self) -> None:
201
+ for future in self._pending.values():
202
+ if not future.done():
203
+ future.cancel()
204
+ self._pending.clear()
205
+
206
+ async def notify_change_configuration(self) -> None:
207
+ await self.notify(
208
+ "workspace/didChangeConfiguration",
209
+ {"settings": rocq_lsp_settings(self.max_errors)},
210
+ )
211
+
212
+ async def notify_document_opened(
213
+ self, file_path: Path, content: str, version: int
214
+ ) -> None:
215
+ await self.notify(
216
+ "textDocument/didOpen",
217
+ {
218
+ "textDocument": {
219
+ "uri": file_path.as_uri(),
220
+ "languageId": language_id_for_path(file_path),
221
+ "version": version,
222
+ "text": content,
223
+ }
224
+ },
225
+ )
226
+
227
+ async def notify_document_changed(
228
+ self, file_path: Path, content: str, version: int
229
+ ) -> None:
230
+ await self.notify(
231
+ "textDocument/didChange",
232
+ {
233
+ "textDocument": {"uri": file_path.as_uri(), "version": version},
234
+ "contentChanges": [{"text": content}],
235
+ },
236
+ )
237
+
238
+ async def request_get_document(
239
+ self,
240
+ *,
241
+ uri: str,
242
+ version: int,
243
+ timeout: float | None,
244
+ hard_timeout_grace: float,
245
+ command_timeout: float | None = None,
246
+ timeout_after_error: float | None = None,
247
+ goals: str | None = None,
248
+ content: str | None = None,
249
+ ) -> GetDocumentResult:
250
+ if command_timeout is not None and command_timeout <= 0:
251
+ command_timeout = None
252
+ if timeout_after_error is not None and timeout_after_error <= 0:
253
+ timeout_after_error = None
254
+ request_id = self._reserve_request()
255
+ future = self._pending[request_id]
256
+ self.restart_required_after_external_cancellation = False
257
+ watchdog: _CommandTimeoutWatchdog | None = None
258
+ watchdog_task: asyncio.Task[None] | None = None
259
+ params: dict[str, Any] = {"textDocument": {"uri": uri, "version": version}}
260
+ if goals is not None:
261
+ params["goals"] = goals
262
+ await self._send(
263
+ {
264
+ "jsonrpc": "2.0",
265
+ "id": request_id,
266
+ "method": "coq/getDocument",
267
+ "params": params,
268
+ }
269
+ )
270
+ if command_timeout is not None or timeout_after_error is not None:
271
+ watchdog = _CommandTimeoutWatchdog(
272
+ client=self,
273
+ uri=uri,
274
+ version=version,
275
+ request_id=request_id,
276
+ command_timeout=command_timeout,
277
+ timeout_after_error=timeout_after_error,
278
+ content=content,
279
+ )
280
+ watchdog_task = asyncio.create_task(watchdog.run())
281
+
282
+ try:
283
+ if watchdog is None or watchdog_task is None:
284
+ return await self._await_get_document(
285
+ request_id=request_id,
286
+ future=future,
287
+ timeout=timeout,
288
+ hard_timeout_grace=hard_timeout_grace,
289
+ )
290
+
291
+ done, _ = await asyncio.wait(
292
+ {future, watchdog_task},
293
+ timeout=timeout,
294
+ return_when=asyncio.FIRST_COMPLETED,
295
+ )
296
+ if not done:
297
+ await self.notify("$/cancelRequest", {"id": request_id})
298
+ return await self._wait_for_cancelled_get_document(
299
+ request_id=request_id,
300
+ future=future,
301
+ hard_timeout_grace=hard_timeout_grace,
302
+ timeout_kind="request",
303
+ timeout_seconds=timeout,
304
+ timed_out_range=None,
305
+ )
306
+
307
+ if future in done or future.done():
308
+ raw = future.result()
309
+ self._pending.pop(request_id, None)
310
+ if watchdog.timeout is not None and "error" in raw:
311
+ return self._get_document_timeout_result(raw, watchdog.timeout)
312
+ return GetDocumentResult(document=self._result_or_raise(raw))
313
+
314
+ await watchdog_task
315
+ if watchdog.timeout is None:
316
+ # The watchdog stopped without cancelling anything (it is
317
+ # advisory and contains its own failures), so keep waiting
318
+ # for the document under the plain request deadline.
319
+ return await self._await_get_document(
320
+ request_id=request_id,
321
+ future=future,
322
+ timeout=timeout,
323
+ hard_timeout_grace=hard_timeout_grace,
324
+ )
325
+ if future.done():
326
+ raw = future.result()
327
+ self._pending.pop(request_id, None)
328
+ if "error" in raw:
329
+ return self._get_document_timeout_result(raw, watchdog.timeout)
330
+ return GetDocumentResult(document=self._result_or_raise(raw))
331
+ return await self._wait_for_cancelled_get_document(
332
+ request_id=request_id,
333
+ future=future,
334
+ hard_timeout_grace=hard_timeout_grace,
335
+ timeout_kind=watchdog.timeout.kind,
336
+ timeout_seconds=watchdog.timeout.seconds,
337
+ timed_out_range=watchdog.timeout.timed_out_range,
338
+ )
339
+
340
+ except asyncio.CancelledError:
341
+ await self._cancel_lsp_request_after_external_cancellation(
342
+ request_id=request_id,
343
+ future=future,
344
+ hard_timeout_grace=hard_timeout_grace,
345
+ )
346
+ raise
347
+ finally:
348
+ if watchdog_task is not None:
349
+ watchdog_task.cancel()
350
+ with suppress(asyncio.CancelledError):
351
+ await watchdog_task
352
+
353
+ async def _await_get_document(
354
+ self,
355
+ *,
356
+ request_id: int,
357
+ future: asyncio.Future[dict[str, Any]],
358
+ timeout: float | None,
359
+ hard_timeout_grace: float,
360
+ ) -> GetDocumentResult:
361
+ try:
362
+ raw = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
363
+ except TimeoutError:
364
+ await self.notify("$/cancelRequest", {"id": request_id})
365
+ return await self._wait_for_cancelled_get_document(
366
+ request_id=request_id,
367
+ future=future,
368
+ hard_timeout_grace=hard_timeout_grace,
369
+ timeout_kind="request",
370
+ timeout_seconds=timeout,
371
+ timed_out_range=None,
372
+ )
373
+ self._pending.pop(request_id, None)
374
+ return GetDocumentResult(document=self._result_or_raise(raw))
375
+
376
+ def _get_document_timeout_result(
377
+ self, raw: dict[str, Any], watchdog_timeout: WatchdogTimeout
378
+ ) -> GetDocumentResult:
379
+ document = {} if "error" in raw else self._result_or_raise(raw)
380
+ return GetDocumentResult(
381
+ document=document,
382
+ timed_out=True,
383
+ timeout_kind=watchdog_timeout.kind,
384
+ timed_out_range=watchdog_timeout.timed_out_range,
385
+ )
386
+
387
+ async def _wait_for_cancelled_get_document(
388
+ self,
389
+ *,
390
+ request_id: int,
391
+ future: asyncio.Future[dict[str, Any]],
392
+ hard_timeout_grace: float,
393
+ timeout_kind: TimeoutKind,
394
+ timeout_seconds: float,
395
+ timed_out_range: dict[str, Any] | None,
396
+ ) -> GetDocumentResult:
397
+ try:
398
+ raw = await asyncio.wait_for(
399
+ asyncio.shield(future), timeout=hard_timeout_grace
400
+ )
401
+ except asyncio.CancelledError:
402
+ await self._cancel_lsp_request_after_external_cancellation(
403
+ request_id=request_id,
404
+ future=future,
405
+ hard_timeout_grace=hard_timeout_grace,
406
+ )
407
+ raise
408
+ except TimeoutError as exc:
409
+ self._pending.pop(request_id, None)
410
+ future.cancel()
411
+ raise RocqLspHardTimeout(
412
+ f"coq/getDocument {timeout_kind} timed out after "
413
+ f"{timeout_seconds}s and did not settle within "
414
+ f"{hard_timeout_grace}s",
415
+ timeout_kind=timeout_kind,
416
+ timed_out_range=timed_out_range,
417
+ ) from exc
418
+ finally:
419
+ self._pending.pop(request_id, None)
420
+
421
+ document = {} if "error" in raw else self._result_or_raise(raw)
422
+ return GetDocumentResult(
423
+ document=document,
424
+ timed_out=True,
425
+ timeout_kind=timeout_kind,
426
+ timed_out_range=timed_out_range,
427
+ )
428
+
429
+ async def request_goals(
430
+ self,
431
+ *,
432
+ uri: str,
433
+ version: int,
434
+ position: dict[str, int],
435
+ timeout: float | None = None,
436
+ pp_format: str = "Str",
437
+ compact: bool = True,
438
+ mode: str = "Prev",
439
+ ) -> dict[str, Any]:
440
+ return await self.request(
441
+ "proof/goals",
442
+ {
443
+ "textDocument": {"uri": uri, "version": version},
444
+ "position": position,
445
+ "pp_format": pp_format,
446
+ "compact": compact,
447
+ "mode": mode,
448
+ },
449
+ timeout=timeout,
450
+ )
451
+
452
+ async def request_run_at_point(
453
+ self,
454
+ *,
455
+ uri: str,
456
+ version: int,
457
+ position: dict[str, int],
458
+ commands: str,
459
+ timeout: float | None,
460
+ hard_timeout_grace: float,
461
+ ) -> tuple[dict[str, Any], bool]:
462
+ request_id = self._reserve_request()
463
+ future = self._pending[request_id]
464
+ self.restart_required_after_external_cancellation = False
465
+ await self._send(
466
+ {
467
+ "jsonrpc": "2.0",
468
+ "id": request_id,
469
+ "method": "petanque/run_at_point",
470
+ "params": {
471
+ "textDocument": {"uri": uri, "version": version},
472
+ "position": position,
473
+ "command": commands,
474
+ },
475
+ }
476
+ )
477
+
478
+ try:
479
+ raw = await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
480
+ self._pending.pop(request_id, None)
481
+ return self._result_or_raise(raw), False
482
+ except TimeoutError as exc:
483
+ await self.notify("$/cancelRequest", {"id": request_id})
484
+ try:
485
+ raw = await asyncio.wait_for(
486
+ asyncio.shield(future), timeout=hard_timeout_grace
487
+ )
488
+ except asyncio.CancelledError:
489
+ await self._cancel_lsp_request_after_external_cancellation(
490
+ request_id=request_id,
491
+ future=future,
492
+ hard_timeout_grace=hard_timeout_grace,
493
+ )
494
+ raise
495
+ except TimeoutError:
496
+ self._pending.pop(request_id, None)
497
+ future.cancel()
498
+ raise RocqLspHardTimeout(
499
+ f"petanque/run_at_point timed out after {timeout}s and did not "
500
+ f"settle within {hard_timeout_grace}s"
501
+ ) from exc
502
+ finally:
503
+ self._pending.pop(request_id, None)
504
+
505
+ if "error" in raw:
506
+ return {}, True
507
+ return self._result_or_raise(raw), True
508
+ except asyncio.CancelledError:
509
+ await self._cancel_lsp_request_after_external_cancellation(
510
+ request_id=request_id,
511
+ future=future,
512
+ hard_timeout_grace=hard_timeout_grace,
513
+ )
514
+ raise
515
+
516
+ async def request(
517
+ self, method: str, params: Any | None, *, timeout: float | None = None
518
+ ) -> dict[str, Any]:
519
+ request_id = self._reserve_request()
520
+ future = self._pending[request_id]
521
+ await self._send(
522
+ {
523
+ "jsonrpc": "2.0",
524
+ "id": request_id,
525
+ "method": method,
526
+ "params": params,
527
+ }
528
+ )
529
+ try:
530
+ raw = await asyncio.wait_for(
531
+ asyncio.shield(future), timeout=timeout or self.request_timeout
532
+ )
533
+ return self._result_or_raise(raw)
534
+ finally:
535
+ self._pending.pop(request_id, None)
536
+
537
+ async def notify(self, method: str, params: Any | None = None) -> None:
538
+ await self._send({"jsonrpc": "2.0", "method": method, "params": params})
539
+
540
+ async def _cancel_lsp_request_after_external_cancellation(
541
+ self,
542
+ *,
543
+ request_id: int,
544
+ future: asyncio.Future[dict[str, Any]],
545
+ hard_timeout_grace: float,
546
+ ) -> None:
547
+ try:
548
+ await self.notify("$/cancelRequest", {"id": request_id})
549
+ await asyncio.wait_for(
550
+ asyncio.shield(future), timeout=hard_timeout_grace
551
+ )
552
+ except TimeoutError:
553
+ self.restart_required_after_external_cancellation = True
554
+ future.cancel()
555
+ except Exception:
556
+ self.restart_required_after_external_cancellation = True
557
+ future.cancel()
558
+ finally:
559
+ self._pending.pop(request_id, None)
560
+
561
+ def _reserve_request(self) -> int:
562
+ request_id = self._next_request_id
563
+ self._next_request_id += 1
564
+ self._pending[request_id] = asyncio.get_running_loop().create_future()
565
+ return request_id
566
+
567
+ async def _send(self, message: dict[str, Any]) -> None:
568
+ if self._reader_failure is not None:
569
+ raise self._reader_failure
570
+ process = self.process
571
+ if process is None or process.stdin is None:
572
+ raise RocqLspProcessExited("coq-lsp is not running")
573
+ body = json.dumps(message, separators=(",", ":")).encode("utf-8")
574
+ process.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode("ascii"))
575
+ process.stdin.write(body)
576
+ await process.stdin.drain()
577
+
578
+ async def _reader_loop(self) -> None:
579
+ try:
580
+ while True:
581
+ message = await self._read_message()
582
+ if message is None:
583
+ raise RocqLspProcessExited("coq-lsp stdout closed")
584
+ await self._dispatch(message)
585
+ except asyncio.CancelledError:
586
+ raise
587
+ except Exception as exc:
588
+ failure = (
589
+ exc
590
+ if isinstance(exc, RocqLspProcessExited)
591
+ else RocqLspProcessExited(f"coq-lsp JSON-RPC reader failed: {exc}")
592
+ )
593
+ self._reader_failure = failure
594
+ for future in self._pending.values():
595
+ if not future.done():
596
+ future.set_exception(failure)
597
+
598
+ async def _read_message(self) -> dict[str, Any] | None:
599
+ process = self.process
600
+ if process is None or process.stdout is None:
601
+ raise RocqLspProcessExited("coq-lsp is not running")
602
+
603
+ content_length: int | None = None
604
+ saw_header = False
605
+ while True:
606
+ line = await process.stdout.readline()
607
+ if line == b"":
608
+ if not saw_header:
609
+ return None
610
+ raise RocqLspProcessExited("coq-lsp stdout closed during headers")
611
+ if line in (b"\r\n", b"\n"):
612
+ if saw_header:
613
+ break
614
+ continue
615
+ saw_header = True
616
+ name, separator, value = line.decode(
617
+ "ascii", errors="replace"
618
+ ).rstrip("\r\n").partition(":")
619
+ if not separator:
620
+ raise ValueError("Malformed JSON-RPC header from coq-lsp")
621
+ if name.lower() == "content-length":
622
+ if content_length is not None:
623
+ raise ValueError("Duplicate Content-Length header from coq-lsp")
624
+ content_length = int(value.strip())
625
+
626
+ if content_length is None:
627
+ raise ValueError("Missing Content-Length header from coq-lsp")
628
+ if content_length < 0:
629
+ raise ValueError("Negative Content-Length header from coq-lsp")
630
+
631
+ body = await process.stdout.readexactly(content_length)
632
+ return json.loads(body.decode("utf-8"))
633
+
634
+ async def _dispatch(self, message: dict[str, Any]) -> None:
635
+ if "id" in message and ("result" in message or "error" in message):
636
+ request_id = message["id"]
637
+ future = self._pending.get(request_id)
638
+ if future is not None and not future.done():
639
+ future.set_result(message)
640
+ return
641
+
642
+ method = message.get("method")
643
+ params = message.get("params")
644
+ if "id" in message and method:
645
+ await self._respond_to_server_request(message["id"], method, params)
646
+ return
647
+
648
+ if isinstance(method, str):
649
+ self.notifications.handle(method, params)
650
+
651
+ async def _respond_to_server_request(
652
+ self, request_id: int, method: str, params: Any | None
653
+ ) -> None:
654
+ if method == "workspace/workspaceFolders":
655
+ result: Any = [{"uri": self.workspace.as_uri(), "name": self.workspace.name}]
656
+ elif method == "workspace/configuration":
657
+ items = (params or {}).get("items", []) if isinstance(params, dict) else []
658
+ result = [rocq_lsp_settings(self.max_errors) for _ in items]
659
+ else:
660
+ result = None
661
+ await self._send({"jsonrpc": "2.0", "id": request_id, "result": result})
662
+
663
+ async def _drain_stderr(self) -> None:
664
+ process = self.process
665
+ if process is None or process.stderr is None:
666
+ return
667
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
668
+ pending = ""
669
+ try:
670
+ while chunk := await process.stderr.read(STDERR_CHUNK_SIZE):
671
+ pending += decoder.decode(chunk)
672
+ lines = pending.split("\n")
673
+ pending = lines.pop()
674
+ for line in lines:
675
+ self._append_stderr_line(line.rstrip("\r"))
676
+ # A diagnostic need not contain a newline. Store fragments so
677
+ # draining never depends on asyncio's line-length limit.
678
+ if len(pending) >= STDERR_CHUNK_SIZE:
679
+ self._append_stderr_line(pending)
680
+ pending = ""
681
+ pending += decoder.decode(b"", final=True)
682
+ if pending:
683
+ self._append_stderr_line(pending.rstrip("\r"))
684
+ except asyncio.CancelledError:
685
+ raise
686
+
687
+ def _append_stderr_line(self, line: str) -> None:
688
+ self.stderr_lines.append(line)
689
+ if len(self.stderr_lines) > 200:
690
+ del self.stderr_lines[:100]
691
+
692
+ async def process_failure_message(self, exc: BaseException) -> str:
693
+ """Add child exit status and recent stderr to a transport failure."""
694
+ await self._collect_process_stderr()
695
+ message = str(exc)
696
+ process = self.process
697
+ returncode = process.returncode if process is not None else None
698
+ if returncode is not None:
699
+ if returncode < 0:
700
+ signal_number = -returncode
701
+ try:
702
+ signal_name = signal.Signals(signal_number).name
703
+ except ValueError:
704
+ signal_name = "unknown signal"
705
+ message += (
706
+ f". coq-lsp exited with signal {signal_name} "
707
+ f"({signal_number})"
708
+ )
709
+ else:
710
+ message += f". coq-lsp exited with code {returncode}"
711
+ stderr = "\n".join(self.stderr_lines[-20:]).strip()
712
+ if stderr:
713
+ message += f". stderr:\n{stderr}"
714
+ return message
715
+
716
+ @staticmethod
717
+ def _result_or_raise(raw: dict[str, Any]) -> dict[str, Any]:
718
+ if "error" in raw:
719
+ raise RocqLspResponseError(raw["error"] or {})
720
+ result = raw.get("result")
721
+ if isinstance(result, dict):
722
+ return result
723
+ return {"result": result}
724
+
725
+ def _ensure_launcher_available(self) -> None:
726
+ launcher = self.command[0]
727
+ if _looks_like_path(launcher):
728
+ launcher_path = Path(launcher)
729
+ if not launcher_path.is_absolute():
730
+ launcher_path = self.workspace / launcher_path
731
+ if launcher_path.exists():
732
+ return
733
+ elif shutil.which(launcher) is not None:
734
+ return
735
+ raise RocqLspUnavailableError(self._command_not_found_message(launcher))
736
+
737
+ def _command_not_found_message(self, program: str) -> str:
738
+ tried = shlex.join(self.command)
739
+ if program == "opam":
740
+ advice = (
741
+ "Install opam, or set ROCQ_LSP_USE_OPAM=0 if ROCQ_LSP_BINARY "
742
+ "points to a directly runnable coq-lsp binary."
743
+ )
744
+ else:
745
+ advice = (
746
+ "Install bens-rocq-lsp/coq-lsp, set ROCQ_LSP_BINARY to its full "
747
+ "path, or set ROCQ_LSP_USE_OPAM=1 to run it through an opam switch."
748
+ )
749
+ if _looks_like_path(program):
750
+ reason = f"executable path '{program}' was not found"
751
+ else:
752
+ reason = f"required executable '{program}' was not found on PATH"
753
+ return f"Unable to start coq-lsp: {reason}. Tried command: {tried}. {advice}"
754
+
755
+ async def _startup_failure_message(self, exc: Exception) -> str:
756
+ await self._collect_process_stderr()
757
+ message = (
758
+ "Unable to start coq-lsp: the server exited or failed during "
759
+ f"initialization. Tried command: {shlex.join(self.command)}. "
760
+ f"Workspace: {self.workspace}. Cause: {exc}."
761
+ )
762
+ stderr = "\n".join(self.stderr_lines[-20:]).strip()
763
+ if stderr:
764
+ message += f" stderr:\n{stderr}"
765
+ message += (
766
+ " Ensure bens-rocq-lsp/coq-lsp is installed in the selected opam "
767
+ "switch or set ROCQ_LSP_BINARY/ROCQ_LSP_USE_OPAM to the correct "
768
+ "launcher."
769
+ )
770
+ return message
771
+
772
+ async def _collect_process_stderr(self) -> None:
773
+ process = self.process
774
+ if process is not None and process.returncode is None:
775
+ with suppress(Exception):
776
+ await asyncio.wait_for(process.wait(), timeout=0.2)
777
+ stderr_task = self._stderr_task
778
+ if stderr_task is not None and not stderr_task.done():
779
+ with suppress(Exception):
780
+ await asyncio.wait_for(asyncio.shield(stderr_task), timeout=0.2)
781
+
782
+
783
+ class _CommandTimeoutWatchdog:
784
+ """Cancels one ``coq/getDocument`` request when a per-sentence or
785
+ after-error deadline expires.
786
+
787
+ The interface rules that keep the timing behavior easy to reason about:
788
+
789
+ - Every timer is an absolute event-loop instant. The per-sentence
790
+ deadline is anchored to the sentence's ``$/coq/executionInformation``
791
+ notification and the after-error deadline to the first observed error
792
+ diagnostic, so unrelated wake-ups can never restart a timer.
793
+ - The after-error deadline is armed in exactly one place, from the state
794
+ poll at the top of the loop; the error waiter task only wakes the loop.
795
+ - Which timeout fired is decided when the wait is armed — the earliest
796
+ deadline wins, and the after-error deadline wins ties — never
797
+ re-derived from the clock afterwards.
798
+ - The outcome is published exactly once, as the immutable
799
+ ``self.timeout``, before the cancel notification is sent.
800
+ - The watchdog is advisory and contains its own failures: if sending the
801
+ cancel fails (for example because coq-lsp died), the request future is
802
+ the authoritative error channel and surfaces the real failure itself.
803
+ """
804
+
805
+ def __init__(
806
+ self,
807
+ *,
808
+ client: RocqLspClient,
809
+ uri: str,
810
+ version: int,
811
+ request_id: int,
812
+ command_timeout: float | None,
813
+ timeout_after_error: float | None,
814
+ content: str | None,
815
+ ) -> None:
816
+ self._client = client
817
+ self._uri = uri
818
+ self._version = version
819
+ self._request_id = request_id
820
+ self._command_timeout = command_timeout
821
+ self._timeout_after_error = timeout_after_error
822
+ self._final_boundary = _end_of_content_position(content)
823
+ self.timeout: WatchdogTimeout | None = None
824
+
825
+ async def run(self) -> None:
826
+ try:
827
+ await self._watch()
828
+ except asyncio.CancelledError:
829
+ raise
830
+ except Exception:
831
+ # Advisory task: any I/O failure it can hit (for example a dead
832
+ # coq-lsp process while sending the cancel) also fails the
833
+ # pending request future, which is the authoritative channel.
834
+ return
835
+
836
+ async def _watch(self) -> None:
837
+ loop = asyncio.get_running_loop()
838
+ notifications = self._client.notifications
839
+ uri = self._uri
840
+ version = self._version
841
+ after_sequence = notifications.latest_execution_sequence(uri, version)
842
+ active_range: dict[str, Any] | None = None
843
+ sentence_deadline: float | None = None
844
+ after_error_deadline: float | None = None
845
+ watch_errors = self._timeout_after_error is not None
846
+ exec_task: asyncio.Task[tuple[int, dict[str, Any]] | None] | None = None
847
+ error_task: asyncio.Task[None] | None = None
848
+
849
+ try:
850
+ while True:
851
+ if (
852
+ watch_errors
853
+ and after_error_deadline is None
854
+ and notifications.has_error_diagnostic(uri, version)
855
+ ):
856
+ # The only site that arms the after-error deadline. It
857
+ # never resets, and it also covers the final sentence and
858
+ # end-of-file finalization, which the per-sentence
859
+ # deadline exempts: after an error a bounded exit matters
860
+ # more than attributing that time precisely.
861
+ after_error_deadline = loop.time() + self._timeout_after_error
862
+
863
+ expiring = self._earliest_deadline(
864
+ sentence_deadline, after_error_deadline
865
+ )
866
+ if expiring is not None and expiring[0] <= loop.time():
867
+ await self._cancel(expiring, active_range)
868
+ return
869
+
870
+ if exec_task is None or exec_task.done():
871
+ exec_task = asyncio.create_task(
872
+ notifications.wait_for_execution_info(
873
+ uri,
874
+ version,
875
+ after_sequence=after_sequence,
876
+ timeout=None,
877
+ )
878
+ )
879
+ waiters: set[asyncio.Task[Any]] = {exec_task}
880
+ if watch_errors and after_error_deadline is None:
881
+ if error_task is None or error_task.done():
882
+ error_task = asyncio.create_task(
883
+ notifications.wait_for_error_diagnostic(uri, version)
884
+ )
885
+ waiters.add(error_task)
886
+
887
+ done, _ = await asyncio.wait(
888
+ waiters,
889
+ timeout=(
890
+ None if expiring is None else expiring[0] - loop.time()
891
+ ),
892
+ return_when=asyncio.FIRST_COMPLETED,
893
+ )
894
+ if not done and expiring is not None:
895
+ await self._cancel(expiring, active_range)
896
+ return
897
+ if exec_task in done:
898
+ update = exec_task.result()
899
+ if update is not None:
900
+ after_sequence, params = update
901
+ range_ = (
902
+ params.get("range") if isinstance(params, dict) else None
903
+ )
904
+ active_range = range_ if isinstance(range_, dict) else None
905
+ sentence_deadline = self._sentence_deadline_for(
906
+ active_range, loop.time()
907
+ )
908
+ # A completed error waiter only wakes the loop; the poll at
909
+ # the top of the next iteration arms the deadline.
910
+ finally:
911
+ for task in (exec_task, error_task):
912
+ if task is not None and not task.done():
913
+ task.cancel()
914
+ with suppress(asyncio.CancelledError):
915
+ await task
916
+
917
+ def _earliest_deadline(
918
+ self,
919
+ sentence_deadline: float | None,
920
+ after_error_deadline: float | None,
921
+ ) -> tuple[float, TimeoutKind, float] | None:
922
+ """The next (deadline, kind, configured seconds) to enforce.
923
+
924
+ The after-error deadline wins ties so a bounded post-error exit is
925
+ reported as such.
926
+ """
927
+ expiring: tuple[float, TimeoutKind, float] | None = None
928
+ for deadline, kind, seconds in (
929
+ (after_error_deadline, "after_error", self._timeout_after_error),
930
+ (sentence_deadline, "command", self._command_timeout),
931
+ ):
932
+ if deadline is None:
933
+ continue
934
+ if expiring is None or deadline < expiring[0]:
935
+ expiring = (deadline, kind, seconds)
936
+ return expiring
937
+
938
+ def _sentence_deadline_for(
939
+ self, active_range: dict[str, Any] | None, now: float
940
+ ) -> float | None:
941
+ """Absolute deadline for the sentence that just started, if any.
942
+
943
+ The final sentence is exempt because execution notifications mark
944
+ sentence starts, not completion, so end-of-file finalization would
945
+ otherwise be attributed to it.
946
+ """
947
+ if (
948
+ self._command_timeout is None
949
+ or active_range is None
950
+ or self._is_final_sentence(active_range)
951
+ ):
952
+ return None
953
+ return now + self._command_timeout
954
+
955
+ def _is_final_sentence(self, range_: dict[str, Any]) -> bool:
956
+ if self._final_boundary is None:
957
+ return False
958
+ end = range_.get("end")
959
+ if not isinstance(end, dict):
960
+ return False
961
+ line = end.get("line")
962
+ character = end.get("character")
963
+ if not isinstance(line, int) or not isinstance(character, int):
964
+ return False
965
+ return (line, character) >= self._final_boundary
966
+
967
+ async def _cancel(
968
+ self,
969
+ expiring: tuple[float, TimeoutKind, float],
970
+ timed_out_range: dict[str, Any] | None,
971
+ ) -> None:
972
+ _, kind, seconds = expiring
973
+ self.timeout = WatchdogTimeout(
974
+ kind=kind, seconds=seconds, timed_out_range=timed_out_range
975
+ )
976
+ await self._client.notify("$/cancelRequest", {"id": self._request_id})
977
+
978
+
979
+ def _looks_like_path(program: str) -> bool:
980
+ return "/" in program