fix(pipeline): module-QN strings must outlive the cross registries they feed

The sequential lsp_cross pass builds its shared per-language cross registries
in ctx->seq_cross_arena, which DELIBERATELY outlives the pass -- resolved_calls
and the registries carry borrowed strings that pass_calls still reads, and the
arena is destroyed only after all passes (the earlier freeing-here bug was a
pass_calls use-after-free, says the comment at the arena's creation).

But the per-file module-QN strings (def_modules[], malloc'd in
cbm_pxc_collect_all_defs and handed to every registrar as def_module_qn) were
freed at the END OF THE PASS -- the exact mistake the arena comment warns
about, one level down. Any registry-reachable structure holding one of those
pointers read freed memory in pass_calls.

AddressSanitizer caught it as a heap-use-after-free (strcmp in
cbm_pipeline_pass_calls on a string freed by the pass-end cleanup) on the
first-ever run of the real-repo determinism tier (linux/fs/xfs, 355 files) --
a tier no CI runner can execute because the corpus is local-only, which is why
it survived: bisect shows it predates today's commits (b020748 reproduces),
and main is clean on the identical suite.

Ownership now transfers to the ctx at the end of the pass and the strings are
released beside the arena, in the pipeline teardown and in test_parallel's
direct-drive harness. The parallel path is unchanged: it already destroys its
registries and module strings together, before any later pass.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-03 01:04:14 +02:00
parent 004a9a499f
commit e2f5b6a798
4 changed files with 34 additions and 4 deletions
+8 -3
View File
@@ -1265,9 +1265,14 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *
cbm_pxc_free_module_def_index(module_def_index);
free(all_defs);
for (int i = 0; i < file_count; i++)
free(def_modules[i]);
free(def_modules);
/* The module-QN strings are borrowed by the shared cross registries in
* ctx->seq_cross_arena, which deliberately outlive this pass so that
* pass_calls can read borrowed registry strings. Freeing the strings here
* while keeping the registries alive was a use-after-free (pass_calls
* strcmp on a freed module QN, caught by ASan on the kernel corpus tier).
* Ownership transfers to the ctx; released beside the arena. */
ctx->seq_cross_def_modules = def_modules;
ctx->seq_cross_def_module_count = file_count;
/* Drop the borrowed manifest pointer before its arena dies, so a later
* pass (or a stale thread-local) can never read freed manifest memory. */
+11 -1
View File
@@ -1075,11 +1075,21 @@ static int run_sequential_pipeline(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx,
ctx->result_cache = NULL;
}
/* Release the lsp_cross pass's shared registries only now: resolved_calls
* borrowed registry-owned strings that the calls pass read above. */
* borrowed registry-owned strings that the calls pass read above. The
* module-QN strings the registries borrow (parked on the ctx by the pass
* for exactly this lifetime) go with them. */
if (ctx->seq_cross_arena_live) {
cbm_arena_destroy(&ctx->seq_cross_arena);
ctx->seq_cross_arena_live = false;
}
if (ctx->seq_cross_def_modules) {
for (int i = 0; i < ctx->seq_cross_def_module_count; i++) {
free(ctx->seq_cross_def_modules[i]);
}
free(ctx->seq_cross_def_modules);
ctx->seq_cross_def_modules = NULL;
ctx->seq_cross_def_module_count = 0;
}
/* 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
+8
View File
@@ -120,6 +120,14 @@ typedef struct {
* resolve. */
CBMArena seq_cross_arena;
bool seq_cross_arena_live;
/* Sequential lsp_cross only: the per-file module-QN strings the collected
* defs (and through them the shared cross registries in seq_cross_arena)
* borrow. The registries outlive the pass so pass_calls can read borrowed
* strings -- these must too. Ownership transfers here at the end of the
* pass; released beside the arena. Freeing them at pass end was a
* use-after-free first observable on the real-repo corpus tier. */
char **seq_cross_def_modules;
int seq_cross_def_module_count;
/* ObjectScript $$$macro table built from .inc files in the repo (NULL if
* no ObjectScript include files were found). Owned by pipeline.c. */
+7
View File
@@ -253,6 +253,13 @@ static cbm_gbuf_t *run_sequential_with_lsp_cross_and_mutator(
cbm_arena_destroy(&ctx.seq_cross_arena);
ctx.seq_cross_arena_live = false;
}
if (ctx.seq_cross_def_modules) {
for (int i = 0; i < ctx.seq_cross_def_module_count; i++) {
free(ctx.seq_cross_def_modules[i]);
}
free(ctx.seq_cross_def_modules);
ctx.seq_cross_def_modules = NULL;
}
return gbuf;
}