Merge pull request #1002 from halindrome/p913-glr-recursion

fix(ts-runtime): bound GLR stack-merge recursion to prevent stack overflow
This commit is contained in:
Martin Vogel
2026-07-16 02:11:10 +02:00
committed by GitHub
3 changed files with 130 additions and 4 deletions
+11
View File
@@ -13,6 +13,17 @@ The tree-sitter C runtime is vendored in `internal/cbm/vendored/ts_runtime/`.
- **License:** MIT
- **Copyright:** (c) 2018–2024 Max Brunsfeld
**Local modification** (`internal/cbm/vendored/ts_runtime/src/stack.c`, #913): a
single CBM patch bounds the recursive ambiguity-merge in `stack_node_add_link`
at `CBM_TS_STACK_MERGE_MAX_DEPTH` (512). Deeply nested grammar-ambiguous input
(e.g. Perl `f(f(f(...)))`) otherwise recurses once per level on the native C
stack and overflows it during parsing (SIGSEGV on the ~1 MB Windows thread
stack, and even the 8 MB POSIX stack at extreme depth) before any extractor
runs. Past the cap the ambiguity is left on the GLR stack instead of merged —
still a valid parse, never a wrong one — mirroring the existing
`MAX_LINK_COUNT` bail-out. The change is clearly marked `// CBM patch:` inline.
**On re-vendor (e.g. ts_runtime → 0.26.x): re-apply this bound.**
The shared scanner helpers in `internal/cbm/vendored/common/` (`scanner.h`,
`tag.h`) originate from
[tree-sitter-html](https://github.com/tree-sitter/tree-sitter-html) (MIT,
+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);