fix: bound MCP request cancellation and recovery

This commit is contained in:
HumbleTurtle
2026-09-05 02:20:58 -03:00
parent f8594bd3d8
commit e8c6a5109e
7 changed files with 593 additions and 68 deletions
+105 -28
View File
@@ -8,8 +8,8 @@ session. This supervisor owns those client-facing pipes while treating
- valid requests are forwarded without head-of-line blocking and responses
are correlated by JSON-RPC request id;
- notifications never create client-facing responses;
- cancellation releases the matching pending request and forwards the
notification to the worker;
- cancellation stops waiting for a result but retains unfinished work until
the worker acknowledges cleanup, with bounded grace before termination;
- a worker exit fails every affected in-flight request once and never replays
it automatically;
- a replaced worker, or an explicitly configured runtime dependency behind a
@@ -26,10 +26,12 @@ import json
import os
import pathlib
import queue
import select
import signal
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import BinaryIO, Callable
@@ -54,6 +56,10 @@ MAX_IN_FLIGHT_REQUESTS = max(
int(os.environ.get("NAPSEER_MCP_MAX_IN_FLIGHT_REQUESTS", "32")),
)
WORKER_RESTARTED_ERROR = -32098
REQUEST_REJECTED_ERROR = -32097
CANCEL_GRACE_SECONDS = max(1, int(os.environ.get("NAPSEER_MCP_CANCEL_GRACE_SECONDS", "10")))
WRITE_TIMEOUT_SECONDS = max(1, int(os.environ.get("NAPSEER_MCP_WRITE_TIMEOUT_SECONDS", "5")))
REQUEST_FINISHED = "notifications/napseer/requestFinished"
_CANCELLED = object()
@@ -61,6 +67,10 @@ class WorkerUnavailable(RuntimeError):
"""The replaceable MCP worker could not complete a request."""
class RequestRejected(WorkerUnavailable):
"""The request was not sent to the worker and cannot have executed."""
def source_identity(path: pathlib.Path) -> tuple[int, int, int]:
stat = path.stat()
return stat.st_ino, stat.st_size, stat.st_mtime_ns
@@ -112,16 +122,16 @@ def cancelled_request_id(message: bytes):
return True, params.get("requestId")
def worker_error(message: bytes) -> bytes:
def worker_error(message: bytes, *, rejected=False) -> bytes:
payload = {
"jsonrpc": "2.0",
"id": request_id(message),
"error": {
"code": WORKER_RESTARTED_ERROR,
"code": REQUEST_REJECTED_ERROR if rejected else WORKER_RESTARTED_ERROR,
"message": (
"Napseer MCP could not complete this request. The outcome may be "
"uncertain; inspect state before retrying a mutation."
),
) if not rejected else "Napseer MCP is busy or this request id is still active. This request was not started; retry later.",
},
}
return json.dumps(payload, separators=(",", ":")).encode("utf-8") + b"\n"
@@ -133,6 +143,8 @@ class PendingRequest:
key: str
generation: int
response: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=1))
cancelled: bool = False
cancel_timer: threading.Timer | None = None
class Worker:
@@ -156,7 +168,7 @@ class Worker:
def _discard_stderr(self, stream: BinaryIO) -> None:
try:
for _line in stream:
while stream.read(4096):
pass
finally:
stream.close()
@@ -180,6 +192,8 @@ class Worker:
if pending.generation != generation:
continue
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
failed.append(pending)
for pending in failed:
self._signal(pending, error)
@@ -189,18 +203,32 @@ class Worker:
if payload is None:
return False
normalized = response if response.endswith(b"\n") else response + b"\n"
if payload.get("method") == REQUEST_FINISHED:
params = payload.get("params") or {}
if isinstance(params, dict) and "requestId" in params:
key = request_key(params["requestId"])
with self._pending_lock:
pending = self._pending.get(key)
if pending is not None and pending.generation == generation and pending.cancelled:
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
return True
if "id" in payload:
key = request_key(payload.get("id"))
with self._pending_lock:
pending = self._pending.get(key)
if pending is not None and pending.generation == generation:
self._pending.pop(key, None)
if pending.cancel_timer is not None:
pending.cancel_timer.cancel()
else:
pending = None
if pending is not None:
self._signal(pending, normalized)
if not pending.cancelled:
self._signal(pending, normalized)
return True
if "method" in payload and self.unsolicited is not None:
if generation == self.generation and "method" in payload and self.unsolicited is not None:
self.unsolicited(normalized)
return True
@@ -240,12 +268,15 @@ class Worker:
if process is None:
return
if process.poll() is None:
process.terminate()
try:
process.terminate()
except ProcessLookupError:
pass
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
process.wait(timeout=2)
for stream in (process.stdin, process.stdout):
if stream is not None:
try:
@@ -265,6 +296,7 @@ class Worker:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
env={**os.environ, "NAPSEER_MCP_SUPERVISED": "1"},
)
except (OSError, subprocess.SubprocessError) as exc:
raise WorkerUnavailable("unable to start Napseer MCP worker") from exc
@@ -275,6 +307,7 @@ class Worker:
self.generation += 1
generation = self.generation
self.process = process
os.set_blocking(process.stdin.fileno(), False)
self.identity = identity
threading.Thread(
target=self._discard_stderr,
@@ -309,14 +342,30 @@ class Worker:
self.start()
def _write(self, process: subprocess.Popen[bytes], message: bytes) -> None:
with self._stdin_lock:
deadline = time.monotonic() + WRITE_TIMEOUT_SECONDS
if not self._stdin_lock.acquire(timeout=WRITE_TIMEOUT_SECONDS):
raise WorkerUnavailable("Napseer MCP worker input timed out")
try:
if self.process is not process or process.poll() is not None or process.stdin is None:
raise WorkerUnavailable("Napseer MCP worker is not running")
try:
process.stdin.write(message if message.endswith(b"\n") else message + b"\n")
process.stdin.flush()
except (BrokenPipeError, OSError) as exc:
remaining = memoryview(message if message.endswith(b"\n") else message + b"\n")
while remaining:
timeout = deadline - time.monotonic()
if timeout <= 0 or self.process is not process:
raise WorkerUnavailable("Napseer MCP worker input timed out")
fd = process.stdin.fileno()
if not select.select([], [fd], [], timeout)[1]:
raise WorkerUnavailable("Napseer MCP worker input timed out")
try:
count = os.write(fd, remaining)
remaining = remaining[count:]
except BlockingIOError:
continue
except (BrokenPipeError, OSError, ValueError) as exc:
raise WorkerUnavailable("Napseer MCP worker input closed") from exc
finally:
self._stdin_lock.release()
def submit(self, message: bytes) -> PendingRequest | None:
self.ensure_current()
@@ -326,16 +375,20 @@ class Worker:
if process is None:
raise WorkerUnavailable("Napseer MCP worker is not running")
if is_notification(message):
self._write(process, message)
try:
self._write(process, message)
except WorkerUnavailable:
self.stop()
raise
return None
key = request_key(request_id(message))
pending = PendingRequest(message=message, key=key, generation=generation)
with self._pending_lock:
if key in self._pending:
raise WorkerUnavailable("duplicate in-flight JSON-RPC request id")
raise RequestRejected("duplicate in-flight JSON-RPC request id")
if len(self._pending) >= MAX_IN_FLIGHT_REQUESTS:
raise WorkerUnavailable("too many in-flight Napseer MCP requests")
raise RequestRejected("too many unfinished Napseer MCP requests")
self._pending[key] = pending
try:
self._write(process, message)
@@ -343,26 +396,54 @@ class Worker:
with self._pending_lock:
if self._pending.get(key) is pending:
self._pending.pop(key, None)
# A partial write has an uncertain outcome and corrupts framing.
# Stop this generation rather than appending another request to it.
self.stop()
raise
return pending
def cancel(self, value) -> bool:
def cancel(self, value, *, expected: PendingRequest | None = None) -> bool:
key = request_key(value)
with self._pending_lock:
pending = self._pending.get(key)
if expected is not None and pending is not expected:
return False
if pending is None or request_method(pending.message) == "initialize":
return False
self._pending.pop(key, None)
if pending.cancelled:
return True
pending.cancelled = True
pending.cancel_timer = threading.Timer(CANCEL_GRACE_SECONDS, self._expire_cancelled, args=(pending,))
pending.cancel_timer.daemon = True
timer = pending.cancel_timer
self._signal(pending, _CANCELLED)
timer.start()
process = self.process
if process is not None and pending.generation == self.generation:
try:
self._write(process, json.dumps({"jsonrpc": "2.0", "method": "notifications/cancelled",
"params": {"requestId": value}}).encode("utf-8"))
except WorkerUnavailable:
self._expire_cancelled(pending)
return True
def _expire_cancelled(self, pending: PendingRequest) -> None:
with self._lifecycle_lock:
with self._pending_lock:
unfinished = self._pending.get(pending.key) is pending
if unfinished and pending.generation == self.generation:
self._stop_locked()
def wait(self, pending: PendingRequest) -> bytes | None:
try:
result = pending.response.get(timeout=RESPONSE_TIMEOUT_SECONDS)
except queue.Empty as exc:
with self._pending_lock:
if self._pending.get(pending.key) is pending:
self._pending.pop(pending.key, None)
if request_method(pending.message) == "initialize":
# Initialize is not cancellable, but a stuck initialization
# must not permanently consume the worker generation.
self._expire_cancelled(pending)
else:
self.cancel(request_id(pending.message), expected=pending)
raise WorkerUnavailable("Napseer MCP worker response timed out") from exc
if result is _CANCELLED:
return None
@@ -416,16 +497,12 @@ def supervise() -> int:
cancelled, value = cancelled_request_id(message)
if cancelled:
worker.cancel(value)
try:
worker.submit(message)
except WorkerUnavailable:
pass
continue
try:
pending = worker.submit(message)
except WorkerUnavailable:
except WorkerUnavailable as exc:
if not is_notification(message):
emit(worker_error(message))
emit(worker_error(message, rejected=isinstance(exc, RequestRejected)))
continue
if pending is not None:
waiters.submit(finish, message, pending)