Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -281,6 +282,31 @@ def _get_mine_targets() -> list[tuple[str, str]]:
# own slot on exit without scanning the whole directory.
_MINE_PID_FILE_ENV = "MEMPALACE_MINE_PID_FILE"

# Maximum wall-clock hours a mine subprocess is allowed to run before its
# PID slot is treated as stale (even if the process is still alive). A
# wedged mine — e.g. one that is blocking indefinitely on ChromaDB
# cold-init under concurrent Windows load (#1552) — would otherwise hold
# its slot forever. Set MEMPALACE_MINE_TIMEOUT_HOURS=0 to disable the
# timeout (slots are reclaimed only when the PID is dead).
_MINE_TIMEOUT_HOURS_ENV = "MEMPALACE_MINE_TIMEOUT_HOURS"
_MINE_TIMEOUT_HOURS_DEFAULT = 2.0


def _mine_slot_timeout_secs() -> float:
"""Return the configured mine-slot timeout in seconds.

Reads ``MEMPALACE_MINE_TIMEOUT_HOURS`` from the environment (float).
Returns 0 if the env var is set to 0 or is not parseable.
Comment thread
igorls marked this conversation as resolved.
"""
raw = os.environ.get(_MINE_TIMEOUT_HOURS_ENV, "")
if raw:
try:
hours = float(raw)
return max(0.0, hours) * 3600
except ValueError:
return 0.0
return _MINE_TIMEOUT_HOURS_DEFAULT * 3600


def _pid_file_for_cmd(cmd: list[str]) -> Path:
"""Return the per-target PID file path for a mine subcommand.
Expand Down Expand Up @@ -333,23 +359,59 @@ def _pid_alive(pid: int) -> bool:


def _mine_already_running(cmd: list[str]) -> bool:
"""Return True if a previous mine for ``cmd``'s target is still alive."""
"""Return True if a previous mine for ``cmd``'s target is still alive.

The PID file format is ``{pid} {unix_timestamp}`` (timestamp added in
#1552 to detect wedged subprocesses). Old-format files (bare ``{pid}``)
use the PID file's mtime as the approximate start time so a still-running
pre-upgrade mine is not immediately misclassified as stale.

A process is considered stale (and this function returns False) when:
- the PID is dead, OR
- the configured mine timeout is > 0 AND the process has been running
longer than the timeout.
"""
pid_file = _pid_file_for_cmd(cmd)
try:
recorded = pid_file.read_text().strip()
except OSError:
return False
if not recorded.isdigit():
if not recorded:
return False
parts = recorded.split(None, 1)
if not parts[0].isdigit():
return False
return _pid_alive(int(recorded))
pid = int(parts[0])
if not _pid_alive(pid):
return False
timeout_secs = _mine_slot_timeout_secs()
if timeout_secs > 0:
if len(parts) > 1 and parts[1]:
try:
start_ts = float(parts[1])
except ValueError:
return False
else:
try:
start_ts = pid_file.stat().st_mtime
except OSError:
return True
if time.time() - start_ts > timeout_secs:
return False
return True


def _create_mine_slot_with_placeholder(pid_file: Path) -> Path:
"""Atomically create a mine PID slot and write this hook PID into it."""
"""Atomically create a mine PID slot and write this hook PID into it.

The slot body is ``{pid} {unix_timestamp}`` so that stale-by-age
detection in ``_mine_already_running`` can determine how long the
recorded process has been running (#1552).
"""
fd = os.open(str(pid_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
try:
with os.fdopen(fd, "w", encoding="ascii") as f:
f.write(str(os.getpid()))
f.write(f"{os.getpid()} {int(time.time())}")
except OSError:
try:
os.close(fd)
Expand Down Expand Up @@ -437,7 +499,7 @@ def _spawn_mine(cmd: list) -> None:
pass
raise
try:
pid_file.write_text(str(proc.pid))
pid_file.write_text(f"{proc.pid} {int(time.time())}")
except OSError:
pass

Expand Down
79 changes: 73 additions & 6 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,27 @@ def _parse_args():
_kg_cache_lock = threading.Lock()
_palace_flag_given: bool = bool(_args.palace)

# MCP server idle auto-exit (#1552). Stale MCP servers from ended Claude
# Code sessions do not self-terminate, accumulating ChromaDB/HNSW file
# handles on Windows. When MEMPALACE_MCP_IDLE_HOURS is set (or defaults
# to 8 h), a background daemon thread exits the process once no request
# has been handled for that long. Set to 0 to disable.
_MCP_IDLE_HOURS_ENV = "MEMPALACE_MCP_IDLE_HOURS"
_MCP_IDLE_HOURS_DEFAULT = 8.0
_last_request_time: float = time.monotonic()


def _mcp_idle_timeout_secs() -> float:
"""Return the configured MCP idle timeout in seconds (0 = disabled)."""
raw = os.environ.get(_MCP_IDLE_HOURS_ENV, "")
if raw:
try:
hours = float(raw)
return max(0.0, hours) * 3600
except ValueError:
return 0.0
return _MCP_IDLE_HOURS_DEFAULT * 3600


def _resolve_kg_path() -> str:
if _palace_flag_given:
Expand Down Expand Up @@ -1204,7 +1225,11 @@ def tool_sync(project_dir: str = None, wing: str = None, apply: bool = False):
# below, otherwise MineAlreadyRunning and ValueError fall into the
# generic "sync failed" branch and break the structured-error tests.
except MineAlreadyRunning as exc:
return {"success": False, "error": f"another mine is in progress: {exc}"}
return {
"success": False,
"error": f"another mine is in progress: {exc}",
"error_class": "LockHeldByOtherProcess",
}
except ValueError as exc:
return {"success": False, "error": str(exc)}
except Exception as exc:
Expand Down Expand Up @@ -2286,22 +2311,30 @@ def tool_reconnect():
]


def _internal_tool_error(req_id, tool_name: str) -> dict:
def _internal_tool_error(req_id, tool_name: str, exc: BaseException = None) -> dict:
logger.exception(f"Tool error in {tool_name}")
error: dict = {"code": -32000, "message": "Internal tool error"}
if exc is not None:
error["data"] = {
"error_class": type(exc).__name__,
"message": str(exc),
}
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32000, "message": "Internal tool error"},
"error": error,
}


def handle_request(request):
global _last_request_time
if not isinstance(request, dict):
return {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32600, "message": "Invalid Request"},
}
_last_request_time = time.monotonic()
method = request.get("method") or ""
params = request.get("params") or {}
req_id = request.get("id")
Expand Down Expand Up @@ -2449,9 +2482,9 @@ def handle_request(request):
"message": f"Missing required {word} {quoted} for tool {tool_name}",
},
}
return _internal_tool_error(req_id, tool_name)
except Exception:
return _internal_tool_error(req_id, tool_name)
return _internal_tool_error(req_id, tool_name, e)
except Exception as exc:
return _internal_tool_error(req_id, tool_name, exc)

# Notifications (missing id) must never get a response
if req_id is None:
Expand Down Expand Up @@ -2626,6 +2659,37 @@ def _maybe_eager_warmup_embedder() -> None:
)


def _start_idle_exit_watchdog() -> None:
"""Start a daemon thread that exits the process after an idle period.

When no request has been handled for ``MEMPALACE_MCP_IDLE_HOURS``
(default 8 h), the thread terminates the process so that stale MCP
servers from ended Claude Code sessions do not accumulate ChromaDB /
HNSW file handles on Windows (#1552).

Set ``MEMPALACE_MCP_IDLE_HOURS=0`` to disable the watchdog.
"""
timeout = _mcp_idle_timeout_secs()
if timeout <= 0:
return
check_interval = min(60.0, timeout / 4)

def _watchdog() -> None:
while True:
time.sleep(check_interval)
idle = time.monotonic() - _last_request_time
if idle >= timeout:
logger.info(
"MCP server idle for %.1f h (limit %.1f h); exiting to release file handles.",
idle / 3600,
timeout / 3600,
)
os._exit(0)

t = threading.Thread(target=_watchdog, name="mcp-idle-watchdog", daemon=True)
t.start()


def main():
"""MCP server entry point for the ``mempalace-mcp`` console script.

Expand Down Expand Up @@ -2661,6 +2725,9 @@ def main():
# does not pay the ONNX/CoreML cold-load tax under the MCP client
# timeout (#1495). Default off — preserves current startup latency.
_maybe_eager_warmup_embedder()
# Idle auto-exit: release ChromaDB file handles from stale servers
# that outlived their Claude Code session (#1552).
_start_idle_exit_watchdog()
while True:
try:
line = sys.stdin.readline()
Expand Down
7 changes: 6 additions & 1 deletion mempalace/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1323,7 +1323,12 @@ def _cleanup_mine_pid_file() -> None:
if not pid_file.exists():
return
recorded = pid_file.read_text().strip()
if recorded and recorded.isdigit() and int(recorded) == os.getpid():
# PID file format: "{pid} {unix_timestamp}" (timestamp added in
# #1552 for stale-by-age detection). Old-format files (bare
# "{pid}") are also handled: split on whitespace and take the
# first token as the PID.
pid_token = recorded.split()[0] if recorded else ""
if pid_token and pid_token.isdigit() and int(pid_token) == os.getpid():
pid_file.unlink()
except OSError:
# Best-effort cleanup; never fail the mine over PID bookkeeping.
Expand Down
Loading
Loading