Files
deusdata--codebase-memory-mcp/tests/repro/issue832_rss.py
Martin Vogel 7d00f77372 fix(index): run background auto-index + watcher re-index in the supervised subprocess (RSS isolation)
The long-lived MCP server ran the full index pipeline in-process on two
background paths -- the session auto-index (autoindex_thread, mcp.c) and the
watcher re-index (watcher_index_fn, main.c). The parallel pipeline's worker
threads abandon their mimalloc pages at thread exit, and mimalloc v3 defaults
page_reclaim_on_free=0, so those pages are never reclaimed when the main thread
later frees their blocks (mi_collect cannot touch abandoned pages either). RSS
then ratchets across re-index cycles and never comes back down (#832).

The index_repository tool path already avoided this by running the index in a
supervised worker subprocess: on child exit the kernel reclaims 100% of the
child's RSS. Route the two background paths through that same supervised runner:

- supervisor_invalidate_store() is now NULL-safe (the watcher path has no MCP
  server / cached store).
- New static index_run_supervised_path() builds {"repo_path": root} and reuses
  the existing spawn + skip-and-continue recovery loop (no duplication). New
  public cbm_mcp_index_run_supervised_path() is the srv-less entry main.c calls.
- autoindex_thread and watcher_index_fn gate on cbm_index_supervisor_should_wrap():
  supervised on a marked host with the kill switch off, else fall through to the
  unchanged in-process pipeline (kill switch / spawn-failure degrade). The
  watcher keeps its non-blocking try_lock gate + g_shutdown check exactly; the
  lock still serialises re-indexes. cbm_mem_collect() stays on the in-process
  branch only.

Also fold in the in-process allocator fallback: cbm_mem_init sets
mi_option_page_reclaim_on_free=1, restoring v2 behaviour so any path that stays
in-process (kill switch, degrade, embedders) also reclaims abandoned worker
pages. The subprocess is the primary cure (child exit returns RSS every cycle);
this is the fallback.

Tests: a deterministic gating guard (test_mcp.c) proves the background paths now
spawn a worker child and index the fixture (RED when routed in-process:
spawn_count unchanged; GREEN after). The test-runner acts as a real in-process
worker when spawned as `cli --index-worker` (public APIs only, no production
seam). A non-gating prod-binary RSS reproduction (tests/repro/issue832_rss.py)
shows the supervised server flat at ~10 MB vs ~131 MB in-process.

Scope: JobObject, the #841 spurious-trigger fix, retention caps, budget tiering
and the backpressure busy-spin are follow-ups.

Refs #832, #841.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-04 17:23:43 +02:00

136 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""
issue832_rss.py -- direct RSS reproduction for #832 (NON-GATING, manual repro tier).
WHY THIS IS NOT A C UNIT TEST
-----------------------------
The RSS ratchet is a property of mimalloc v3's abandoned-page handling
(page_reclaim_on_free=0): pages a worker THREAD abandons at exit are not
reclaimed when the main thread later frees their blocks. That only manifests in
the PROD binary, which links mimalloc as the global allocator (Makefile.cbm:
MI_OVERRIDE=1). The C test-runner and the C repro-runner are built CRT+ASan
(MI_OVERRIDE=0), so mimalloc is inert there and cbm_mem_rss() falls back to
os_rss() -- a C test would be VACUOUS. Hence this drives the real
`build/c/codebase-memory-mcp` server over stdio and samples its RSS from `ps`.
WHAT IT SHOWS
-------------
A long-lived MCP server is driven through K index_repository cycles of the same
fixture. The in-process pipeline (CBM_INDEX_SUPERVISOR=0) is the pre-#832-fix
background-path behaviour: RSS RATCHETS across cycles. The supervised subprocess
path (default) is the fix: each child returns 100% of its RSS on exit, so the
long-lived parent stays ~FLAT. The auto-index (mcp.c) and watcher re-index
(main.c) paths now route through that same supervised subprocess, so they inherit
this flat profile; the deterministic routing proof is the GATING guard
tests/test_mcp.c::index_bg_paths_route_through_supervisor_issue832.
Inherently noisy (allocator/OS dependent) -> thresholds are generous and this is
NOT wired into `make test` / `ci-ok`. Run manually:
make -f Makefile.cbm cbm
python3 tests/repro/issue832_rss.py
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BINARY = os.path.join(ROOT, "build", "c", "codebase-memory-mcp")
CYCLES = 10
NUM_FILES = 120 # enough files to fan out across worker threads (abandoned heaps)
def rss_kb(pid):
out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(pid)])
return int(out.strip())
def make_fixture(d):
for i in range(NUM_FILES):
with open(os.path.join(d, f"mod_{i}.py"), "w") as f:
for j in range(20):
f.write(f"def fn_{i}_{j}(a, b):\n")
f.write(f" x = a + b + {i} * {j}\n")
f.write(" return x\n\n")
def run_series(repo, cache, supervised):
env = dict(os.environ)
env["CBM_CACHE_DIR"] = cache
if supervised:
env.pop("CBM_INDEX_SUPERVISOR", None)
else:
env["CBM_INDEX_SUPERVISOR"] = "0" # in-process (pre-fix background behaviour)
env["CBM_INDEX_WORKER_TIMEOUT_S"] = "120"
proc = subprocess.Popen(
[BINARY], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, env=env, text=True, bufsize=1,
)
def rpc(obj):
proc.stdin.write(json.dumps(obj) + "\n")
proc.stdin.flush()
return proc.stdout.readline()
rpc({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})
series = []
for k in range(CYCLES):
rpc({"jsonrpc": "2.0", "id": k + 1, "method": "tools/call",
"params": {"name": "index_repository",
"arguments": {"repo_path": repo, "mode": "fast"}}})
series.append(rss_kb(proc.pid))
try:
proc.stdin.close()
proc.wait(timeout=15)
except Exception:
proc.kill()
return series
def main():
if not os.path.exists(BINARY):
print(f"missing prod binary: {BINARY}\n build it: make -f Makefile.cbm cbm")
return 2
base = tempfile.mkdtemp(prefix="cbm-832-")
repo = os.path.join(base, "repo")
os.makedirs(repo)
make_fixture(repo)
try:
inproc = run_series(repo, os.path.join(base, "c1"), supervised=False)
superv = run_series(repo, os.path.join(base, "c2"), supervised=True)
finally:
shutil.rmtree(base, ignore_errors=True)
def mb(kb):
return kb / 1024.0
print(f"cycles={CYCLES} files={NUM_FILES}")
print("cycle | in-process(MB) | supervised(MB)")
for i in range(CYCLES):
print(f" {i:2d} | {mb(inproc[i]):8.1f} | {mb(superv[i]):8.1f}")
ip_peak = max(mb(x) for x in inproc)
sv_peak = max(mb(x) for x in superv)
print(f"\nin-process peak resident: {ip_peak:8.1f} MB")
print(f"supervised peak resident: {sv_peak:8.1f} MB")
# The decisive, robust signal at laptop-fixture scale is the RESIDENT-LEVEL
# contrast, not cycle-over-cycle growth: the in-process server keeps the whole
# index working set resident (it never leaves the long-lived process), while
# the supervised path returns it every cycle (the child exits) -> the server
# stays near its idle baseline. The unbounded ratchet in the field (#832, GB
# over hours) is the same effect amplified by worker-thread count + cycle count
# beyond what a small fixture surfaces. Generous threshold; report-only,
# NON-GATING.
verdict = "SUPERVISED ISOLATION reproduced (server stays near baseline)" \
if sv_peak < ip_peak / 2 \
else "inconclusive (env-dependent; see numbers)"
print(f"verdict: {verdict}")
return 0
if __name__ == "__main__":
sys.exit(main())