fix(ts-runtime): bound GLR stack-merge recursion to prevent stack overflow

tree-sitter's GLR parser merges ambiguous parse-stack heads recursively in
stack_node_add_link (internal/cbm/vendored/ts_runtime/src/stack.c), once per
nesting level. On pathologically nested input whose grammar is ambiguous
(Perl's paren-optional calls, f(f(f(...)))), that recursion overflows the
native stack during the parse — a small ~1 MB Windows thread stack, and even
an 8 MB POSIX stack at extreme depth — before any extraction runs. Grammars
that are unambiguous here (Java, C++) never trigger the recursive merge.

Cap the recursive merge at CBM_TS_STACK_MERGE_MAX_DEPTH (512) via a
depth-tracked inner worker; the public stack_node_add_link is a thin wrapper
seeding depth 0, so every existing call site is unchanged. Past the cap the
ambiguity is left on the GLR stack instead of merged — a valid parse, never a
wrong one. 512 frames is well within a 1 MB stack while far exceeding any
realistic source nesting.

Add perl_glr_deep_parse_recursion_capped: a crash-isolating regression that
parses deep ambiguous Perl directly (a forked child on POSIX, in-process on
Windows), bypassing cbm_extract_file's CBM_PERL_MAX_PARSE_NESTING pre-parse
guard so the vendored cap itself is exercised rather than the guard.

Narrow vendored-runtime change; no other parsing behavior is affected. The
higher-level Perl pre-parse guard (issue #913 context) stays in place and can
be retired in a follow-up once this cap is proven in the field.

Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
This commit is contained in:
Shane McCarron committed 2026-07-10 07:58:04 -05:00
1 parent 6b57db271a
commit da046da59f
2 files changed
+119 -4

No files matched your search

+37 -4
View File
@@ -12,6 +12,21 @@
#define MAX_NODE_POOL_SIZE 50
#define MAX_ITERATOR_COUNT 64
// CBM patch: cap the recursive ambiguity-merge in stack_node_add_link.
// On a deeply ambiguous parse (e.g. Perl's optional-paren function calls in a
// f(f(f(...))) chain ~30k deep), the "merge them recursively" path below
// recurses once per nesting level on the C stack (~260 B/frame), overflowing
// the small default thread stack on Windows (~1 MB) and even the 8 MB POSIX
// stack at extreme depth — a SIGSEGV during ts_parser_parse, before any
// language extractor runs. Past this cap we stop eagerly merging and leave the
// ambiguity on the GLR stack, exactly as the existing
// `self->link_count == MAX_LINK_COUNT` bail-out already does: the parse still
// produces a valid tree (graceful degradation), never a wrong one. 512 mirrors
// CBM's CBM_LSP_*_MAX_WALK_DEPTH walk caps; 512*~260 B ~= 130 KB, safe with
// wide headroom on a 1 MB stack, while far exceeding any realistic source
// nesting.
#define CBM_TS_STACK_MERGE_MAX_DEPTH 512
#if defined _WIN32 && !defined __GNUC__
#define forceinline __forceinline
#else
@@ -197,10 +212,15 @@ static bool stack__subtree_is_equivalent(Subtree left, Subtree right) {
);
}
static void stack_node_add_link(
// CBM patch: depth-tracked inner worker. `depth` counts the recursive
// ambiguity-merge nesting; past CBM_TS_STACK_MERGE_MAX_DEPTH we stop merging
// (see the macro comment). The public stack_node_add_link below is a thin
// wrapper that seeds depth = 0, so all existing call sites are unchanged.
static void stack_node_add_link_inner(
StackNode *self,
StackLink link,
SubtreePool *subtree_pool
SubtreePool *subtree_pool,
unsigned depth
) {
if (link.node == self) return;
@@ -231,8 +251,12 @@ static void stack_node_add_link(
existing_link->node->position.bytes == link.node->position.bytes &&
existing_link->node->error_cost == link.node->error_cost
) {
for (int j = 0; j < link.node->link_count; j++) {
stack_node_add_link(existing_link->node, link.node->links[j], subtree_pool);
// CBM patch: bound the recursive merge to keep the C stack finite.
if (depth < CBM_TS_STACK_MERGE_MAX_DEPTH) {
for (int j = 0; j < link.node->link_count; j++) {
stack_node_add_link_inner(existing_link->node, link.node->links[j],
subtree_pool, depth + 1);
}
}
int32_t dynamic_precedence = link.node->dynamic_precedence;
if (link.subtree.ptr) {
@@ -263,6 +287,15 @@ static void stack_node_add_link(
if (dynamic_precedence > self->dynamic_precedence) self->dynamic_precedence = dynamic_precedence;
}
// CBM patch: public entry point — unchanged signature, seeds recursion depth.
static void stack_node_add_link(
StackNode *self,
StackLink link,
SubtreePool *subtree_pool
) {
stack_node_add_link_inner(self, link, subtree_pool, 0);
}
static void stack_head_delete(
StackHead *self,
StackNodeArray *pool,
+82
View File
@@ -11,6 +11,7 @@
*/
#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>
@@ -440,6 +441,53 @@ static bool so_extract_crashes(const char *content, CBMLanguage lang, const char
#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) {
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;
@@ -541,6 +589,39 @@ TEST(lsp_perl_deep_expression_no_crash) {
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
@@ -679,6 +760,7 @@ SUITE(stack_overflow) {
RUN_TEST(lsp_cpp_deep_expression_no_crash);
RUN_TEST(lsp_python_deep_expression_no_crash);
RUN_TEST(lsp_perl_deep_expression_no_crash);
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);