Files
deusdata--codebase-memory-mcp/tests/test_stack_overflow.c
Martin Vogel b0a1287794 test(so): document the POSIX GLR-cap branch as a bounded smoke, per falsification
Follow-up correcting d8a78e8, whose message claimed a broken merge cap
crashes "within the first second" so the 10s alarm could not mask it.
Falsification disproved that claim AND found the guard was vacuous on
POSIX from the start:

- With CBM_TS_STACK_MERGE_MAX_DEPTH deleted outright, the ORIGINAL
  unbounded test stayed GREEN (220s full grind, no crash) on macOS ASan.
- Small thread stacks (2 MB, then 512 KB) did not change that.
- Instrumenting the vendored recursion showed why: the recursive merge
  path is NEVER ENTERED for this input in the observable window — max
  depth stays 0. There is nothing for a stack bound to catch here.

So the cap-regression DETECTION lives in the Windows in-process branch
(real ~1 MB native stack, where #913 actually manifested — exercised by
the windows CI leg). The POSIX branch is, and always was, a bounded
no-crash smoke of the pathological parse; the 10s alarm makes it
deterministic (12-15s across three runs, was 2-218s machine-dependent)
without any detection loss, because there was no POSIX detection to
lose. The experimental pthread machinery is dropped; the test comment
now carries the falsification data so no future reader mistakes the
POSIX branch for a cap guard.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-18 13:31:18 +02:00

822 lines
33 KiB
C

/*
* test_stack_overflow.c — Regression tests for GitHub issue #199.
*
* Verifies that extraction functions do NOT silently drop AST nodes
* when files exceed the fixed traversal stack capacity (512 for calls,
* 256 for variables, 64 for Elixir, etc.).
*
* These tests generate source code with more call sites / definitions /
* imports than the stack cap, then assert the extraction count matches
* the expected total. Before the fix, counts plateau at the cap.
*/
#include "test_framework.h"
#include "cbm.h"
#include "lang_specs.h" /* cbm_ts_language — direct-parse GLR cap regression (#913) */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* tree-sitter runtime allocator hooks (ts_runtime/src/alloc.h, TS_PUBLIC) and
* mimalloc (vendored) — for the #424 allocator-binding regression test. */
extern void *(*ts_current_malloc)(size_t);
extern void *(*ts_current_calloc)(size_t, size_t);
extern void *(*ts_current_realloc)(void *, size_t);
extern void (*ts_current_free)(void *);
extern void *mi_malloc(size_t);
extern void *mi_calloc(size_t, size_t);
extern void *mi_realloc(void *, size_t);
extern void mi_free(void *);
/* ── Helpers ───────────────────────────────────────────────────── */
static CBMFileResult *extract(const char *src, CBMLanguage lang, const char *proj,
const char *path) {
CBMFileResult *r = cbm_extract_file(src, (int)strlen(src), lang, proj, path, 0, NULL, NULL);
return r;
}
/* ═══════════════════════════════════════════════════════════════════
* Test: JavaScript calls exceeding 512 stack cap
*
* Generates a JS function with 600 unique function calls.
* Before fix: walk_calls() stops at ~512 due to CALLS_STACK_CAP.
* After fix: all 600 calls are extracted.
* ═══════════════════════════════════════════════════════════════════ */
TEST(js_calls_exceed_512) {
const int CALL_COUNT = 600;
/* Generate calls spread across many small functions to create wide AST.
* Each function has ~20 calls, 30 functions = 600 calls total.
* The DFS stack must hold sibling function nodes simultaneously. */
const int FUNCS = 30;
const int CALLS_PER = CALL_COUNT / FUNCS;
size_t buf_sz = 256 + (size_t)CALL_COUNT * 48;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
for (int f = 0; f < FUNCS; f++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "function handler_%d() {\n", f);
for (int c = 0; c < CALLS_PER; c++) {
int idx = f * CALLS_PER + c;
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), " func_%d();\n", idx);
}
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "}\n");
}
CBMFileResult *r = extract(src, CBM_LANG_JAVASCRIPT, "test", "many_calls.js");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* Count calls that match our generated pattern */
int matched = 0;
for (int i = 0; i < r->calls.count; i++) {
if (strncmp(r->calls.items[i].callee_name, "func_", 5) == 0) {
matched++;
}
}
printf(" calls extracted: %d / %d expected\n", matched, CALL_COUNT);
ASSERT_EQ(matched, CALL_COUNT);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: Python calls exceeding 512 stack cap
*
* Same test in Python syntax to verify language-independence.
* ═══════════════════════════════════════════════════════════════════ */
TEST(python_calls_exceed_512) {
const int CALL_COUNT = 600;
size_t buf_sz = 256 + (size_t)CALL_COUNT * 32;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "def main():\n");
for (int i = 0; i < CALL_COUNT; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), " func_%d()\n", i);
}
CBMFileResult *r = extract(src, CBM_LANG_PYTHON, "test", "many_calls.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int matched = 0;
for (int i = 0; i < r->calls.count; i++) {
if (strncmp(r->calls.items[i].callee_name, "func_", 5) == 0) {
matched++;
}
}
printf(" calls extracted: %d / %d expected\n", matched, CALL_COUNT);
ASSERT_EQ(matched, CALL_COUNT);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: Go calls at exactly 1024 (well past 512 cap)
*
* Larger count to ensure the fix handles 2x overflow gracefully.
* ═══════════════════════════════════════════════════════════════════ */
TEST(go_calls_exceed_1024) {
const int CALL_COUNT = 1024;
size_t buf_sz = 256 + (size_t)CALL_COUNT * 40;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "package main\n\nfunc main() {\n");
for (int i = 0; i < CALL_COUNT; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "\tfunc_%d()\n", i);
}
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "}\n");
CBMFileResult *r = extract(src, CBM_LANG_GO, "test", "many_calls.go");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int matched = 0;
for (int i = 0; i < r->calls.count; i++) {
if (strncmp(r->calls.items[i].callee_name, "func_", 5) == 0) {
matched++;
}
}
printf(" calls extracted: %d / %d expected\n", matched, CALL_COUNT);
ASSERT_EQ(matched, CALL_COUNT);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: Express-style route file (original reporter's scenario)
*
* ~150 route definitions — the actual use case from issue #199.
* Each route has a handler call, so both defs and calls matter.
* ═══════════════════════════════════════════════════════════════════ */
TEST(express_routes_exceed_512) {
const int ROUTE_COUNT = 150;
/* Each route: app.get('/route_NNN', handler_NNN); */
size_t buf_sz = 512 + (size_t)ROUTE_COUNT * 80;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "const express = require('express');\n");
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "const app = express();\n\n");
for (int i = 0; i < ROUTE_COUNT; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)),
"app.get('/route_%d', handler_%d);\n", i, i);
}
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "\napp.listen(3000);\n");
CBMFileResult *r = extract(src, CBM_LANG_JAVASCRIPT, "test", "routes.js");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* Each app.get() is a call — count calls containing "get" */
int get_calls = 0;
for (int i = 0; i < r->calls.count; i++) {
if (strstr(r->calls.items[i].callee_name, "get") != NULL) {
get_calls++;
}
}
printf(" route calls extracted: %d / %d expected\n", get_calls, ROUTE_COUNT);
ASSERT_EQ(get_calls, ROUTE_COUNT);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: ES imports exceeding 512 (walk_es_imports uses same cap)
*
* Generate a TS file with 600 import statements.
* ═══════════════════════════════════════════════════════════════════ */
TEST(ts_imports_exceed_512) {
const int IMPORT_COUNT = 600;
size_t buf_sz = 256 + (size_t)IMPORT_COUNT * 64;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
for (int i = 0; i < IMPORT_COUNT; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)),
"import { mod_%d } from './module_%d';\n", i, i);
}
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "console.log('done');\n");
CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "many_imports.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
printf(" imports extracted: %d / %d expected\n", r->imports.count, IMPORT_COUNT);
ASSERT_GTE(r->imports.count, IMPORT_COUNT);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: Deeply nested calls (tests stack depth, not just breadth)
*
* Generates nested function calls: a(b(c(d(e(...)))))
* A deep call chain can overflow the stack even with fewer total nodes.
* ═══════════════════════════════════════════════════════════════════ */
TEST(js_deeply_nested_calls) {
const int DEPTH = 200;
/* Build: outermost( level_0( level_1( ... level_199() ... ))) */
size_t buf_sz = 256 + (size_t)DEPTH * 32;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "function main() {\n ");
for (int i = 0; i < DEPTH; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "level_%d(", i);
}
/* Close all parens */
for (int i = 0; i < DEPTH; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), ")");
}
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), ";\n}\n");
CBMFileResult *r = extract(src, CBM_LANG_JAVASCRIPT, "test", "nested_calls.js");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int matched = 0;
for (int i = 0; i < r->calls.count; i++) {
if (strncmp(r->calls.items[i].callee_name, "level_", 6) == 0) {
matched++;
}
}
printf(" nested calls extracted: %d / %d expected\n", matched, DEPTH);
ASSERT_EQ(matched, DEPTH);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: YAML variables exceeding 256 (walk_variables_iter cap)
*
* Generate a YAML file with 300 top-level keys.
* ═══════════════════════════════════════════════════════════════════ */
TEST(yaml_vars_exceed_256) {
const int VAR_COUNT = 300;
size_t buf_sz = (size_t)VAR_COUNT * 32;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
for (int i = 0; i < VAR_COUNT; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "key_%d: value_%d\n", i, i);
}
CBMFileResult *r = extract(src, CBM_LANG_YAML, "test", "many_keys.yaml");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int var_count = 0;
for (int i = 0; i < r->defs.count; i++) {
if (strcmp(r->defs.items[i].label, "Variable") == 0) {
var_count++;
}
}
printf(" YAML vars extracted: %d / %d expected\n", var_count, VAR_COUNT);
ASSERT_EQ(var_count, VAR_COUNT);
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: tree-sitter runtime allocator is bound to cbm's allocator (#424)
*
* cbm_init() must bind the vendored tree-sitter runtime to mimalloc via
* ts_set_allocator(). Otherwise the runtime allocates through its overridable
* ts_current_malloc/free defaults (plain malloc/free); under the production
* MI_OVERRIDE=1 build (esp. Windows static-MinGW + --allow-multiple-definition)
* ts_malloc and ts_free can resolve to different allocators, corrupting the
* heap and crashing mid-parse on large templated C++ headers. Asserting the
* binding reproduces the mismatch CONDITION deterministically on every
* platform: RED before the fix (ts_current_free == plain free), GREEN after.
* ═══════════════════════════════════════════════════════════════════ */
TEST(ts_allocator_bound_to_mimalloc_issue424) {
cbm_init();
#if defined(CBM_BIND_TS_ALLOCATOR) && CBM_BIND_TS_ALLOCATOR
/* Production build: cbm_init must have bound the ts runtime to mimalloc so
* ts_malloc and ts_free can never resolve to different allocators (#424). */
ASSERT_TRUE(ts_current_malloc == mi_malloc);
ASSERT_TRUE(ts_current_free == mi_free);
#else
/* Test build (MI_OVERRIDE=0, CRT + ASan): binding is intentionally OFF (it
* would mismatch ASan/CRT frees). The crash only affects the prod binary;
* its reproduction runs in the prod smoke job. Independently verify the fix
* MECHANISM — ts_set_allocator wires the runtime hooks — then restore the
* CRT defaults so the rest of this build stays allocator-consistent. */
void *(*save_m)(size_t) = ts_current_malloc;
void *(*save_c)(size_t, size_t) = ts_current_calloc;
void *(*save_r)(void *, size_t) = ts_current_realloc;
void (*save_f)(void *) = ts_current_free;
ts_set_allocator(mi_malloc, mi_calloc, mi_realloc, mi_free);
int wired = (ts_current_malloc == mi_malloc) && (ts_current_free == mi_free);
ts_set_allocator(save_m, save_c, save_r, save_f); /* restore BEFORE asserting */
ASSERT_TRUE(wired);
ASSERT_TRUE(ts_current_free == save_f);
#endif
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Test: large templated C++ header extraction does not crash (#424)
*
* Generates a json.hpp-style header (hundreds of templated structs with
* operator overloads) — the parse path that SEGV'd in #424. Asserts
* extraction completes. (In the MI_OVERRIDE=0 test build the allocator is
* consistent so this cannot fault here; it guards extraction on large
* templated input and addresses the C++ gap in this file. The production-
* binary crash reproduction runs in the smoke job.)
* ═══════════════════════════════════════════════════════════════════ */
TEST(cpp_large_templated_header_no_crash_issue424) {
const int N = 400; /* ~400 templated structs → ~2400 lines */
size_t buf_sz = (size_t)N * 600;
char *src = malloc(buf_sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, buf_sz, "#include <cstddef>\nnamespace ns {\n");
for (int i = 0; i < N; i++) {
p += snprintf(p, (size_t)(buf_sz - (size_t)(p - src)),
"template <typename T> struct Box%d {\n"
" T value;\n"
" bool operator<(const Box%d &o) const { return value < o.value; }\n"
" bool operator==(const Box%d &o) const { return value == o.value; }\n"
" T get() const { return value; }\n"
"};\n",
i, i, i);
}
snprintf(p, (size_t)(buf_sz - (size_t)(p - src)), "}\n");
CBMFileResult *r = extract(src, CBM_LANG_CPP, "test", "templated.hpp");
ASSERT_NOT_NULL(r); /* no crash is the real assertion */
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* LSP resolve recursion guards (perf-sweep crashes, 2026-06-10)
*
* Indexing real OSS crashed in the LSP RESOLVE walks (distinct from the
* extraction-walk caps above): elasticsearch → SIGSEGV under deep recursive
* java_resolve_calls_in_node frames (bind_lambda_args), bitcoin → SIGSEGV
* under deep c_resolve_calls_in_node frames (cbm_type_substitute via
* c_adl_resolve), microsoft/TypeScript → SIGBUS under an unbounded
* lookup_member_type cycle. The walks now carry depth guards; these
* reproductions fork a child so a regression cannot kill the test runner
* (the TS cyclic-type shape is only reachable with a real cross-file
* registry, so that one is verified at the real-repo tier; the synthetic
* cyclic fixture here guards the in-file path).
* ═══════════════════════════════════════════════════════════════════ */
#if !defined(_WIN32)
#include <sys/wait.h>
#include <unistd.h>
#endif
/* Run cbm_extract_file in a forked child; true if the child died by signal.
* Mirrors tests/test_lang_contract.c. On Windows run in-process (a genuine
* crash there aborts the runner — hard, visible failure). */
static bool so_extract_crashes(const char *content, CBMLanguage lang, const char *relpath) {
#if defined(_WIN32)
CBMFileResult *r =
cbm_extract_file(content, (int)strlen(content), lang, "so", relpath, 0, NULL, NULL);
if (r) {
cbm_free_result(r);
}
return false;
#else
fflush(NULL);
pid_t pid = fork();
if (pid < 0) {
return false;
}
if (pid == 0) {
CBMFileResult *r =
cbm_extract_file(content, (int)strlen(content), lang, "so", relpath, 0, NULL, NULL);
if (r) {
cbm_free_result(r);
}
_exit(0);
}
int status = 0;
(void)waitpid(pid, &status, 0);
return WIFSIGNALED(status);
#endif
}
#if !defined(_WIN32)
/* Child-side alarm handler: a clean exit on the deadline — never reached on
* a crash (SIGSEGV/SIGBUS terminate the child first and stay visible). */
static void so_parse_alarm_exit(int sig) {
(void)sig;
_exit(0);
}
#endif
/* Parse `content` with tree-sitter DIRECTLY in a forked child — bypassing
* cbm_extract_file's Perl pre-parse nesting guard — returning true if the child
* died by signal. This is the crash-isolating regression for the vendored GLR
* stack-merge recursion cap (CBM_TS_STACK_MERGE_MAX_DEPTH, ts_runtime/src/stack.c):
* the extract-level guard skips pathologically nested Perl before it reaches the
* parser, so only a direct parse exercises the cap. Windows runs in-process (a
* real crash aborts the runner — a visible failure), mirroring so_extract_crashes. */
static bool so_parse_crashes(const char *content, CBMLanguage lang) {
const TSLanguage *ts_lang = cbm_ts_language(lang);
if (!ts_lang) {
return false;
}
#if defined(_WIN32)
TSParser *parser = ts_parser_new();
if (parser) {
ts_parser_set_language(parser, ts_lang);
TSTree *tree = ts_parser_parse_string(parser, NULL, content, (uint32_t)strlen(content));
if (tree) {
ts_tree_delete(tree);
}
ts_parser_delete(parser);
}
return false;
#else
fflush(NULL);
pid_t pid = fork();
if (pid < 0) {
return false;
}
if (pid == 0) {
/* Deterministic ceiling. Falsification data (2026-07-18, macOS
* ASan): with CBM_TS_STACK_MERGE_MAX_DEPTH deleted outright, this
* branch stayed GREEN both unbounded (220s full grind, no crash)
* and bounded — instrumentation shows the recursive merge path is
* never even entered here (max depth 0 through the whole window).
* The cap-regression DETECTION therefore lives in the Windows
* in-process branch above (real ~1 MB native stack, where #913
* manifested); this POSIX branch is a bounded no-crash smoke of
* the pathological parse, and grinding past 10s adds no signal
* (unbounded it wandered 2s-218s with machine state). A real
* SIGSEGV/SIGBUS still terminates the child by signal before the
* alarm handler can exit cleanly. */
signal(SIGALRM, so_parse_alarm_exit);
alarm(10);
TSParser *parser = ts_parser_new();
if (parser) {
ts_parser_set_language(parser, ts_lang);
TSTree *tree = ts_parser_parse_string(parser, NULL, content, (uint32_t)strlen(content));
if (tree) {
ts_tree_delete(tree);
}
ts_parser_delete(parser);
}
_exit(0);
}
int status = 0;
(void)waitpid(pid, &status, 0);
return WIFSIGNALED(status);
#endif
}
TEST(lsp_java_deep_nesting_no_crash) {
/* Deeply nested call expressions — the same shape as the elasticsearch
* crash (fast SIGSEGV under recursive java_resolve_calls_in_node frames;
* pre-guard prod probe: rc=139 in under a second at this depth). Nested
* BLOCKS are deliberately not used: java block processing is minutes-slow
* at depth >=3000 (separate adversarial-input pathology, documented in
* the perf-sweep report). */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "class X { static int f(int a) { return a; } static int g() { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "; } }\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_JAVA, "X.java"));
free(src);
PASS();
}
TEST(lsp_cpp_deep_expression_no_crash) {
/* See lsp_java_deep_nesting_no_crash on the depth choice. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "int f(int x) { return x; }\nint g() { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "; }\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_CPP, "deep.cpp"));
free(src);
PASS();
}
TEST(lsp_python_deep_expression_no_crash) {
/* Issue #720: a deeply parenthesized assignment RHS drives
* py_eval_expr_type into one native recursion frame per paren level
* via py_process_statement's RHS inference — a path NOT covered by
* the py_resolve_calls_in walk_depth cap (that guards the emit walk,
* not the binder's expression evaluation). Pre-guard this SIGSEGVs.
* PY_LSP_MAX_EVAL_DEPTH turns it into a graceful unknown. Depth
* mirrors lsp_java_deep_nesting_no_crash. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 2 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "def main():\n value = ");
memset(p, '(', DEPTH);
p += DEPTH;
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "\n return value\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_PYTHON, "deep_expr.py"));
free(src);
PASS();
}
TEST(lsp_perl_deep_expression_no_crash) {
/* Deeply nested Perl call expressions f(f(f(...f(1)...))). Unlike the
* Java/C++ cases, the overflow here is NOT in the LSP walk — it is in
* tree-sitter's own GLR parser: stack_node_add_link (vendored
* ts_runtime/src/stack.c) recurses once per nesting level while merging the
* ambiguous parse-stack heads that Perl's `f(...)` grammar produces, blowing
* a small (1 MB Windows) stack during the parse, before any LSP walk runs.
* The CBM_PERL_MAX_PARSE_NESTING pre-parse guard in cbm_extract_file skips
* such input so it never reaches tree-sitter. See
* lsp_java_deep_nesting_no_crash on the depth choice. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "sub f { return $_[0]; }\nsub g { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "; }\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_PERL, "deep.pl"));
free(src);
PASS();
}
TEST(perl_glr_deep_parse_recursion_capped) {
/* Issue #913 — the proper fix for what lsp_perl_deep_expression_no_crash's
* pre-parse guard only works around. Parse deeply nested ambiguous Perl
* f(f(f(...f(1)...))) DIRECTLY (past the CBM_PERL_MAX_PARSE_NESTING guard,
* which would otherwise skip it). Perl's paren-optional call grammar makes
* each level ambiguous, so tree-sitter's GLR parser merges the ambiguous
* parse-stack heads recursively — stack_node_add_link in
* ts_runtime/src/stack.c, once per nesting level — overflowing the native
* stack (a ~1 MB Windows stack, and even an 8 MB POSIX stack at this depth)
* during the parse, before any extraction runs. The
* CBM_TS_STACK_MERGE_MAX_DEPTH cap stops merging past the bound: the
* ambiguity is left on the GLR stack instead of merged — a valid parse,
* never a wrong one — so the parse returns cleanly instead of crashing.
* Depth mirrors lsp_perl_deep_expression_no_crash. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "sub f { return $_[0]; }\nsub g { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "; }\n");
ASSERT_FALSE(so_parse_crashes(src, CBM_LANG_PERL));
free(src);
PASS();
}
TEST(lsp_java_lambda_args_exceed_params_no_crash) {
/* A call with MORE arguments than the resolved method's declared params:
* bind_lambda_args indexed the NULL-terminated signature param_types array
* by the call-site argument index, reading past the terminator — a garbage
* CBMType* then got dereferenced (elasticsearch SIGSEGV, java_lsp.c:2364
* via :2722; same OOB family as #427). */
const char *src = "class A {\n"
" void run(Runnable r) {}\n"
" void go() {\n"
" run(() -> {}, () -> {}, () -> {}, () -> {}, () -> {}, () -> {});\n"
" }\n"
"}\n";
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_JAVA, "A.java"));
PASS();
}
TEST(lsp_ts_cyclic_types_no_crash) {
const char *src = "type A = B | null;\n"
"type B = A | number;\n"
"interface C extends D { c: number; }\n"
"interface D extends C { d: number; }\n"
"declare const a: A;\n"
"declare const c: C;\n"
"function useIt(p: C) { return p.missing_member; }\n"
"const y = c.also_missing;\n";
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_TYPESCRIPT, "cycle.ts"));
PASS();
}
/* ─── Deeply-nested calls drive the per-language LSP resolve walkers into
* per-nesting-level native recursion. Unguarded, these SIGSEGV and take down
* the whole index. Each walker now has a walk_depth cap (CBM_LSP_MAX_WALK_DEPTH,
* env-overridable) that skips the too-deep subtree — graceful degradation.
* These mirror lsp_java_deep_nesting_no_crash: same fixture shape, one per
* previously-unguarded walker (py_resolve_calls_in, resolve_calls_in_node[go],
* php_resolve_calls_in_node, kt_resolve_calls_in_node). RED proof: run the
* suite with CBM_LSP_MAX_WALK_DEPTH set huge (disabling only these caps) and
* each of these four SIGSEGVs — proving the guard, not the fixture, is what
* keeps them green. ─── */
TEST(lsp_python_deep_nesting_no_crash) {
/* py_resolve_calls_in recurses per nesting level; see the Java analog. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "def f(a):\n return a\ndef g():\n return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_PYTHON, "deep.py"));
free(src);
PASS();
}
TEST(lsp_go_deep_nesting_no_crash) {
/* resolve_calls_in_node recurses per nesting level; see the Java analog. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "package p\nfunc f(a int) int { return a }\nfunc g() int { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), " }\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_GO, "deep.go"));
free(src);
PASS();
}
TEST(lsp_php_deep_nesting_no_crash) {
/* php_resolve_calls_in_node recurses per nesting level; Java analog. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "<?php\nfunction f($a) { return $a; }\nfunction g() { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), "; }\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_PHP, "deep.php"));
free(src);
PASS();
}
TEST(lsp_kotlin_deep_nesting_no_crash) {
/* kt_resolve_calls_in_node recurses per nesting level; Java analog. */
const int DEPTH = 30000;
size_t sz = (size_t)DEPTH * 3 + 256;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *p = src;
p += snprintf(p, sz, "fun f(a: Int): Int { return a }\nfun g(): Int { return ");
for (int i = 0; i < DEPTH; i++) {
*p++ = 'f';
*p++ = '(';
}
*p++ = '1';
memset(p, ')', DEPTH);
p += DEPTH;
snprintf(p, sz - (size_t)(p - src), " }\n");
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_KOTLIN, "deep.kt"));
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Suite registration
* ═══════════════════════════════════════════════════════════════════ */
/* Split into three sub-suites so parallel/sharded runs are not serialized
* behind one ~4-minute suite (it was the wall-clock critical path: every
* other suite finished underneath it). Pure re-registration — the 20
* RUN_TEST entries are exactly the ones the single suite carried; the
* before/after test-count parity is asserted in the shard runner. */
SUITE(stack_overflow_a) {
cbm_init();
RUN_TEST(ts_allocator_bound_to_mimalloc_issue424);
RUN_TEST(cpp_large_templated_header_no_crash_issue424);
RUN_TEST(lsp_java_deep_nesting_no_crash);
RUN_TEST(lsp_java_lambda_args_exceed_params_no_crash);
RUN_TEST(lsp_cpp_deep_expression_no_crash);
RUN_TEST(lsp_python_deep_expression_no_crash);
RUN_TEST(lsp_perl_deep_expression_no_crash);
cbm_shutdown();
}
SUITE(stack_overflow_b) {
cbm_init();
RUN_TEST(perl_glr_deep_parse_recursion_capped);
RUN_TEST(lsp_ts_cyclic_types_no_crash);
RUN_TEST(lsp_python_deep_nesting_no_crash);
RUN_TEST(lsp_go_deep_nesting_no_crash);
RUN_TEST(lsp_php_deep_nesting_no_crash);
RUN_TEST(lsp_kotlin_deep_nesting_no_crash);
cbm_shutdown();
}
SUITE(stack_overflow_c) {
cbm_init();
RUN_TEST(js_calls_exceed_512);
RUN_TEST(python_calls_exceed_512);
RUN_TEST(go_calls_exceed_1024);
RUN_TEST(express_routes_exceed_512);
RUN_TEST(ts_imports_exceed_512);
RUN_TEST(js_deeply_nested_calls);
RUN_TEST(yaml_vars_exceed_256);
cbm_shutdown();
}