mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-03 08:03:38 +08:00
fix(pipeline): destroy TLS parsers at allocator-epoch boundaries
SIGABRT (free of a non-libmalloc pointer in ts_stack_delete) on the second index_repository in one process, whenever a sequential run is followed by a parallel one. The sequential path parses on the CALLING thread and left its TLS parser alive; the next parallel run switches the global tree-sitter allocator to the slab (cbm_slab_install), the calling thread participates in extraction, reuses the stale parser, and its per-file teardown frees mimalloc-epoch memory through slab_free's foreign-pointer fallback -> plain free() -> libmalloc abort. The v0.9.0 supervisor masks this on the default MCP path (fresh worker process per index); the in-process path and every embedded consumer died. Root-caused with allocator-epoch instrumentation: the victim parser is created at epoch 0 in cbm_pipeline_pass_definitions and deleted at epoch 1 in extract_worker. Two boundary fixes: - run_sequential_pipeline destroys the calling thread's parser at run end (also bounds its memory like the parallel workers already do); - cbm_parallel_extract destroys the calling thread's parser BEFORE cbm_slab_install, so no parser ever crosses the allocator switch. Guards: prod-tier smoke invariant inv_second_index_inprocess (sequential-then-parallel double index over stdio, RED with signal 6 on the unfixed binary — ASan builds mask the allocator seam, so the unit tier cannot catch this class) + suite-tier fork-isolated index_second_inprocess_run_survives_issue773 pinning the in-process double-index contract. Repro 5/5 clean after the fix (was 5/5 abort). Closes #773 Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -345,6 +345,48 @@ inv_index_status_cli() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── #773: second in-process index must not SIGABRT ─────────────────────────
|
||||
# Sequential run 1 (mimalloc-epoch parser on the calling thread) followed by
|
||||
# parallel run 2 (global ts allocator switched to the slab). The stale-parser
|
||||
# teardown used to free mi pointers through slab_free -> plain free() and
|
||||
# libmalloc aborted. ASan builds mask the allocator seam — this guard needs
|
||||
# the REAL binary.
|
||||
inv_second_index_inprocess() {
|
||||
local base
|
||||
base=$(mktemp -d "${TMPDIR:-/tmp}/cbm_inv773.XXXXXX")
|
||||
mkdir -p "$base/dirA" "$base/dirB"
|
||||
local i
|
||||
for i in 1 2 3 4 5; do
|
||||
printf 'class H%s:\n def run(self, x):\n return x + %s\n' "$i" "$i" > "$base/dirA/m$i.py"
|
||||
done
|
||||
for i in $(seq 1 60); do
|
||||
printf 'def u_%s(y):\n return y * %s\n' "$i" "$i" > "$base/dirB/u$i.py"
|
||||
done
|
||||
local infile="$base/in.jsonl"
|
||||
{
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"inv773","version":"1"}}}'
|
||||
printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
||||
printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"%s/dirA","mode":"full"}}}\n' "$base"
|
||||
printf '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"%s/dirB","mode":"full"}}}\n' "$base"
|
||||
} > "$infile"
|
||||
# Re-open stdin from the file INSIDE the child: run_bounded's no-timeout
|
||||
# fallback backgrounds the command, and background jobs get /dev/null
|
||||
# stdin (POSIX), which would EOF the server before it answers.
|
||||
run_bounded 120 env CBM_INDEX_SUPERVISOR=0 sh -c 'exec "$1" < "$2"' _ "$BINARY" "$infile"
|
||||
local rc="$RB_RC"
|
||||
local out="$RB_OUT"
|
||||
rm -rf "$base"
|
||||
if [ "$rc" -gt 128 ]; then
|
||||
fail "second-index-inprocess" "server died (signal $((rc-128))) on the second in-process index (#773)"
|
||||
return
|
||||
fi
|
||||
if printf '%s' "$out" | grep -q '"id":2' && printf '%s' "$out" | grep -q '"id":3'; then
|
||||
pass "second-index-inprocess (both runs answered, no abort)"
|
||||
else
|
||||
fail "second-index-inprocess" "missing response (id2/id3) rc=$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# MCP STDIO SERVER LIFECYCLE
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -1007,6 +1049,7 @@ inv_empty_repo_cli
|
||||
inv_garbage_files_cli
|
||||
inv_crasher_skipped_cli
|
||||
inv_hanger_skipped_cli
|
||||
inv_second_index_inprocess
|
||||
|
||||
# MCP server-lifecycle invariants (one shared server instance).
|
||||
inv_mcp_initialize
|
||||
|
||||
@@ -1067,7 +1067,13 @@ int cbm_parallel_extract_ex(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
|
||||
CBM_PROF_START(t_init);
|
||||
cbm_init();
|
||||
|
||||
/* Slab allocator for tree-sitter (thread-safe via TLS). */
|
||||
/* Slab allocator for tree-sitter (thread-safe via TLS). Destroy any
|
||||
* parser this thread still holds BEFORE switching the global ts
|
||||
* allocator: a parser created in the mimalloc epoch (sequential run,
|
||||
* watcher tick) must be freed by the allocator that created it, or its
|
||||
* teardown after the switch routes mi pointers into plain free()
|
||||
* (#773). */
|
||||
cbm_destroy_thread_parser();
|
||||
cbm_slab_install();
|
||||
CBM_PROF_END("parallel_extract", "1_init_libs", t_init);
|
||||
|
||||
|
||||
@@ -832,6 +832,14 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx,
|
||||
cbm_arena_destroy(&ctx->seq_cross_arena);
|
||||
ctx->seq_cross_arena_live = false;
|
||||
}
|
||||
/* Destroy this thread's TLS parser: the sequential path parses on the
|
||||
* CALLING thread (usually main), and a parser left alive here was
|
||||
* allocated in the current tree-sitter allocator epoch. A later
|
||||
* parallel run switches the global ts allocator to the slab
|
||||
* (cbm_slab_install); destroying the stale parser then frees
|
||||
* mimalloc-epoch memory through slab_free -> plain free() and libmalloc
|
||||
* aborts — the #773 second-index SIGABRT. */
|
||||
cbm_destroy_thread_parser();
|
||||
return rc;
|
||||
}
|
||||
|
||||
|
||||
@@ -4870,6 +4870,124 @@ static int idxpar_recovery_check(const char *repo_dir) {
|
||||
}
|
||||
#endif /* !_WIN32 */
|
||||
|
||||
/* #773: SIGABRT (invalid free in ts_stack_delete via
|
||||
* cbm_destroy_thread_parser) on the SECOND index_repository in one server
|
||||
* process, once both repos take the PARALLEL path (~30+ files). The
|
||||
* supervisor masks this on the default MCP path (fresh worker process per
|
||||
* index); the in-process pipeline — CBM_INDEX_SUPERVISOR=0, and every
|
||||
* embedded/test consumer — dies. Forked child so the abort cannot kill the
|
||||
* runner; ASan legs print the exact bad free. */
|
||||
enum {
|
||||
IDX773_OK = 0,
|
||||
IDX773_FIRST_FAILED = 71, /* first index didn't return indexed */
|
||||
IDX773_SECOND_FAILED = 72, /* second index didn't return indexed */
|
||||
};
|
||||
|
||||
#ifndef _WIN32
|
||||
static void idx773_write_py_repo(const char *dir, int files, int variant) {
|
||||
for (int i = 0; i < files; i++) {
|
||||
char path[CBM_SZ_512];
|
||||
snprintf(path, sizeof(path), "%s/mod_%d_%03d.py", dir, variant, i);
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
continue;
|
||||
}
|
||||
fprintf(f,
|
||||
"class Handler%d:\n"
|
||||
" def run(self, x):\n"
|
||||
" return self.helper(x) + %d\n"
|
||||
" def helper(self, x):\n"
|
||||
" for i in range(10):\n"
|
||||
" x += i\n"
|
||||
" return x\n"
|
||||
"\n"
|
||||
"def main_%d(x):\n"
|
||||
" return Handler%d().run(x)\n",
|
||||
i, i, i, i);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
static int idx773_double_index_check(const char *dir_a, const char *dir_b) {
|
||||
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
if (!srv) {
|
||||
return IDX773_FIRST_FAILED;
|
||||
}
|
||||
char args[CBM_SZ_512];
|
||||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_a);
|
||||
char *r1 = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||||
bool ok1 = r1 && strstr(r1, "indexed") != NULL;
|
||||
free(r1);
|
||||
if (!ok1) {
|
||||
cbm_mcp_server_free(srv);
|
||||
return IDX773_FIRST_FAILED;
|
||||
}
|
||||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_b);
|
||||
char *r2 = cbm_mcp_handle_tool(srv, "index_repository", args); /* SIGABRT here (RED) */
|
||||
bool ok2 = r2 && strstr(r2, "indexed") != NULL;
|
||||
free(r2);
|
||||
cbm_mcp_server_free(srv);
|
||||
return ok2 ? IDX773_OK : IDX773_SECOND_FAILED;
|
||||
}
|
||||
#endif /* !_WIN32 */
|
||||
|
||||
TEST(index_second_inprocess_run_survives_issue773) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("fork-isolated crash guard (POSIX-only)");
|
||||
#else
|
||||
char dir_a[CBM_SZ_256];
|
||||
char dir_b[CBM_SZ_256];
|
||||
char cache[CBM_SZ_256];
|
||||
snprintf(dir_a, sizeof(dir_a), "/tmp/cbm-idx773a-XXXXXX");
|
||||
snprintf(dir_b, sizeof(dir_b), "/tmp/cbm-idx773b-XXXXXX");
|
||||
snprintf(cache, sizeof(cache), "/tmp/cbm-idx773c-XXXXXX");
|
||||
if (!cbm_mkdtemp(dir_a) || !cbm_mkdtemp(dir_b) || !cbm_mkdtemp(cache)) {
|
||||
FAIL("mkdtemp failed");
|
||||
}
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
|
||||
/* Trigger shape: run 1 small enough for the SEQUENTIAL path (parses on
|
||||
* the calling thread, mimalloc epoch), run 2 large enough for the
|
||||
* PARALLEL path (switches the global ts allocator to the slab). */
|
||||
idx773_write_py_repo(dir_a, 5, 0);
|
||||
idx773_write_py_repo(dir_b, 60, 1);
|
||||
|
||||
int code = -1;
|
||||
bool signalled = false;
|
||||
int sig = 0;
|
||||
fflush(NULL);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
alarm(180); /* generous: two full parallel indexes */
|
||||
_exit(idx773_double_index_check(dir_a, dir_b));
|
||||
}
|
||||
ASSERT_TRUE(pid > 0);
|
||||
int status = 0;
|
||||
(void)waitpid(pid, &status, 0);
|
||||
if (WIFEXITED(status)) {
|
||||
code = WEXITSTATUS(status);
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
signalled = true;
|
||||
sig = WTERMSIG(status);
|
||||
}
|
||||
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
|
||||
if (signalled) {
|
||||
printf(" child killed by signal %d (SIGABRT = the #773 invalid free)\n", sig);
|
||||
} else if (code != IDX773_OK) {
|
||||
printf(" child exit code %d (71=first index failed, 72=second failed)\n", code);
|
||||
}
|
||||
ASSERT_FALSE(signalled);
|
||||
ASSERT_EQ(code, IDX773_OK);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(index_recovery_parallel_quarantines_crasher) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("parallel-recovery guard needs fork isolation (POSIX-only)");
|
||||
@@ -5464,6 +5582,7 @@ SUITE(mcp) {
|
||||
RUN_TEST(index_repository_cli_name_override_issue823);
|
||||
RUN_TEST(index_supervisor_gate_requires_marked_host_issue845);
|
||||
RUN_TEST(index_bg_paths_route_through_supervisor_issue832);
|
||||
RUN_TEST(index_second_inprocess_run_survives_issue773);
|
||||
RUN_TEST(index_recovery_parallel_quarantines_crasher);
|
||||
RUN_TEST(tool_manage_adr_not_found_rich_error);
|
||||
RUN_TEST(tool_manage_adr_get_accepts_abs_path);
|
||||
|
||||
Reference in New Issue
Block a user