diff --git a/extra/mcp/eval_guide.md b/extra/mcp/eval_guide.md index d061198b..19464253 100644 --- a/extra/mcp/eval_guide.md +++ b/extra/mcp/eval_guide.md @@ -72,3 +72,26 @@ stats = ctx.get_zone_stats(sid) For long-running queries pass `async_mode=True` to `eval`; it returns `{task_id, status: "running"}`. Poll with the `task` tool (`action="poll", task_id=...`). + +## Instance lifecycle + +`tracy_mcp.py` runs as a long-lived singleton process shared across every +MCP client (all VS Code windows), and every `live_connect`/`load_capture` +instance holds its *entire* trace — zones, messages, callstacks, memory +events — resident in memory for as long as that process runs. Nothing +frees it just because your conversation ends. + +- Call `unload_capture(instance_id)` as soon as you're done analyzing a + capture. Don't rely on automatic eviction as your primary cleanup path — + treat it as a backstop for forgotten sessions, not a substitute. +- `list_instances` reports `idle_seconds` and `connected` per instance; use + it to spot stale ones before starting a new session, especially if you're + about to load several captures for comparison. +- The server does provide two backstops, both configurable via environment + variables at startup: it evicts the least-recently-used *evictable* + instance (disconnected live sessions, or any loaded file capture — never + a still-connected live one) once the instance count reaches + `TRACY_MCP_MAX_INSTANCES` (default 4), and it drops a disconnected live + instance that's sat idle for `TRACY_MCP_DISCONNECTED_TTL_S` (default + 1800s / 30 min) so a session is still there for analysis right after the + target disconnects, but doesn't linger forever if forgotten. diff --git a/extra/mcp/tracy_mcp.py b/extra/mcp/tracy_mcp.py index 1b8524c0..50432363 100644 --- a/extra/mcp/tracy_mcp.py +++ b/extra/mcp/tracy_mcp.py @@ -15,7 +15,7 @@ import struct import sys import time import uuid -from contextlib import redirect_stdout +from contextlib import asynccontextmanager, redirect_stdout import mcp.server.fastmcp as fastmcp @@ -219,8 +219,9 @@ except ImportError: except ImportError: tracy_server = None -mcp_server = fastmcp.FastMCP("Tracy Profiler") -executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) +_MAX_INSTANCES = int(os.environ.get("TRACY_MCP_MAX_INSTANCES", "4")) +_DISCONNECTED_TTL_S = float(os.environ.get("TRACY_MCP_DISCONNECTED_TTL_S", "1800")) +_SWEEP_INTERVAL_S = float(os.environ.get("TRACY_MCP_SWEEP_INTERVAL_S", "300")) class Task: @@ -240,7 +241,108 @@ class TracyInstance: self.worker = worker self.path = None self.mtime = None + self.last_used = time.time() + # Wall-clock time the instance was first observed disconnected (live + # instances only). None while connected or for file-loaded captures. + self.disconnected_since: float | None = None + def touch(self) -> None: + self.last_used = time.time() + + def is_evictable(self) -> bool: + """True if this instance is safe to drop without losing in-progress data. + + A live instance still connected is actively recording — never evict it + automatically. Everything else (file captures, disconnected live + instances) is fair game for the LRU cap. + """ + if self.worker is not None and self.path is None: + try: + return not self.worker.is_connected() + except Exception: + return True + return True + + +def _shutdown_worker(worker: object | None) -> None: + if worker is None: + return + try: + worker.shutdown() + except Exception: + pass + + +def _evict_disconnected_idle() -> list[str]: + """Unload live instances that disconnected and have sat idle past the TTL. + + Runs on the periodic sweep. A grace period (_DISCONNECTED_TTL_S) is kept + so a capture is still around for post-session analysis right after the + target disconnects — this only reclaims sessions that were forgotten. + """ + now = time.time() + evicted = [] + for name, inst in list(instances.items()): + if inst.path is not None or inst.worker is None: + continue + try: + connected = inst.worker.is_connected() + except Exception: + connected = False + if connected: + inst.disconnected_since = None + continue + if inst.disconnected_since is None: + inst.disconnected_since = now + continue + if now - inst.disconnected_since >= _DISCONNECTED_TTL_S: + del instances[name] + _shutdown_worker(inst.worker) + evicted.append(name) + return evicted + + +def _evict_for_capacity(exclude: str | None = None) -> str | None: + """If at/over _MAX_INSTANCES, drop the least-recently-used evictable + instance to make room for a new one. Returns the evicted name, if any.""" + if len(instances) < _MAX_INSTANCES: + return None + candidates = [ + inst for name, inst in instances.items() + if name != exclude and inst.is_evictable() + ] + if not candidates: + return None + victim = min(candidates, key=lambda inst: inst.last_used) + del instances[victim.name] + _shutdown_worker(victim.worker) + return victim.name + + +async def _sweep_loop() -> None: + while True: + await asyncio.sleep(_SWEEP_INTERVAL_S) + try: + _evict_disconnected_idle() + except Exception: + pass + + +@asynccontextmanager +async def _lifespan(_server: fastmcp.FastMCP): + sweep_task = asyncio.create_task(_sweep_loop()) + try: + yield + finally: + sweep_task.cancel() + try: + await sweep_task + except asyncio.CancelledError: + pass + + +mcp_server = fastmcp.FastMCP("Tracy Profiler", lifespan=_lifespan) +executor = concurrent.futures.ThreadPoolExecutor(max_workers=4) instances: dict[str, TracyInstance] = {} tasks: dict[str, Task] = {} @@ -273,13 +375,26 @@ async def list_captures() -> list[str]: @mcp_server.tool() async def list_instances() -> list[dict]: - """List all loaded Tracy instances and captures with metadata.""" + """List all loaded Tracy instances and captures with metadata. + + Each full trace stays resident in memory for as long as this server + process runs (it's a singleton shared across all MCP clients) until + unloaded. `idle_seconds` is how long since this instance was last used + via `eval`/`save_trace` — call `unload_capture` on ones you no longer + need instead of waiting for automatic eviction. Automatic eviction only + kicks in at the `TRACY_MCP_MAX_INSTANCES` cap (LRU, connected live + instances are never evicted) or after a disconnected live instance sits + idle past `TRACY_MCP_DISCONNECTED_TTL_S`. + """ + now = time.time() return [ { "id": name, "path": inst.path, "mtime": inst.mtime, - "live": inst.path is None + "live": inst.path is None, + "connected": inst.worker.is_connected() if inst.path is None and inst.worker else None, + "idle_seconds": now - inst.last_used, } for name, inst in instances.items() ] @@ -382,9 +497,13 @@ async def live_connect(address: str = "127.0.0.1", port: int = 8086, alias: str ) name = alias or f"live_{address}_{port}" + if name in instances: + _shutdown_worker(instances[name].worker) + evicted = _evict_for_capacity(exclude=name) instances[name] = TracyInstance(name, w) + note = f" (evicted idle instance '{evicted}' to stay under the {_MAX_INSTANCES}-instance cap)" if evicted else "" return ( - f"Connected to live instance as '{name}'. " + f"Connected to live instance as '{name}'{note}. " f"Before your first eval, read resources tracy://prompt " f"(analysis guidance) and tracy://eval-guide (ctx object model, " f"ns time units, srcloc IDs)." @@ -418,7 +537,11 @@ async def load_capture(path: str, alias: str | None = None) -> str: if name in instances: inst = instances[name] if inst.path == path and inst.mtime == mtime: + inst.touch() return f"Instance '{name}' is already loaded and up to date." + _shutdown_worker(inst.worker) + + evicted = _evict_for_capacity(exclude=name) f = tracy_server.open_file(path) w = tracy_server.create_worker_from_file(f) @@ -426,8 +549,9 @@ async def load_capture(path: str, alias: str | None = None) -> str: inst.path = path inst.mtime = mtime instances[name] = inst + note = f" (evicted idle instance '{evicted}' to stay under the {_MAX_INSTANCES}-instance cap)" if evicted else "" return ( - f"Loaded as '{name}'. " + f"Loaded as '{name}'{note}. " f"Before your first eval, read resources tracy://prompt " f"(analysis guidance) and tracy://eval-guide (ctx object model, " f"ns time units, srcloc IDs)." @@ -476,6 +600,7 @@ async def save_trace( instance = instances[instance_id] if not instance.worker: return f"Error: Instance '{instance_id}' has no worker." + instance.touch() if not os.path.isabs(path): if captures_dir and os.path.basename(path) == path: @@ -542,9 +667,18 @@ async def _execute_save(worker: object, path: str, level: int, streams: int, fi_ @mcp_server.tool() async def unload_capture(instance_id: str) -> str: - """Unload a Tracy instance and release its memory.""" + """Unload a Tracy instance and release its memory. + + Prefer calling this as soon as you're done analyzing a capture — every + loaded instance holds the *entire* trace (zones, messages, callstacks, + memory events) in memory for as long as this server process runs, and + the server is a long-lived singleton shared across all MCP clients. + Automatic eviction exists as a backstop (see `list_instances`), not a + substitute for unloading what you no longer need. + """ if instance_id in instances: - del instances[instance_id] + inst = instances.pop(instance_id) + _shutdown_worker(inst.worker) return f"Instance '{instance_id}' unloaded." return f"Instance '{instance_id}' not found." @@ -566,6 +700,7 @@ async def tracy_eval(code: str, instance_id: str, async_mode: bool = False) -> o instance = instances[instance_id] if not instance.worker: return f"Error: Instance '{instance_id}' has no worker." + instance.touch() if not async_mode: return await _execute_eval(code, instance.worker)