7d00f77372
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>
136 lines
5.2 KiB
Python
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())
|