mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-22 06:09:35 +08:00
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:
co-authored by
Joseph-MingEn
parent
8bcfa2915f
commit
0b0d143c2d
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user