fix(python): walk bare-call scopes with the walk cursor, bounded

#1912 hung stack_overflow_b on all seven CI legs: rc=124 at 900s with zero
tests completed, on lsp_python_deep_nesting_no_crash.

The bare-call scope walk used ts_node_parent(), which is NOT O(1) -- it
restarts at the tree root and descends to find the parent (vendored
ts_runtime/src/node.c). Each hop therefore costs O(depth), a parent chain is
O(depth^2) per call, and since every level of f(f(f(...))) is itself a bare
call, O(depth^3) across the file. The fixture nests 30,000 deep, so this is a
hang rather than a slowdown.

A hop-count cap alone is NOT sufficient, and this was measured rather than
assumed: with a 64-hop cap the suite still timed out, because 64 x O(30,000)
x 30,000 calls is still ~5.7e10. When the cost is per hop, bounding the
number of hops cannot fix it -- the walk itself has to be cheap.

Use the unified walk's own cursor. WalkState.current_cursor is already parked
on the current node and ts_tree_cursor_goto_parent() IS O(1), because the
cursor carries its path stack, so ascending costs nothing per hop. Same
current_cursor idiom, including the ts_node_eq identity guard before trusting
the shared cursor, as usage_current_field_name in extract_usages.c. This is
the same lesson CBMWalkScope already records: carry walk state, never
recompute it per node.

The 64-hop cap is retained and now does real work, bounding the remaining
O(depth) per call. Precedent for the shape: CBM_LSP_PERL_MAX_WALK_DEPTH and
LEAN_MAX_PARENT_DEPTH in this file. Both the cap and a missing or mismatched
cursor FAIL OPEN -- they can only ever cost a suppression, never a true edge,
which is the safe direction for a guard whose justification is precision.

lsp_python_deep_nesting_no_crash: 900s timeout, 0 completions -> PASS.
stack_overflow_b overall: 6 passed in 46s.

The regression test pins the cap's contract deterministically rather than by
wall clock: within the cap a shadowed callee is flagged; past 64 ancestors the
guard fails open and leaves it unflagged. Raising the cap flips the deep case
and fails the test, verified.

826 passed / 0 failed across stack_overflow_a stack_overflow_b stack_overflow_c
pipeline registry parallel extraction complexity lsp_resolution_probe.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Co-authored-by: Joseph-MingEn <125283161+Joseph-MingEn@users.noreply.github.com>
This commit is contained in:
Martin Vogel
2026-08-29 21:11:45 +02:00
co-authored by Joseph-MingEn
parent 8bcfa2915f
commit 0b0d143c2d
2 changed files with 111 additions and 6 deletions
+49 -6
View File
@@ -2853,14 +2853,54 @@ static const char *python_parameter_name(CBMExtractCtx *ctx, TSNode param) {
* function whose parameter is `run` is still flagged. Detecting it needs exactly
* the body scan this helper avoids, and it costs one edge in a shape that
* essentially does not occur. */
static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, TSNode call_node,
static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, WalkState *state, TSNode call_node,
TSNode callee_ident) {
/* Ancestors are walked with the UNIFIED WALK'S OWN CURSOR, not ts_node_parent().
*
* ts_node_parent() is not O(1): it restarts at the tree root and descends to
* find the parent (vendored ts_runtime/src/node.c), so it costs O(depth) per
* hop. A parent-chain walk is therefore O(depth^2) per call, and since every
* level of f(f(f(...))) is ITSELF a bare call, O(depth^3) across the file --
* tests/test_stack_overflow.c nests 30,000 deep, which is a hang, not a
* slowdown. Capping the hop COUNT does not fix that, because the cost is per
* hop; the walk itself has to be cheap.
*
* ts_tree_cursor_goto_parent() IS O(1) -- the cursor carries its path stack --
* so copying the walk cursor and ascending costs nothing per hop. Same reason
* CBMWalkScope keeps a frame stack instead of recomputing enclosing state, and
* same current_cursor idiom (including the identity guard) as
* usage_current_field_name in extract_usages.c.
*
* The hop cap then bounds the remaining O(depth) per call. 64 rather than
* Lean's 20 above: that walk looks for an IMMEDIATE declaration boundary,
* while this one crosses whatever statement/expression nesting separates a
* call from its enclosing def, so it needs headroom before it can bite on
* ordinary code.
*
* Both the cap and a missing/mismatched cursor FAIL OPEN -- return false, do
* NOT suppress. A guard whose whole justification is precision must never
* destroy an edge it did not actually prove was fabricated, so either can
* only ever cost a suppression, never a true edge. Pinned in
* test_extraction.c. */
enum { PY_MAX_SCOPE_WALK_DEPTH = 64 };
const char *callee_name = cbm_node_text(ctx->arena, callee_ident, ctx->source);
if (!callee_name || !callee_name[0]) {
return false;
}
for (TSNode scope = ts_node_parent(call_node); !ts_node_is_null(scope);
scope = ts_node_parent(scope)) {
/* Only trust the shared cursor when it is actually parked on this call. */
if (!state || !state->current_cursor ||
!ts_node_eq(ts_tree_cursor_current_node(state->current_cursor), call_node)) {
return false;
}
TSTreeCursor up = ts_tree_cursor_copy(state->current_cursor);
bool bound = false;
for (int walked = 0; walked < PY_MAX_SCOPE_WALK_DEPTH && !bound; walked++) {
if (!ts_tree_cursor_goto_parent(&up)) {
break;
}
TSNode scope = ts_tree_cursor_current_node(&up);
const char *kind = ts_node_type(scope);
if (strcmp(kind, "function_definition") != 0 && strcmp(kind, "lambda") != 0) {
continue;
@@ -2873,11 +2913,13 @@ static bool python_callee_is_bound_parameter(CBMExtractCtx *ctx, TSNode call_nod
for (uint32_t i = 0; i < count; i++) {
const char *pname = python_parameter_name(ctx, ts_node_named_child(params, i));
if (pname && strcmp(pname, callee_name) == 0) {
return true;
bound = true;
break;
}
}
}
return false;
ts_tree_cursor_delete(&up);
return bound;
}
static bool is_objectscript_language(CBMLanguage language) {
@@ -3515,7 +3557,8 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
call.is_method = !python_receiver_is_exempt(ctx, obj);
} else if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
call.callee_is_locally_bound = python_callee_is_bound_parameter(ctx, node, fn);
call.callee_is_locally_bound =
python_callee_is_bound_parameter(ctx, state, node, fn);
}
}
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
+62
View File
@@ -4700,6 +4700,67 @@ TEST(extract_python_bare_call_flags_locally_bound_callee) {
PASS();
}
/* The scope walk behind the bare-call flag is BOUNDED (64 ancestors). An
* unbounded walk is QUADRATIC in a file's nesting depth -- every level of
* f(f(f(...))) is itself a bare call re-walking its own chain -- which hung
* stack_overflow_b's 30,000-deep fixture rather than merely slowing it.
*
* This pins the cap's CONTRACT deterministically rather than by wall clock (a
* timing assertion would be a lottery, not a gate): within the cap the shadowed
* callee is flagged; past it the guard FAILS OPEN and leaves the call alone, so
* the cap can only ever cost a suppression, never a true edge. Removing the cap
* flips the deep case to flagged and fails this test. */
TEST(extract_python_bare_call_scope_walk_is_bounded) {
/* Shallow: return_statement / block / function_definition — 3 ancestors. */
CBMFileResult *shallow = extract("def shallow(handler):\n"
" return handler()\n",
CBM_LANG_PYTHON, "t", "s.py");
ASSERT_NOT_NULL(shallow);
ASSERT_FALSE(shallow->has_error);
int shallow_seen = 0;
for (int i = 0; i < shallow->calls.count; i++) {
const char *cn = shallow->calls.items[i].callee_name;
if (cn && strcmp(cn, "handler") == 0) {
shallow_seen++;
ASSERT_TRUE(shallow->calls.items[i].callee_is_locally_bound);
}
}
ASSERT_EQ(shallow_seen, 1);
cbm_free_result(shallow);
/* Deep: 200 parenthesized_expression ancestors put the SAME call well past
* the cap, so the enclosing parameter is never reached and the call stays
* unflagged. */
const int PARENS = 200;
size_t sz = (size_t)PARENS * 2 + 128;
char *src = malloc(sz);
ASSERT_NOT_NULL(src);
char *w = src;
w += snprintf(w, sz, "def deep(handler):\n return ");
memset(w, '(', (size_t)PARENS);
w += PARENS;
w += snprintf(w, sz - (size_t)(w - src), "handler()");
memset(w, ')', (size_t)PARENS);
w += PARENS;
snprintf(w, sz - (size_t)(w - src), "\n");
CBMFileResult *deep = extract(src, CBM_LANG_PYTHON, "t", "d.py");
ASSERT_NOT_NULL(deep);
ASSERT_FALSE(deep->has_error);
int deep_seen = 0;
for (int i = 0; i < deep->calls.count; i++) {
const char *cn = deep->calls.items[i].callee_name;
if (cn && strcmp(cn, "handler") == 0) {
deep_seen++;
ASSERT_FALSE(deep->calls.items[i].callee_is_locally_bound);
}
}
ASSERT_EQ(deep_seen, 1);
cbm_free_result(deep);
free(src);
PASS();
}
/* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above).
* A member call x.foo() with a non-this/super receiver is flagged is_method so
* the resolver can suppress a weak short-name match (`re.test()` must not bind a
@@ -6380,6 +6441,7 @@ SUITE(extraction) {
RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method);
RUN_TEST(extract_python_member_call_flags_is_method);
RUN_TEST(extract_python_bare_call_flags_locally_bound_callee);
RUN_TEST(extract_python_bare_call_scope_walk_is_bounded);
RUN_TEST(extract_ts_member_call_flags_is_method);
RUN_TEST(extract_ts_this_super_receiver_not_flagged);
RUN_TEST(extract_js_member_call_flags_is_method);