Skip to content

Commit ec47a62

Browse files
jgravelleclaude
andcommitted
release: v1.108.172 — opt-in idle index-cache TTL + process presence registry
Follow-up to #375. A user found 25+ live jcodemunch instances on one box against a single ~140MB store, ages to 1d15h, mostly stdio servers their MCP client never reaped at session end. Reaping the day-plus-old ones freed ~17 GB. The leak is not ours: every background thread is already daemon=True and run_stdio_server exits on stdin EOF. We cannot reap another program's children. The MEMORY is ours. _index_cache holds up to 32 hydrated CodeIndex objects and released them only under LRU pressure, never on idle, so an abandoned process sat on every index it had ever touched. #370 was the same fact seen from the other end: one worker at ~16 GiB. (1) JCODEMUNCH_INDEX_CACHE_TTL, opt-in and DEFAULT OFF. It must stay off: cold hydration of that 665k-symbol index measured 7.5 to 11.4 minutes, so evicting during a quiet spell hands the next query that bill. Defaulting it on would fix the leaking box by breaking the large-index one. Unset, 0, negative and unparseable all disable, byte-identical to before. Swept on access, not by a timer thread: a leaked process makes no calls, so it needs no timer to stay small, and adding a thread to every server to solve a too-many-servers problem would be perverse. (2) storage/process_registry.py makes the sprawl visible, since neither reporter could see it until they went looking. One small file per server, removed on exit, readers prune by PID liveness so a hard kill leaves nothing behind and no daemon is needed to keep it honest. Surfaced as get_session_stats.processes, with a hint only past 5 live processes so a normal setup stays quiet, and wrapped so a registry failure can never fail a stats call. Disclosed in the README background-behavior section, which is the compliance surface for anything that writes files. (3) Fixed a shared primitive the registry uncovered. _is_pid_alive reported dead Windows processes as alive: a successful OpenProcess does not mean running, because while any handle remains open the PID stays queryable after exit, and the spawning parent normally holds exactly such a handle. Blast radius beyond sprawl: process_locks.inspect treats a dead holder's lock as stale and ignorable, so a crashed server's lock could look permanently held. Now checks GetExitCodeProcess for STILL_ACTIVE and falls back to the old answer on failure rather than calling a possibly-live process dead. Also added the missing argtypes/restype: OpenProcess returns a pointer-sized HANDLE and ctypes defaults to c_int, truncating it on 64-bit, the same trap already documented for GetProcessTimes. How (3) was found is worth recording: the first sprawl test showed a killed child still counted as live, and I had labelled that output line "dead entry pruned" before reading it. The label was wishful; the data was not. tests/test_v1_108_172.py (21), including a real spawn-and-kill that fails against the pre-fix liveness logic. The 63 existing lock tests stay green. Suite 5737 passed + the known 12 local-ONNX semantic env failures. NO schema, tool-count, or INDEX_VERSION change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2a18ccf commit ec47a62

10 files changed

Lines changed: 621 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,64 @@
22

33
All notable changes to jcodemunch-mcp are documented here.
44

5+
## [1.108.172] - 2026-07-25 - idle index-cache TTL (opt-in) + process presence registry
6+
7+
### Added
8+
9+
- **`get_session_stats` now reports how many jCodeMunch processes share this
10+
index store.** Follow-up to
11+
[#375](https://github.com/jgravelle/jcodemunch-mcp/issues/375), where a user
12+
found **25+ live instances** on one box against a single ~140MB store, ages to
13+
1d15h, mostly stdio servers their MCP client never reaped at session end.
14+
Reaping the day-plus-old ones freed **~17 GB of RAM**. Nobody could see it.
15+
16+
We cannot reap another program's children. New `storage/process_registry.py`
17+
makes the sprawl visible instead: each server writes one small
18+
`~/.code-index/_processes/<pid>.json` (pid, client, transport, version, start
19+
time — no repos, paths, or queries) and removes it on exit. Readers filter by
20+
PID liveness and prune what they find dead, so a hard kill leaves nothing
21+
behind and there is no daemon keeping the registry honest. Surfaced as a
22+
`processes` block; a hint naming the memory lever appears only past 5 live
23+
processes, so a normal setup stays quiet. Wrapped so a registry failure can
24+
never fail a stats call. Disclosed in the README background-behavior section.
25+
26+
- **`JCODEMUNCH_INDEX_CACHE_TTL`: opt-in idle eviction for the in-memory index
27+
cache.** A hydrated `CodeIndex` is large and was released only under LRU
28+
pressure (`_CACHE_MAX_SIZE = 32`), never on idle, so an abandoned process sat
29+
on every index it had touched. The same fact appeared in #370 as one worker at
30+
~16 GiB.
31+
32+
⚠ **Disabled by default, and it must stay that way.** Cold hydration of that
33+
665k-symbol index was measured at 7.5 to 11.4 minutes, so a TTL that evicts
34+
during a quiet spell hands the next query that bill. Defaulting it on would
35+
fix the leaking box by breaking the large-index one. Unset, `0`, negative, or
36+
unparseable all mean disabled, which is byte-identical to previous behavior.
37+
Swept on access rather than by a timer thread: a leaked process makes no
38+
calls, so it needs no timer to stay small.
39+
40+
### Fixed
41+
42+
- **`_is_pid_alive` reported dead Windows processes as alive.** Uncovered while
43+
testing the registry. A successful `OpenProcess` does not mean the process is
44+
running: while any handle to it remains open the PID stays queryable after
45+
exit, and the parent that spawned it normally holds exactly such a handle. So
46+
a killed child read as alive.
47+
48+
This is a shared primitive. `process_locks.inspect` treats a dead holder's
49+
lock as stale and ignorable, so a crashed server's lock file could look
50+
permanently held. Now checks `GetExitCodeProcess` for `STILL_ACTIVE`, and
51+
falls back to the old answer if the call fails rather than declaring a
52+
possibly-live process dead. Also sets the `argtypes`/`restype` that were
53+
missing: `OpenProcess` returns a pointer-sized HANDLE and ctypes defaults the
54+
return type to `c_int`, truncating it on 64-bit — the same trap already
55+
documented for `GetProcessTimes`. On POSIX, a zombie now reads as dead via
56+
`/proc/<pid>/stat` (best effort; reap state is only visible for our own
57+
children).
58+
59+
New `tests/test_v1_108_172.py` (21), including a real spawn-and-kill check that
60+
fails against the pre-fix liveness logic. NO schema, tool-count, or
61+
INDEX_VERSION change.
62+
563
## [1.108.171] - 2026-07-25 - provider discovery no longer scans in quadratic time
664

765
### Fixed

CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# jcodemunch-mcp — Project Brief
22

33
## Current State
4-
- **Version:** 1.108.171 — **Provider discovery scanned in QUADRATIC time; one large file hung the whole index before the file walk even started (#375).** Reported on Ubuntu: `index_folder` over a ~40-file subtree ran **4m0s at ~99% CPU**, emitted no progress and no log line, and never finished; with `context_providers` off the same index took **5.1s**. The reporter's mid-stall `py-spy` pinned every sample in `ExpressProvider._extract_mounts` via `discover_providers` -> `_resolve_active_providers` — which runs **BEFORE the file walk**, so nothing had happened yet to report. ⚠ **Mechanism: a leading uncaptured `(?:\w+)` before the `\.`. It asserts nothing the `.` doesn't already imply, but at every offset inside an unbroken word run the engine matches the run greedily, fails on the `.`, then backtracks a character at a time — quadratic. ONE minified chunk or base64 blob in a single source file is enough.** ⚠ **py-spy named ONE pattern; screening every compiled regex in `parser/context/` EMPIRICALLY (4k vs 16k input, quadratic shows ~16x for 4x) found SIX across two providers: `_JS_ROUTE`/`_JS_MIDDLEWARE`/`_JS_MOUNT` + `_GO_ROUTE`/`_GO_MIDDLEWARE`/`_GO_GROUP`. Fixing only the reported one would have left five live — screen, don't read.** Measured on a lone `\w` run, before -> after: 8k 0.45s -> 0.00001s, 32k 7.5s -> 0.00002s, 128k **120s+** -> 0.00006s; all provider regexes over 128k now total 0.0104s. Extraction unchanged (group was uncaptured) and marginally more permissive (now also matches `getRouter().use(...)`). New `tests/test_v1_108_171.py` (11): the scaling guard is **empirical, not a pattern-text assertion** (a future provider can regress with different syntax), plus non-vacuity + a named pin on the six. ⚠ **Guard sizing matters: the blob was first 128k, which made a real regression take 120s+ to FAIL — a guard that hangs CI is worse than no guard. 32k keeps three orders of magnitude of separation and fails in 11.6s.** NOT shipped: the reporter's per-provider time budget (removes the CLASS, but silently skipping a provider needs a disclosure design). Suite 5716. No schema/tool-count/INDEX_VERSION change.
4+
- **Version:** 1.108.172 — **Idle index-cache TTL (OPT-IN) + a process presence registry, because instance sprawl was invisible (#375 follow-up).** A user found **25+ live jcm instances** on one box against ONE ~140MB store, ages to 1d15h, mostly stdio servers **their MCP client never reaped at session end**; reaping the day-plus-old ones freed **~17 GB**. ⚠ **We cannot reap another program's children — all our threads are already `daemon=True` and `run_stdio_server` exits on stdin EOF. The leak is client-side; the MEMORY is ours.** `_index_cache` holds up to `_CACHE_MAX_SIZE=32` hydrated CodeIndex objects released ONLY under LRU pressure, never on idle — the same fact that showed up in #370 as one worker at ~16 GiB. **(1)** New `JCODEMUNCH_INDEX_CACHE_TTL`; ⚠ **DEFAULT 0 = OFF AND MUST STAY OFF — cold hydration of that 665k-symbol index measured 7.5-11.4 MINUTES (#370), so evicting on idle hands the next query that bill. Defaulting it on fixes the leaking box by breaking the large-index one.** Unset/0/negative/garbage all disable (byte-identical to before); swept on access, NO timer thread (a leaked process makes no calls, so it needs no timer to stay small). **(2)** New `storage/process_registry.py`: one `~/.code-index/_processes/<pid>.json` per server (pid/client/transport/version/started; no repos, paths, or queries), removed on exit, readers prune by PID liveness so a hard kill leaves nothing. Surfaced as `get_session_stats.processes`; hint only past 5 live procs; wrapped so a registry failure can never fail a stats call. **README background-behavior section updated — that is the PyPI-compliance surface for anything that writes files.** **(3) ⚠ FIXED A SHARED PRIMITIVE the registry uncovered: `_is_pid_alive` reported DEAD Windows processes as ALIVE.** A successful `OpenProcess` does NOT mean running — while ANY handle remains open the PID stays queryable after exit, and **the spawning parent normally holds exactly such a handle**. Blast radius beyond sprawl: `process_locks.inspect` treats a dead holder's lock as stale/ignorable, so **a crashed server's lock could look permanently held**. Now checks `GetExitCodeProcess` for `STILL_ACTIVE`, falling back to the old answer on failure rather than calling a possibly-live process dead; POSIX zombies read dead via `/proc/<pid>/stat`. ⚠ **Also added the missing `argtypes`/`restype`: `OpenProcess` returns a pointer-sized HANDLE and ctypes defaults to `c_int`, TRUNCATING it on 64-bit — the same trap already documented for `GetProcessTimes`.** New `tests/test_v1_108_172.py` (21) incl. a real spawn-and-kill that fails pre-fix. No schema/tool-count/INDEX_VERSION change.
5+
- **Prior (1.108.171):** — **Provider discovery scanned in QUADRATIC time; one large file hung the whole index before the file walk even started (#375).** Reported on Ubuntu: `index_folder` over a ~40-file subtree ran **4m0s at ~99% CPU**, emitted no progress and no log line, and never finished; with `context_providers` off the same index took **5.1s**. The reporter's mid-stall `py-spy` pinned every sample in `ExpressProvider._extract_mounts` via `discover_providers` -> `_resolve_active_providers` — which runs **BEFORE the file walk**, so nothing had happened yet to report. ⚠ **Mechanism: a leading uncaptured `(?:\w+)` before the `\.`. It asserts nothing the `.` doesn't already imply, but at every offset inside an unbroken word run the engine matches the run greedily, fails on the `.`, then backtracks a character at a time — quadratic. ONE minified chunk or base64 blob in a single source file is enough.** ⚠ **py-spy named ONE pattern; screening every compiled regex in `parser/context/` EMPIRICALLY (4k vs 16k input, quadratic shows ~16x for 4x) found SIX across two providers: `_JS_ROUTE`/`_JS_MIDDLEWARE`/`_JS_MOUNT` + `_GO_ROUTE`/`_GO_MIDDLEWARE`/`_GO_GROUP`. Fixing only the reported one would have left five live — screen, don't read.** Measured on a lone `\w` run, before -> after: 8k 0.45s -> 0.00001s, 32k 7.5s -> 0.00002s, 128k **120s+** -> 0.00006s; all provider regexes over 128k now total 0.0104s. Extraction unchanged (group was uncaptured) and marginally more permissive (now also matches `getRouter().use(...)`). New `tests/test_v1_108_171.py` (11): the scaling guard is **empirical, not a pattern-text assertion** (a future provider can regress with different syntax), plus non-vacuity + a named pin on the six. ⚠ **Guard sizing matters: the blob was first 128k, which made a real regression take 120s+ to FAIL — a guard that hangs CI is worse than no guard. 32k keeps three orders of magnitude of separation and fails in 11.6s.** NOT shipped: the reporter's per-provider time budget (removes the CLASS, but silently skipping a provider needs a disclosure design). Suite 5716. No schema/tool-count/INDEX_VERSION change.
56
- **Prior (1.108.170):** — **The file and symbol tools can see a rebuild too (third and last instance of the week's class).** `.168` added the 5th absence-refusal rule to `build_verdict`; `.169` wired the last search tool in. But `get_file_content` / `get_file_outline` / `get_symbol_source` **never call `build_verdict`** — they route through `symbol_verdict_for_index` / `file_verdict_for_index`, which take a live index and delegate to `build_symbol_verdict` / `build_file_verdict`, **neither of which had an `index_changed` param at all**. Both reach `state:"absent"`, and the absence chokepoint is GENERIC (fires on any `_meta.verdict` dict), so both minted citable `absent:<sha>` refs with the rebuilding rule structurally unable to fire. **A rebuild deletes+reinserts rows, so a genuinely-present file reads as missing for the duration — this is the LIKELIEST way to see a false absence, not the rarest.** Both builders now take `index_changed` → `degraded` + `channels.index:"rebuilding"`; both wrappers pass `index_changed_since_load`. Two calls: **`did_you_mean` SUPPRESSED on the degraded path** (near-miss names for a scan that couldn't see the index is worse than silence), and **`empty_symbols` degrades too** (its note claims "re-requesting the outline will not change this" — actively wrong mid-rewrite). New **`tests/test_absence_wiring_guard.py` (7)** encodes the INVARIANT not the instances: every `build_verdict` call in `tools/` must pass `index_changed=`; the scan is **alias-aware** (every tool imports it renamed — a plain name match finds NOTHING and passes vacuously, which is what `test_the_guard_is_not_vacuous` caught); the wrappers sit in a **ratchet** (`KNOWN_UNWIRED_WRAPPERS`, now EMPTY, kept as the declaration point — the test FAILS if a listed wrapper turns out to be wired, so a stale entry can't rot into an exemption). ⚠ **Known limit: the guard covers `build_verdict` call sites + the two wrappers. A future tool inventing a THIRD way to emit `_meta.verdict` is still served unchecked by the chokepoint — enforcing AT the chokepoint is the larger fix, not done.** No schema/tool-count/INDEX_VERSION change; suite 5680.
6-
- **Prior (1.108.169):** — **The retrieval verdict survives compaction.** TWO defects in contracts shipped days earlier. **(1)** `schema_driven.encode` filters `_meta` through a STRICT ALLOWLIST and `verdict` was in **none of the 15 schema files** — so under MUNCH encoding the whole verdict contract was invisible (`state`, `channels`, `coverage`, `scorer`, `did_you_mean`). Tools WITHOUT a custom encoder (`get_file_content`) kept theirs, so the signal was present exactly where least needed and absent on `search_symbols`/`search_text`/`get_ranked_context` — **the three tools the absence contract is built on**. Sharpest consequence: `server.py` mints the citable ref onto `_meta.verdict.evidence_ref` and compact callers NEVER RECEIVED IT, along with `absence_citable`/`absence_blocked_by`. **The token-saving layer was eating the safety layer.** ⚠ **`meta_keys` could NOT be reused** — it flattens to a scalar and would stringify the verdict into a Python repr. New **`meta_json_blobs`** param mirrors the existing `json_blobs` mechanism (`__json._meta.<key>`); all 15 schemas declare `_META_JSON = ("verdict",)`. ⚠ **9 of 15 call sites use a `meta_keys=_META)` form with no trailing comma — a naive replace misses them, INCLUDING `search_symbols`.** **(2)** `search_text` was never wired for the .168 rebuilding rule (only `search_symbols` + `get_ranked_context` were), so it could reach `absent` and mint a ref while blind to a rewrite under its own scan. **Honest cost: +58 tok on a 20-result encoded search (+18.6%), +89 on a zero-result.** Carried at FULL FIDELITY — trimming inside the encoder would make compact and JSON disagree, which is the class of bug being fixed. ⚠ **The sdist ships `tests/`** — an uncommitted test in the tree WILL ride into the wheel; move it aside before `python -m build` or verify the artifact.
7-
- **Older releases (1.108.167 and earlier):** see `CHANGELOG.md` — the authoritative version history (411 entries, 1.0.0 onward).
7+
- **Older releases (1.108.169 and earlier):** see `CHANGELOG.md` — the authoritative version history (411 entries, 1.0.0 onward).
88
- **INDEX_VERSION:** 17
99
- **Tests:** 5716 passed, 7 skipped (1.108.171) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
1010
- **Python:** >=3.10
@@ -235,6 +235,7 @@ Tree-sitter grammar lacks clean named fields for these — custom regex extracto
235235
| `JCODEMUNCH_ORG_ENDPOINT` || Org host URL that `org-report` POSTs seat savings to (`/org/report`); unset = record locally |
236236
| `JCODEMUNCH_ORG_INGEST_ENABLED` | 0 | Set 1 on the org host to accept `POST /org/report` (two-key turn with `JCODEMUNCH_HTTP_TOKEN`) |
237237
| `JCODEMUNCH_LICENSE_KEY` || (v1.108.42) jCodeMunch license key (config key `license_key`). Gates the `org-rollup` team feature ONLY; everything else is free. Validated online vs `validate.php` (sticky-offline cache; 14-day grace for new orgs). **Requires a multi-seat tier — Studio or Platform** (v1.108.43); Builder doesn't unlock org-rollup. Check with the `license` CLI. |
238+
| `JCODEMUNCH_INDEX_CACHE_TTL` | 0 (off) | (v1.108.172) Seconds an unused hydrated index may sit in the in-memory cache before being released. **OPT-IN: 0/unset/garbage = disabled = today's behavior exactly.****Do NOT default this on** — cold hydration of a 665k-symbol index was measured at 7.5-11.4 min (#370), so evicting during a quiet spell hands the next query that bill. For hosts whose MCP client leaks stdio servers (#375: 25+ instances, ~17 GB), where each idle process otherwise sits on its own cache. Swept on access, no timer thread. |
238239
| `JCODEMUNCH_SCIP_MAX_ROWS` | 200000 | (v1.108.96) Row cap for `scip_edges` / `scip_unmapped` (compile-time evidence from `import-scip`); FIFO-evicted oldest-first in 1k batches. Negative disables the cap; env-only, deliberately not a config key. |
239240
| `JCODEMUNCH_LAUNCH_ID` || (v1.108.152) Opaque host-supplied launch token echoed back as `launch_id` in the `munch://runtime/identity` resource (#371). Fallback: suite-generic `MUNCH_LAUNCH_ID`. Omitted from the payload when unset. Env-only, not a config key. |
240241

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ Everything jCodeMunch does beyond answering a tool call is listed here. All of i
384384
- **Agent hooks.** `init` / `install` can write hook entries (auto-reindex on edit, read-interception nudges) into your MCP client's settings. They're offered during the interactive flow, shown before writing, and fully removed by `uninstall`.
385385
- **Local index storage.** Indexes live at `~/.code-index/` (override with `CODE_INDEX_PATH`). Delete the directory and every trace of indexing is gone.
386386
- **Live session journal.** While the server runs, it periodically writes a small `_session_live.json` in `~/.code-index/` recording the files and searches the agent touched this session (paths and query strings only, no file contents). It exists so the out-of-process PreCompact hook can restore session orientation after context compaction. Throttled, atomically written, overwritten in place; disable with `JCODEMUNCH_LIVE_JOURNAL=0`.
387+
- **Process presence file.** While a server runs, it writes one small JSON file at `~/.code-index/_processes/<pid>.json` recording its own PID, transport, version, start time, and the launching client's name — nothing about your code, repos, or queries. It exists so `get_session_stats` can tell you how many jCodeMunch servers are sharing one index store: some MCP clients don't reap stdio servers at session end, and they accumulate invisibly (one user found 25+ holding ~17 GB between them). The file is removed on exit, and any reader prunes entries whose process is no longer alive, so a hard kill leaves nothing behind. No daemon, no timer, no network.
387388
- **User-invoked network calls.** A few commands you run explicitly reach the network. None run in the background or fire on a plain import; each happens only when you invoke the command:
388389
- **License validation.** `license`, `org-rollup`, and `install-pack --license` send your license key to `validate.php` on `j.gravelle.us` to confirm it. The key travels in the request body / a header, never the URL, so it can't land in intermediary access logs. This gates only the team `org-rollup` feature; the individual tools never call it.
389390
- **Starter-pack download.** `install-pack` fetches the pack catalog and any pre-built index pack you request from `j.gravelle.us` (a premium pack also sends your license key).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.171"
3+
version = "1.108.172"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

0 commit comments

Comments
 (0)