// repro_issue581.c -- Reproduce-first case for OPEN bug #581. // // Issue: #581 -- "Memory leak: process grows to 50+ GB virtual memory over // hours/days, crashes Windows" // https://github.com/DeusData/codebase-memory-mcp/issues/581 // // OBSERVED BEHAVIOUR: // codebase-memory-mcp in stdio MCP server mode grows from ~12 MB working // set to 50-107 GB virtual memory over 12-48 hours while the agent issues // repeated queries (search_graph, query_graph, get_architecture, etc.). // The reporter confirmed auto_index=false, so indexing is NOT the growth // path -- the leak occurs purely from query/read operations. // // ROOT-CAUSE HYPOTHESIS (two-part): // // 1. SQLite WAL file: every query-only store open uses WAL journal mode // (configure_pragmas, store.c:343) and mmap_size=64 MB // (store.c:355-358). The WAL file accumulates un-checkpointed frames // on every write-side flush (which happens from other operations even // on a "read-only" query session because SQLite WAL readers also // participate in the WAL protocol). The only checkpoint in the MCP // event loop is SQLITE_CHECKPOINT_PASSIVE, which never ftruncates // (mcp.c:869). Over thousands of operations the WAL grows without // bound, with each page mapped via mmap into virtual address space. // // 2. mimalloc page retention: cbm_mem_collect() is called after // index_repository (mcp.c:2866, 4616) and after delete_project // (mcp.c:1860), but NEVER after query operations. mimalloc retains // freed arena pages in its internal free-lists so they show up as // committed virtual memory (visible on Windows as "commit charge") // even after the query result is freed. // // The combination -- SQLite WAL mapped pages + mimalloc retained pages // not returned to OS -- accumulates monotonically across thousands of // query iterations without any compaction trigger. // // BOUNDED REPRODUCTION STRATEGY: // Repeat a single MCP query tool call (search_graph) N=150 times against // a small indexed project. Measure current RSS (not peak) at warmup // (iteration 10) and at the end (iteration 150). Assert that end RSS is // not more than LEAK_FACTOR x warmup RSS. // // The real-world leak is 50 GB over hours (~thousands of operations). // Per-query accumulation is therefore large but the signal over 150 // iterations is proportionally small. We choose a generous threshold // (3.0x) so a truly bounded implementation passes easily, while a // genuinely leaking implementation that retains ~10-100 kB per query // accumulates enough to exceed 3x warmup after 150 iterations (at // 10 kB/call on a 30 MB baseline: 30 MB + 1.5 MB = 1.05x -- borderline). // // IMPORTANT CAVEATS / FLAKINESS NOTES: // // (a) RSS MEASUREMENT: we use cbm_mem_rss() (src/foundation/mem.c) which // calls mi_process_info() for current RSS, or falls back to // /proc/self/statm (Linux), mach_task_basic_info.resident_size (macOS), // or GetProcessMemoryInfo.WorkingSetSize (Windows). This is CURRENT // RSS, not peak -- suitable for detecting steady-state growth. // // (b) ASan BUILD PITFALL: the repro runner uses ASAN_OPTIONS=detect_leaks=0, // so LSan won't catch this class of leak here (mimalloc/WAL accumulated // pages are not classically leaked -- they are reachable but never freed). // This test is an RSS-growth test, not a LSan test. ASan instrumentation // inflates per-allocation overhead ~3x; iteration count (150) is chosen // conservatively to stay well within CI time budgets even with ASan. // // (c) THRESHOLD 3.0x: the warmup RSS includes the full SQLite page cache // and mimalloc initial arenas. On an 8-core machine warmup may be // 50-100 MB; 3x would be 150-300 MB, achievable with a bad leak rate of // ~1 MB/query over 150 queries. On a FIXED implementation the end RSS // should be close to 1.0-1.2x warmup (GC cycle, small jitter). // If this test produces a false FAIL on a correct implementation (warmup // RSS is very small, e.g. 5 MB, and allocator variance causes spike), the // threshold can be increased to 4x or the warmup moved later; this is // documented as a known-fragile point. // // (d) LINUX-ONLY ALTERNATIVE: if cbm_mem_rss() returns 0 (e.g. MI_OVERRIDE=0 // without the OS fallback compiled), the test falls back to reading // /proc/self/statm directly below. On macOS and Windows cbm_mem_rss() // is expected to return non-zero. If all RSS readings are zero the test // is declared inconclusive and PASSES to avoid false failures (the // growth assertion requires reliable RSS readings). // // FIX LOCATION (not implemented here -- this test must stay RED until fixed): // Two complementary fixes are needed: // 1. src/mcp/mcp.c, cbm_mcp_server_run event loop (or after each tool call // in cbm_mcp_handle_tool): periodically call // sqlite3_wal_checkpoint_v2(..., SQLITE_CHECKPOINT_TRUNCATE, ...) // and cbm_mem_collect() after query bursts (e.g. every N=50 calls or // after exceeding a RSS threshold via cbm_mem_over_budget()). // 2. src/mcp/mcp.c, cbm_mcp_server_evict_idle: on idle eviction, call // cbm_mem_collect() so mimalloc returns pages to the OS, matching the // same pattern used after index_repository. // // Without both fixes the WAL and mimalloc page pools grow monotonically // across a long-running server session. #include "test_framework.h" #include "repro_harness.h" #include #include #include #include // Number of search_graph calls per trial. // 10 warmup + 140 measurement = 150 total. // Deliberately modest to stay within CI time budgets even with ASan. #define ITER_WARMUP 10 #define ITER_TOTAL 150 // Generous RSS growth multiplier: end RSS must not exceed LEAK_FACTOR x // warmup RSS. A correct implementation stays near 1.0-1.2x; a leaking // implementation grows linearly. // Set to 3.0 to tolerate allocator variance while still catching a real leak // of >1 MB per query over 140 post-warmup iterations. #define LEAK_FACTOR 3.0 // Fallback current-RSS reader for Linux, used if cbm_mem_rss() returns 0 // (MI_OVERRIDE=0 with no OS fallback compiled in). Returns 0 if unavailable. static size_t rss_bytes(void) { size_t v = cbm_mem_rss(); if (v > 0) { return v; } #if defined(__linux__) // /proc/self/statm: fields are "VmSize VmRSS ..." in pages FILE *f = fopen("/proc/self/statm", "r"); if (!f) { return 0; } unsigned long vm_pages = 0; unsigned long rss_pages = 0; if (fscanf(f, "%lu %lu", &vm_pages, &rss_pages) != 2) { rss_pages = 0; } fclose(f); long ps = sysconf(_SC_PAGESIZE); return rss_pages * (size_t)(ps > 0 ? (unsigned long)ps : 4096UL); #else return 0; #endif } // Small fixture: a tiny Python module with a few functions. // Chosen to produce a small but real graph (~5 nodes/edges) so that // search_graph hits the actual SQLite code path including FTS5 lookup, // node scan, and JSON serialisation -- replicating the real query workload. static const char FIXTURE_PY[] = "def add(a, b):\n" " return a + b\n" "\n" "def multiply(a, b):\n" " result = a * b\n" " return result\n" "\n" "def greet(name):\n" " msg = 'hello ' + name\n" " print(msg)\n" " return msg\n"; // search_graph args JSON for repeated queries. // Uses a broad name_pattern so results are always non-empty (exercises both // the FTS5 and regex code paths and forces JSON result allocation + free). static const char SEARCH_ARGS[] = "{\"project\":\"__PROJ__\"," "\"name_pattern\":\".*\"," "\"limit\":10}"; // Build the args string with the real project name substituted. // Caller must free the returned string. static char *build_search_args(const char *project) { const char *tmpl = SEARCH_ARGS; const char *marker = "__PROJ__"; const char *pos = strstr(tmpl, marker); if (!pos || !project) { return NULL; } size_t prefix_len = (size_t)(pos - tmpl); size_t proj_len = strlen(project); size_t suffix_len = strlen(pos + strlen(marker)); size_t total = prefix_len + proj_len + suffix_len + 1; char *out = malloc(total); if (!out) { return NULL; } memcpy(out, tmpl, prefix_len); memcpy(out + prefix_len, project, proj_len); memcpy(out + prefix_len + proj_len, pos + strlen(marker), suffix_len + 1); return out; } // repro_issue581_query_rss_stable // // Asserts that RSS does not grow monotonically when search_graph is called // repeatedly against a single indexed project. // // RED on current code: // SQLite WAL frames + mimalloc retained pages accumulate across iterations. // After ITER_TOTAL iterations the RSS exceeds LEAK_FACTOR x warmup RSS. // The ASSERT below fires -> RED. // // GREEN after fix: // cbm_mem_collect() and/or TRUNCATE checkpoint called periodically by the // MCP event loop (or after tool calls) return pages to OS. End RSS stays // near warmup RSS (jitter only) -> assertion passes -> GREEN. // // NOTE on ITER_WARMUP/ITER_TOTAL calibration: // The real leak is ~10 GB/day with an active agent (rough rate: // 10 GB / 86400 s * avg call interval). We cannot reproduce that scale // in CI, so we rely on the leak being MONOTONIC -- any growth per iteration // shows up as a slope over 150 iterations. If the leak rate is so slow // that even 150x does not visibly move RSS beyond allocator jitter, this // test may not fire RED on every CI run (documented flakiness risk above). TEST(repro_issue581_query_rss_stable) { RFile files[] = {{"module.py", FIXTURE_PY}}; RProj lp; cbm_store_t *store = rh_index_files(&lp, files, 1); ASSERT_NOT_NULL(store); // Project name from the harness. const char *project = lp.project; ASSERT_NOT_NULL(project); char *args = build_search_args(project); ASSERT_NOT_NULL(args); size_t rss_warmup = 0; size_t rss_end = 0; for (int i = 0; i < ITER_TOTAL; i++) { char *resp = cbm_mcp_handle_tool(lp.srv, "search_graph", args); // The response must be freed on every call -- verifying the MCP layer // does not itself accumulate the result (it doesn't; the leak is lower). if (resp) { free(resp); } if (i + 1 == ITER_WARMUP) { rss_warmup = rss_bytes(); } } rss_end = rss_bytes(); free(args); rh_cleanup(&lp, store); if (rss_warmup > 0 && rss_end > 0) { printf(" rss_warmup_kb=%zu rss_end_kb=%zu factor=%.2f threshold=%.1f\n", rss_warmup / 1024, rss_end / 1024, (double)rss_end / (double)rss_warmup, LEAK_FACTOR); } else { printf(" NOTE: RSS not measurable on this platform/build\n"); } // HONEST RED — this guard is currently VACUOUS and #581 is OPEN. // // This fixture CANNOT reproduce the leak: a 3-node graph over 150 // search_graph calls allocates far too little to move process RSS (observed // factor=1.00), so the old "rss_end <= 3.0 x rss_warmup" assertion passed // even on the leaking build. A green here would mean "leak fixed" while the // leak is unfixed — a false guard that violates the tests-are-guards rule // (green <=> fixed). So it stays RED. // // Turning this GREEN legitimately requires BOTH: // (a) a real reproduction tier — a long-running MCP session issuing // thousands of ops against a LARGE graph, measuring the SQLite WAL // file size and mimalloc committed pages DIRECTLY (not process-RSS // jitter) so the monotonic growth is actually observable; AND // (b) the fix — periodic SQLITE_CHECKPOINT_TRUNCATE + cbm_mem_collect() in // the MCP query loop / idle eviction (see the header + #581). // // Until both land this is an honest "not fixed / not provable here" RED, not // a false green. /* TODO(#581): whitelisted known-red on the non-gating bug-repro board. The * leak is a real OPEN bug; this fixture cannot yet reproduce it, so the test * stays RED (honest "not fixed") rather than vacuously green. Turning it * green requires a real WAL-size / mimalloc-committed-pages reproduction tier * plus the query-path compaction fix (see header). Tracked, not skipped. */ FAIL("TODO(#581) whitelisted known-red: query-path memory leak is OPEN and " "cannot be reproduced in this fixture (RSS factor ~1.0 even when " "leaking) — needs a real WAL/committed-pages reproduction tier plus the " "query-path compaction fix"); } // -- Suite ------------------------------------------------------------------ SUITE(repro_issue581) { RUN_TEST(repro_issue581_query_rss_stable); }