Files
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

290 lines
13 KiB
C

// 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 <foundation/mem.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 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);
}