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:
Martin Vogel
2026-07-11 12:51:29 +02:00
parent 751d2293f9
commit 2f7befb9ad
4 changed files with 177 additions and 1 deletions
+43
View File
@@ -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
+7 -1
View File
@@ -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);
+8
View File
@@ -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;
}
+119
View File
@@ -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);