|
| 1 | +# v3.19.5 — `open_visualization` reuses the running server; snapshot clobber fixed |
| 2 | + |
| 3 | +Fifth entry in the 3.19.x reliability series. This one fixes two |
| 4 | +`cortex-visualize` defects: every `open_visualization` call spawned a brand-new |
| 5 | +server on a fresh ephemeral port with a cold graph rebuild, and a vestigial |
| 6 | +skeleton-snapshot write silently destroyed the shared full graph snapshot. |
| 7 | + |
| 8 | +## What it fixes |
| 9 | + |
| 10 | +### 1. New server + new port + cold rebuild on every call |
| 11 | + |
| 12 | +Calling `open_visualization` twice left two (or more) servers running on |
| 13 | +different ephemeral ports (observed: `:56746`, `:57167`), each paying the full |
| 14 | +cold-start graph build. Two root causes: |
| 15 | + |
| 16 | +1. **No instance record.** Nothing persisted which pid/port was already |
| 17 | + serving, so every launch path started from scratch. |
| 18 | +2. **Double-spawn race.** The handler ran `launch_server` unconditionally |
| 19 | + *after* the bootstrap had already started a server, so a single tool call |
| 20 | + could itself spawn two processes. |
| 21 | + |
| 22 | +### 2. Skeleton snapshot clobbered the full snapshot |
| 23 | + |
| 24 | +`http_standalone_graph.py` wrote a "skeleton" snapshot intended for an |
| 25 | +`/api/graph.bin` route that does not exist. Its only observable effects were |
| 26 | +overwriting the shared full snapshot (36,931 → 31 nodes) and flipping the |
| 27 | +complete-snapshot counts to `None` — which then forced cold rebuilds on the |
| 28 | +next launch. |
| 29 | + |
| 30 | +## The fix |
| 31 | + |
| 32 | +- **New `mcp_server/server/viz_instance.py`** — an instance registry at |
| 33 | + `~/.cache/cortex/viz-server.json` (`{pid, port, started_at}`): |
| 34 | + - `reusable_instance()` probes the registered server and reuses it when it |
| 35 | + is alive, responding, and *source-current* (`is_current` compares the |
| 36 | + newest source mtime, excluding `__pycache__`, against the instance start). |
| 37 | + - `kill_and_wait()` stops stale instances deterministically |
| 38 | + (SIGTERM → wait → SIGKILL), reaping the instance's own zombie children |
| 39 | + via `waitpid(WNOHANG)` so the wait cannot stall. |
| 40 | +- `http_standalone.py` registers the actually-bound port right after bind. |
| 41 | +- `http_launcher.py` consults the registry before killing/spawning, and |
| 42 | + `_kill_port` now waits for process exit instead of fire-and-forget. |
| 43 | +- `visualize_bootstrap.py` prints `ok reused pid=N url=...` on the reuse path |
| 44 | + and reports the ACTUAL bound port. |
| 45 | +- `open_visualization` handler parses the bootstrap's `url=` line, probes it, |
| 46 | + and **skips `launch_server`** when the bootstrap already produced a live |
| 47 | + server — eliminating the double-spawn race. Dead `_prepare_layout` removed. |
| 48 | +- The skeleton-snapshot write was **removed** outright (it fed a nonexistent |
| 49 | + route). |
| 50 | + |
| 51 | +## Verification |
| 52 | + |
| 53 | +- 30 viz tests pass (13 new in `tests_py/server/test_viz_instance.py`; |
| 54 | + handler tests made hermetic by stubbing `_find_dev_source`). |
| 55 | +- `tests_py/server/` — 63 passed; ruff clean. |
| 56 | +- Smoke test: run 1 spawned and registered pid 71693 on port 3458; run 2 |
| 57 | + reused the same pid. Full snapshot intact at 45,332 nodes afterwards. |
0 commit comments