mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-29 07:13:44 +08:00
Merge pull request #809 from DeusData/distill/py-eval-guards
fix(py-lsp): memoize + guard expression evaluation (exponential re-eval hang)
This commit is contained in:
+229
-29
@@ -23,6 +23,11 @@
|
||||
* only from py_lsp.c, never compiled standalone. */
|
||||
#include "py_builtins.c"
|
||||
|
||||
/* Guards for py_eval_expr_type — mirrors c_eval_expr_type's guard design
|
||||
* (C_EVAL_DEPTH_LIMIT / C_EVAL_MAX_STEPS_PER_FILE in c_lsp.c). */
|
||||
#define PY_LSP_MAX_EVAL_DEPTH 256
|
||||
#define PY_EVAL_MAX_STEPS_PER_FILE 10000
|
||||
|
||||
// Forward decls
|
||||
static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node);
|
||||
|
||||
@@ -41,6 +46,7 @@ static void py_resolve_calls_in(PyLSPContext *ctx, TSNode node) {
|
||||
ctx->walk_depth--;
|
||||
}
|
||||
static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node);
|
||||
static const CBMType *py_eval_expr_type_uncached(PyLSPContext *ctx, TSNode node);
|
||||
static void py_process_statement(PyLSPContext *ctx, TSNode node);
|
||||
static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const char *type_qn,
|
||||
const char *member_name);
|
||||
@@ -58,6 +64,32 @@ static void py_register_dict_literal(PyLSPContext *ctx, const char *name, TSNode
|
||||
static TSNode py_lookup_lambda(PyLSPContext *ctx, const char *name);
|
||||
static const char *py_lookup_dict_dispatch(PyLSPContext *ctx, const char *var, const char *key);
|
||||
|
||||
/* ── Scope mutation wrappers ─────────────────────────────────────────────
|
||||
*
|
||||
* py_eval_expr_type memoizes per-node results (see the cache block above
|
||||
* py_eval_expr_type). A cached result is only valid as long as name lookup
|
||||
* resolves exactly as it did when the entry was computed, so every mutation
|
||||
* of the scope chain must invalidate the cache. Bumping the generation
|
||||
* counter is an O(1) whole-cache flush: entries from older generations
|
||||
* never match again. All binds/restores in this file MUST go through these
|
||||
* wrappers — never call cbm_scope_bind(ctx->current_scope, ...) or assign
|
||||
* ctx->current_scope directly (scope pushes are exempt: an empty child
|
||||
* scope delegates every lookup to its parent, changing nothing).
|
||||
*
|
||||
* This is what makes memoization behavior-preserving even when the same
|
||||
* node is legitimately re-evaluated under different bindings — e.g. a
|
||||
* lambda body re-walked per call site with per-call argument types, or
|
||||
* isinstance-narrowed branches. */
|
||||
static void py_scope_bind(PyLSPContext *ctx, const char *name, const CBMType *type) {
|
||||
ctx->type_cache_gen++;
|
||||
cbm_scope_bind(ctx->current_scope, name, type);
|
||||
}
|
||||
|
||||
static void py_scope_restore(PyLSPContext *ctx, CBMScope *saved) {
|
||||
ctx->type_cache_gen++;
|
||||
ctx->current_scope = saved;
|
||||
}
|
||||
|
||||
void py_lsp_init(PyLSPContext *ctx, CBMArena *arena, const char *source, int source_len,
|
||||
const CBMTypeRegistry *registry, const char *module_qn,
|
||||
CBMResolvedCallArray *out) {
|
||||
@@ -147,7 +179,7 @@ static void py_bind_dotted_prefixes(PyLSPContext *ctx, const char *qn) {
|
||||
const char *short_name = strrchr(prefix, '.');
|
||||
const char *bind_short = short_name ? short_name + 1 : prefix;
|
||||
if (cbm_type_is_unknown(cbm_scope_lookup(ctx->current_scope, bind_short))) {
|
||||
cbm_scope_bind(ctx->current_scope, bind_short, cbm_type_module(ctx->arena, prefix));
|
||||
py_scope_bind(ctx, bind_short, cbm_type_module(ctx->arena, prefix));
|
||||
}
|
||||
p = dot + 1;
|
||||
}
|
||||
@@ -178,7 +210,7 @@ void py_lsp_bind_imports(PyLSPContext *ctx) {
|
||||
// `import X` / `import X as Y` — bind to MODULE(X).
|
||||
t = cbm_type_module(ctx->arena, qn);
|
||||
}
|
||||
cbm_scope_bind(ctx->current_scope, local, t);
|
||||
py_scope_bind(ctx, local, t);
|
||||
// Always walk the dotted prefix chain. The CBMImport shape
|
||||
// can't distinguish `import a.b.c` from `from a.b import c`
|
||||
// (both produce local_name=c, module_path=a.b.c), but binding
|
||||
@@ -621,7 +653,7 @@ static void py_bind_for_target(PyLSPContext *ctx, TSNode left, const CBMType *el
|
||||
if (strcmp(lk, "identifier") == 0) {
|
||||
char *name = py_node_text(ctx, left);
|
||||
if (name)
|
||||
cbm_scope_bind(ctx->current_scope, name, elem_type);
|
||||
py_scope_bind(ctx, name, elem_type);
|
||||
return;
|
||||
}
|
||||
if (strcmp(lk, "pattern_list") == 0 || strcmp(lk, "tuple_pattern") == 0 ||
|
||||
@@ -655,7 +687,7 @@ static void py_bind_for_target(PyLSPContext *ctx, TSNode left, const CBMType *el
|
||||
bind_type = elems[i];
|
||||
else
|
||||
bind_type = cbm_type_unknown();
|
||||
cbm_scope_bind(ctx->current_scope, nm, bind_type);
|
||||
py_scope_bind(ctx, nm, bind_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,7 +739,10 @@ static const CBMType *py_iterable_element_type(PyLSPContext *ctx, const CBMType
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
|
||||
static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node) {
|
||||
/* The real recursive-descent evaluator. Never call directly — go through
|
||||
* the memoizing, depth- and budget-guarded py_eval_expr_type wrapper below
|
||||
* (every recursive call inside this body already does). */
|
||||
static const CBMType *py_eval_expr_type_uncached(PyLSPContext *ctx, TSNode node) {
|
||||
if (!ctx || ts_node_is_null(node))
|
||||
return cbm_type_unknown();
|
||||
|
||||
@@ -1301,6 +1336,171 @@ static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node) {
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
|
||||
/* ── py_eval_expr_type memoization + guards (issues #710, #720) ──────────
|
||||
*
|
||||
* py_eval_expr_type_uncached re-evaluates a call node's attribute receiver
|
||||
* twice — once in the container special-case and again in the general
|
||||
* attribute path — so an N-link chained call (`Builder().add(x)...add(z)`)
|
||||
* cost O(2^N) evaluations: the #710 indexing hang (~65-link real chains).
|
||||
* Memoizing per node makes each distinct node evaluate at most once per
|
||||
* scope generation, and py_emit_call_for's per-call-node receiver
|
||||
* re-evaluation becomes an O(1) cache hit.
|
||||
*
|
||||
* Key: the tree-sitter node identity pointer (TSNode.id — unique per node
|
||||
* within one parse tree). NOT the node's start byte: in a chained call
|
||||
* every leftmost descendant (the whole chain, each receiver, down to the
|
||||
* root identifier) shares the same start byte, so byte-keyed entries alias
|
||||
* distinct nodes and silently return the wrong type (missing/incorrect
|
||||
* CALLS edges).
|
||||
*
|
||||
* Entries carry the scope generation (see py_scope_bind); stale
|
||||
* generations never match, which keeps caching invisible to resolution
|
||||
* behavior. The table is open-addressing/linear-probe, arena-allocated on
|
||||
* the per-file arena (freed with it; ctx is memset per file so each file
|
||||
* starts cold). */
|
||||
|
||||
enum { PY_TYPE_CACHE_INITIAL_CAP = 256 }; /* power of two (index masking) */
|
||||
enum { PY_TYPE_CACHE_MAX_CAP = 1 << 26 }; /* hard stop for adversarial files */
|
||||
|
||||
static uint32_t py_type_cache_hash(const void *id) {
|
||||
uintptr_t v = (uintptr_t)id >> 3; /* node structs are >=8-byte aligned */
|
||||
return (uint32_t)((v ^ (v >> 29)) * 2654435761u);
|
||||
}
|
||||
|
||||
/* Returns the cached full-fidelity result for this node, or NULL when the
|
||||
* node has no entry from the current scope generation. Live entries never
|
||||
* store NULL (the wrapper normalizes to cbm_type_unknown() first), so NULL
|
||||
* unambiguously means "miss". */
|
||||
static const CBMType *py_type_cache_lookup(const PyLSPContext *ctx, const void *id) {
|
||||
if (!ctx->type_cache || ctx->type_cache_cap == 0 || !id)
|
||||
return NULL;
|
||||
uint32_t mask = (uint32_t)ctx->type_cache_cap - 1;
|
||||
uint32_t idx = py_type_cache_hash(id) & mask;
|
||||
for (int probed = 0; probed < ctx->type_cache_cap; probed++) {
|
||||
const CBMPyTypeCacheEntry *e = &ctx->type_cache[idx];
|
||||
if (!e->node_id)
|
||||
return NULL; /* empty slot: this id was never inserted */
|
||||
if (e->node_id == id)
|
||||
return e->gen == ctx->type_cache_gen ? e->result : NULL;
|
||||
idx = (idx + 1) & mask;
|
||||
}
|
||||
return NULL; /* probe bound: unreachable below the 75% load ceiling */
|
||||
}
|
||||
|
||||
/* Doubles the table (arena allocation: the old table is abandoned and
|
||||
* freed with the per-file arena). Entries from stale generations are
|
||||
* dropped during the rehash — they can never match again. */
|
||||
static bool py_type_cache_grow(PyLSPContext *ctx) {
|
||||
if (ctx->type_cache_cap >= PY_TYPE_CACHE_MAX_CAP)
|
||||
return false;
|
||||
int new_cap = ctx->type_cache_cap ? ctx->type_cache_cap * 2 : PY_TYPE_CACHE_INITIAL_CAP;
|
||||
CBMPyTypeCacheEntry *new_entries = (CBMPyTypeCacheEntry *)cbm_arena_alloc(
|
||||
ctx->arena, (size_t)new_cap * sizeof(CBMPyTypeCacheEntry));
|
||||
if (!new_entries)
|
||||
return false;
|
||||
memset(new_entries, 0, (size_t)new_cap * sizeof(CBMPyTypeCacheEntry));
|
||||
|
||||
CBMPyTypeCacheEntry *old_entries = ctx->type_cache;
|
||||
int old_cap = ctx->type_cache_cap;
|
||||
ctx->type_cache = new_entries;
|
||||
ctx->type_cache_cap = new_cap;
|
||||
ctx->type_cache_count = 0;
|
||||
|
||||
uint32_t mask = (uint32_t)new_cap - 1;
|
||||
for (int i = 0; i < old_cap; i++) {
|
||||
if (!old_entries[i].node_id || old_entries[i].gen != ctx->type_cache_gen)
|
||||
continue;
|
||||
uint32_t idx = py_type_cache_hash(old_entries[i].node_id) & mask;
|
||||
while (new_entries[idx].node_id)
|
||||
idx = (idx + 1) & mask;
|
||||
new_entries[idx] = old_entries[i];
|
||||
ctx->type_cache_count++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void py_type_cache_insert(PyLSPContext *ctx, const void *id, const CBMType *result) {
|
||||
if (!id || !result)
|
||||
return;
|
||||
/* Keep load strictly below 75% so probe chains stay short. If growth
|
||||
* fails (OOM / max cap), REJECT the insert rather than filling up: a
|
||||
* full table would turn every probe loop into a full-table scan, and
|
||||
* an unbounded insert loop on a full table would spin forever. */
|
||||
if (!ctx->type_cache || (ctx->type_cache_count + 1) * 4 > ctx->type_cache_cap * 3) {
|
||||
if (!py_type_cache_grow(ctx) &&
|
||||
(!ctx->type_cache || (ctx->type_cache_count + 1) * 4 > ctx->type_cache_cap * 3))
|
||||
return;
|
||||
}
|
||||
uint32_t mask = (uint32_t)ctx->type_cache_cap - 1;
|
||||
uint32_t idx = py_type_cache_hash(id) & mask;
|
||||
for (int probed = 0; probed < ctx->type_cache_cap; probed++) {
|
||||
CBMPyTypeCacheEntry *e = &ctx->type_cache[idx];
|
||||
if (!e->node_id) {
|
||||
e->node_id = id;
|
||||
e->gen = ctx->type_cache_gen;
|
||||
e->result = result;
|
||||
ctx->type_cache_count++;
|
||||
return;
|
||||
}
|
||||
if (e->node_id == id) {
|
||||
e->gen = ctx->type_cache_gen; /* refresh a stale-generation entry in place */
|
||||
e->result = result;
|
||||
return;
|
||||
}
|
||||
idx = (idx + 1) & mask;
|
||||
}
|
||||
/* Probe bound exhausted — only reachable with a corrupt count; drop the
|
||||
* insert instead of spinning. */
|
||||
}
|
||||
|
||||
/* Memoizing, depth- and budget-guarded wrapper — the function every call
|
||||
* site in this file goes through. */
|
||||
static const CBMType *py_eval_expr_type(PyLSPContext *ctx, TSNode node) {
|
||||
if (!ctx || ts_node_is_null(node))
|
||||
return cbm_type_unknown();
|
||||
|
||||
/* Depth cap (issue #720): the evaluator recurses once per expression
|
||||
* nesting level, so a pathologically deep expression (tens of
|
||||
* thousands of parens) overflowed the native stack. Same limit as
|
||||
* C_EVAL_DEPTH_LIMIT. Past the cap: unknown, and NEVER cached. */
|
||||
if (ctx->eval_depth >= PY_LSP_MAX_EVAL_DEPTH) {
|
||||
ctx->eval_truncations++;
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
|
||||
const CBMType *cached = py_type_cache_lookup(ctx, node.id);
|
||||
if (cached)
|
||||
return cached;
|
||||
|
||||
/* Per-file work budget (mirrors C_EVAL_MAX_STEPS_PER_FILE): expression
|
||||
* type evaluation is best-effort, so pathological files degrade to
|
||||
* unknown instead of stalling repository indexing. Only real
|
||||
* evaluations consume budget — cache hits above are O(1). */
|
||||
if (ctx->eval_steps++ > PY_EVAL_MAX_STEPS_PER_FILE) {
|
||||
ctx->eval_truncations++;
|
||||
if (ctx->debug && ctx->eval_steps == PY_EVAL_MAX_STEPS_PER_FILE + 2) {
|
||||
fprintf(stderr, " [pylsp] expression eval step budget exhausted; returning unknown\n");
|
||||
}
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
|
||||
uint32_t trunc_before = ctx->eval_truncations;
|
||||
ctx->eval_depth++;
|
||||
const CBMType *result = py_eval_expr_type_uncached(ctx, node);
|
||||
ctx->eval_depth--;
|
||||
if (!result)
|
||||
result = cbm_type_unknown();
|
||||
|
||||
/* Only cache full-fidelity results: if any descendant evaluation was
|
||||
* cut off by the depth cap or the step budget, this result may be a
|
||||
* truncated `unknown`. Caching it would poison later evaluations that
|
||||
* reach the same node from a shallower frame (or with fresh budget) —
|
||||
* exactly the reuse the guards are supposed to preserve. */
|
||||
if (ctx->eval_truncations == trunc_before)
|
||||
py_type_cache_insert(ctx, node.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── statement processing: bind from assignments, for-loops, with-as ──── */
|
||||
|
||||
static void py_process_statement(PyLSPContext *ctx, TSNode node) {
|
||||
@@ -1383,14 +1583,14 @@ static void py_process_statement(PyLSPContext *ctx, TSNode node) {
|
||||
} else {
|
||||
bind_type = elem_type ? elem_type : cbm_type_unknown();
|
||||
}
|
||||
cbm_scope_bind(ctx->current_scope, nm, bind_type);
|
||||
py_scope_bind(ctx, nm, bind_type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strcmp(lk, "identifier") == 0) {
|
||||
char *name = py_node_text(ctx, left);
|
||||
if (name) {
|
||||
cbm_scope_bind(ctx->current_scope, name, rhs_type);
|
||||
py_scope_bind(ctx, name, rhs_type);
|
||||
// Lambda registry: `fn = lambda x: ...`.
|
||||
if (!ts_node_is_null(right) && strcmp(ts_node_type(right), "lambda") == 0) {
|
||||
py_register_lambda(ctx, name, right);
|
||||
@@ -1526,7 +1726,7 @@ static void py_process_statement(PyLSPContext *ctx, TSNode node) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cbm_scope_bind(ctx->current_scope, name, bind_type);
|
||||
py_scope_bind(ctx, name, bind_type);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1577,7 +1777,7 @@ static void py_process_statement(PyLSPContext *ctx, TSNode node) {
|
||||
char *nm = py_node_text(ctx, exc_name);
|
||||
char *tn = py_node_text(ctx, exc_type);
|
||||
if (nm && tn) {
|
||||
cbm_scope_bind(ctx->current_scope, nm, py_resolve_annotation(ctx, tn));
|
||||
py_scope_bind(ctx, nm, py_resolve_annotation(ctx, tn));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1628,7 +1828,7 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) {
|
||||
}
|
||||
if (pname) {
|
||||
const CBMType *arg_type = py_eval_expr_type(ctx, a);
|
||||
cbm_scope_bind(ctx->current_scope, pname, arg_type);
|
||||
py_scope_bind(ctx, pname, arg_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1641,7 +1841,7 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) {
|
||||
py_resolve_calls_in(ctx, body);
|
||||
}
|
||||
ctx->enclosing_func_qn = prev_func;
|
||||
ctx->current_scope = saved;
|
||||
py_scope_restore(ctx, saved);
|
||||
return;
|
||||
}
|
||||
// Constructor call (ClassName())
|
||||
@@ -2025,7 +2225,7 @@ static void py_bind_walrus_in(PyLSPContext *ctx, TSNode node) {
|
||||
char *name = py_node_text(ctx, left);
|
||||
if (name) {
|
||||
const CBMType *t = py_eval_expr_type(ctx, right);
|
||||
cbm_scope_bind(ctx->current_scope, name, t);
|
||||
py_scope_bind(ctx, name, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2107,7 +2307,7 @@ static void py_walk_if_statement(PyLSPContext *ctx, TSNode if_node) {
|
||||
char *name = NULL;
|
||||
const CBMType *narrowed = NULL;
|
||||
if (py_match_isinstance(ctx, cond, &name, &narrowed) && name && narrowed) {
|
||||
cbm_scope_bind(ctx->current_scope, name, narrowed);
|
||||
py_scope_bind(ctx, name, narrowed);
|
||||
}
|
||||
}
|
||||
// Try `x is not None` narrowing.
|
||||
@@ -2117,12 +2317,12 @@ static void py_walk_if_statement(PyLSPContext *ctx, TSNode if_node) {
|
||||
const CBMType *current = cbm_scope_lookup(ctx->current_scope, none_var);
|
||||
if (current && !cbm_type_is_unknown(current)) {
|
||||
const CBMType *narrowed = py_strip_none(ctx, current);
|
||||
cbm_scope_bind(ctx->current_scope, none_var, narrowed);
|
||||
py_scope_bind(ctx, none_var, narrowed);
|
||||
}
|
||||
}
|
||||
|
||||
py_resolve_calls_in(ctx, body);
|
||||
ctx->current_scope = saved;
|
||||
py_scope_restore(ctx, saved);
|
||||
}
|
||||
|
||||
// Alternative branch: include else / elif. Recurse without narrowing —
|
||||
@@ -2136,14 +2336,14 @@ static void py_walk_if_statement(PyLSPContext *ctx, TSNode if_node) {
|
||||
// enclosing scope so subsequent statements see the narrowed type.
|
||||
if (!ts_node_is_null(body) && py_block_terminates(body)) {
|
||||
if (neg_isinstance && neg_name && neg_type) {
|
||||
cbm_scope_bind(ctx->current_scope, neg_name, neg_type);
|
||||
py_scope_bind(ctx, neg_name, neg_type);
|
||||
}
|
||||
if (neg_none_kind == -1 && neg_none_var) {
|
||||
// `if x is None: return` -> narrow x to non-None afterwards.
|
||||
const CBMType *current = cbm_scope_lookup(ctx->current_scope, neg_none_var);
|
||||
if (current && !cbm_type_is_unknown(current)) {
|
||||
const CBMType *narrowed = py_strip_none(ctx, current);
|
||||
cbm_scope_bind(ctx->current_scope, neg_none_var, narrowed);
|
||||
py_scope_bind(ctx, neg_none_var, narrowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2314,7 +2514,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
char *cls_text = py_node_text(ctx, cls);
|
||||
if (cls_text) {
|
||||
const CBMType *narrowed = py_resolve_annotation(ctx, cls_text);
|
||||
cbm_scope_bind(ctx->current_scope, subject_name, narrowed);
|
||||
py_scope_bind(ctx, subject_name, narrowed);
|
||||
}
|
||||
}
|
||||
// Bind capture sub-patterns to UNKNOWN.
|
||||
@@ -2325,7 +2525,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
TSNode id = ts_node_named_child(sub, 0);
|
||||
char *nm = py_node_text(ctx, id);
|
||||
if (nm)
|
||||
cbm_scope_bind(ctx->current_scope, nm, cbm_type_unknown());
|
||||
py_scope_bind(ctx, nm, cbm_type_unknown());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2359,7 +2559,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
if (strcmp(ts_node_type(id), "identifier") == 0) {
|
||||
char *nm = py_node_text(ctx, id);
|
||||
if (nm && elem_t) {
|
||||
cbm_scope_bind(ctx->current_scope, nm, elem_t);
|
||||
py_scope_bind(ctx, nm, elem_t);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -2367,7 +2567,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
if (strcmp(ek, "identifier") == 0) {
|
||||
char *nm = py_node_text(ctx, elem);
|
||||
if (nm && elem_t) {
|
||||
cbm_scope_bind(ctx->current_scope, nm, elem_t);
|
||||
py_scope_bind(ctx, nm, elem_t);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -2395,7 +2595,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
if (nm && elem_t) {
|
||||
const CBMType *lst =
|
||||
cbm_type_template(ctx->arena, "list", &elem_t, 1);
|
||||
cbm_scope_bind(ctx->current_scope, nm, lst);
|
||||
py_scope_bind(ctx, nm, lst);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2405,7 +2605,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
if (!ts_node_is_null(conseq)) {
|
||||
py_resolve_calls_in(ctx, conseq);
|
||||
}
|
||||
ctx->current_scope = saved;
|
||||
py_scope_restore(ctx, saved);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2440,7 +2640,7 @@ static void py_resolve_calls_in_inner(PyLSPContext *ctx, TSNode node) {
|
||||
for (uint32_t i = 0; i < cnc; i++) {
|
||||
py_resolve_calls_in(ctx, ts_node_named_child(node, i));
|
||||
}
|
||||
ctx->current_scope = saved;
|
||||
py_scope_restore(ctx, saved);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3002,7 +3202,7 @@ static void py_bind_parameters(PyLSPContext *ctx, TSNode params) {
|
||||
const CBMType *args[3] = {str_t, t, NULL};
|
||||
t = cbm_type_template(ctx->arena, "dict", args, 2);
|
||||
}
|
||||
cbm_scope_bind(ctx->current_scope, name, t);
|
||||
py_scope_bind(ctx, name, t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3027,9 +3227,9 @@ static void py_process_function(PyLSPContext *ctx, TSNode func_node, const char
|
||||
// For methods, bind `self`/`cls` AFTER param walk so the receiver type
|
||||
// wins over the unannotated `self` / `cls` parameter declaration.
|
||||
if (ctx->enclosing_class_qn) {
|
||||
cbm_scope_bind(ctx->current_scope, "self",
|
||||
py_scope_bind(ctx, "self",
|
||||
cbm_type_named(ctx->arena, ctx->enclosing_class_qn));
|
||||
cbm_scope_bind(ctx->current_scope, "cls",
|
||||
py_scope_bind(ctx, "cls",
|
||||
cbm_type_named(ctx->arena, ctx->enclosing_class_qn));
|
||||
}
|
||||
|
||||
@@ -3053,7 +3253,7 @@ static void py_process_function(PyLSPContext *ctx, TSNode func_node, const char
|
||||
}
|
||||
}
|
||||
|
||||
ctx->current_scope = saved;
|
||||
py_scope_restore(ctx, saved);
|
||||
ctx->enclosing_func_qn = prev_func;
|
||||
}
|
||||
|
||||
@@ -3166,7 +3366,7 @@ static void py_bind_module_classes(PyLSPContext *ctx) {
|
||||
continue;
|
||||
if (qn[prefix_len] != '.')
|
||||
continue;
|
||||
cbm_scope_bind(ctx->current_scope, sname, cbm_type_named(ctx->arena, qn));
|
||||
py_scope_bind(ctx, sname, cbm_type_named(ctx->arena, qn));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,20 @@ typedef struct {
|
||||
const char *target_qn; // e.g. "test.main.foo"
|
||||
} CBMDictLiteralEntry;
|
||||
|
||||
// Memoization entry for py_eval_expr_type (issue #710). Keyed by the
|
||||
// tree-sitter node identity pointer (TSNode.id), which is unique per node
|
||||
// within one file's parse tree. NOT the start byte: every leftmost
|
||||
// descendant of a chained call expression shares the chain's start byte,
|
||||
// so byte-keyed entries would alias distinct nodes and silently corrupt
|
||||
// call resolution. `gen` snapshots the scope generation (see
|
||||
// py_scope_bind in py_lsp.c); entries from older generations are treated
|
||||
// as misses so scope rebinding can never serve a stale type.
|
||||
typedef struct {
|
||||
const void *node_id; // TSNode.id; NULL marks an empty slot
|
||||
uint32_t gen; // scope generation at insert time
|
||||
const CBMType *result; // full-fidelity result, never NULL in a live entry
|
||||
} CBMPyTypeCacheEntry;
|
||||
|
||||
// PyLSPContext holds state for one Python file's type evaluation.
|
||||
typedef struct {
|
||||
CBMArena *arena;
|
||||
@@ -73,6 +87,17 @@ typedef struct {
|
||||
// deeply-nested/cyclic files; see cbm_lsp_max_walk_depth). Zero via memset.
|
||||
int walk_depth;
|
||||
|
||||
// py_eval_expr_type memoization + guards (issues #710/#720; mirrors
|
||||
// c_eval_expr_type's guard design in c_lsp.c). All zero via memset —
|
||||
// each file starts with a cold cache and a full budget.
|
||||
CBMPyTypeCacheEntry *type_cache; // open addressing, linear probe, arena-allocated
|
||||
int type_cache_count; // occupied slots (kept < 75% of cap)
|
||||
int type_cache_cap; // power-of-two capacity
|
||||
uint32_t type_cache_gen; // bumped on every scope mutation (O(1) flush)
|
||||
int eval_depth; // evaluator recursion depth (PY_LSP_MAX_EVAL_DEPTH)
|
||||
int eval_steps; // per-file work budget used (PY_EVAL_MAX_STEPS_PER_FILE)
|
||||
uint32_t eval_truncations; // depth/budget cutoff count — gates memo inserts
|
||||
|
||||
// Debug mode (CBM_LSP_DEBUG env, shared across all language LSPs).
|
||||
bool debug;
|
||||
} PyLSPContext;
|
||||
|
||||
@@ -1243,6 +1243,122 @@ TEST(pylsp_round6_property_access_chains) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Evaluator guards — memoization, depth cap, step budget ─────
|
||||
* (issues #710 and #720; distilled from PRs #732 and #758) */
|
||||
|
||||
TEST(pylsp_issue710_deep_call_chain_resolves) {
|
||||
/* Regression for #710: py_eval_expr_type evaluated a call node's
|
||||
* attribute receiver TWICE (container special-case + general
|
||||
* attribute path), so an N-link chained builder expression cost
|
||||
* O(2^N) evaluations — ~65-link real-world chains hung indexing for
|
||||
* hours. With per-node memoization the chain is O(N). On unfixed
|
||||
* code this 40-link chain is ~2^40 evaluations: the test cannot
|
||||
* finish and the suite times out, rather than passing silently.
|
||||
* We also require the FINAL link to actually resolve — the fix must
|
||||
* preserve resolution quality, not just terminate. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Self\n"
|
||||
"class G:\n"
|
||||
" def add(self, x) -> Self:\n"
|
||||
" return self\n"
|
||||
" def compile(self):\n"
|
||||
" return 1\n"
|
||||
"def build():\n"
|
||||
" return (\n"
|
||||
" G()\n"
|
||||
" .add(1).add(2).add(3).add(4).add(5).add(6).add(7).add(8)\n"
|
||||
" .add(9).add(10).add(11).add(12).add(13).add(14).add(15)\n"
|
||||
" .add(16).add(17).add(18).add(19).add(20).add(21).add(22)\n"
|
||||
" .add(23).add(24).add(25).add(26).add(27).add(28).add(29)\n"
|
||||
" .add(30).add(31).add(32).add(33).add(34).add(35).add(36)\n"
|
||||
" .add(37).add(38).add(39).add(40)\n"
|
||||
" .compile()\n"
|
||||
" )\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "build", "G.add"), 0);
|
||||
ASSERT_GTE(require_resolved(r, "build", "G.compile"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_issue710_heterogeneous_receiver_chain) {
|
||||
/* Guard for the memo cache's KEY choice. Every leftmost descendant of
|
||||
* `c.session().execute().fetch()` — the outer call, each receiver
|
||||
* call, down to the identifier `c` — starts at the same byte, so a
|
||||
* cache keyed by start byte (PR #732's approach) aliases all of them:
|
||||
* after `c` -> Client is inserted, the receiver call `c.session()`
|
||||
* reads Client back instead of Session and the rest of the chain
|
||||
* resolves wrongly or not at all. Each link here returns a DIFFERENT
|
||||
* class so any such aliasing changes an assertable QN. Keying by node
|
||||
* identity (TSNode.id) keeps every link distinct. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Result:\n"
|
||||
" def fetch(self):\n"
|
||||
" return 1\n"
|
||||
"class Session:\n"
|
||||
" def execute(self) -> \"Result\":\n"
|
||||
" return Result()\n"
|
||||
"class Client:\n"
|
||||
" def session(self) -> \"Session\":\n"
|
||||
" return Session()\n"
|
||||
"def run():\n"
|
||||
" c = Client()\n"
|
||||
" return c.session().execute().fetch()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "run", "Client.session"), 0);
|
||||
ASSERT_GTE(require_resolved(r, "run", "Session.execute"), 0);
|
||||
ASSERT_GTE(require_resolved(r, "run", "Result.fetch"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_eval_steps_budget_degrades_gracefully) {
|
||||
/* PY_EVAL_MAX_STEPS_PER_FILE guard: a file whose expressions demand
|
||||
* more evaluator work than the per-file budget must still complete
|
||||
* quickly and keep everything resolved BEFORE exhaustion — graceful
|
||||
* partial degradation, not a hang, crash, or corrupted result. 90
|
||||
* statements x 30-link chains re-evaluated across the bind + emit
|
||||
* phases (the assignment bind flushes the memo generation in between)
|
||||
* comfortably exceed the 10000-step budget. */
|
||||
enum { BUDGET_STMTS = 90, BUDGET_LINKS = 30 };
|
||||
size_t sz = 4096 + (size_t)BUDGET_STMTS * (32 + (size_t)BUDGET_LINKS * 8);
|
||||
char *src = malloc(sz);
|
||||
ASSERT_NOT_NULL(src);
|
||||
char *p = src;
|
||||
size_t left = sz;
|
||||
int n = snprintf(p, left,
|
||||
"from typing import Self\n"
|
||||
"class G:\n"
|
||||
" def add(self, x) -> Self:\n"
|
||||
" return self\n"
|
||||
" def compile(self):\n"
|
||||
" return 1\n"
|
||||
"def run():\n");
|
||||
p += n;
|
||||
left -= (size_t)n;
|
||||
for (int s = 0; s < BUDGET_STMTS; s++) {
|
||||
n = snprintf(p, left, " v%d = G()", s);
|
||||
p += n;
|
||||
left -= (size_t)n;
|
||||
for (int l = 0; l < BUDGET_LINKS; l++) {
|
||||
n = snprintf(p, left, ".add(%d)", l);
|
||||
p += n;
|
||||
left -= (size_t)n;
|
||||
}
|
||||
n = snprintf(p, left, ".compile()\n");
|
||||
p += n;
|
||||
left -= (size_t)n;
|
||||
}
|
||||
snprintf(p, left, " return v0\n");
|
||||
CBMFileResult *r = extract_py(src);
|
||||
free(src);
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* The first statements run with budget to spare — they must resolve. */
|
||||
ASSERT_GTE(require_resolved(r, "run", "G.compile"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(py_lsp) {
|
||||
@@ -1318,4 +1434,8 @@ SUITE(py_lsp) {
|
||||
RUN_TEST(pylsp_round6_generator_yields_iterable);
|
||||
RUN_TEST(pylsp_round6_dataclass_field_access);
|
||||
RUN_TEST(pylsp_round6_property_access_chains);
|
||||
/* Evaluator guards — #710/#720 (distilled PRs #732 + #758) */
|
||||
RUN_TEST(pylsp_issue710_deep_call_chain_resolves);
|
||||
RUN_TEST(pylsp_issue710_heterogeneous_receiver_chain);
|
||||
RUN_TEST(pylsp_eval_steps_budget_degrades_gracefully);
|
||||
}
|
||||
|
||||
@@ -487,6 +487,31 @@ TEST(lsp_cpp_deep_expression_no_crash) {
|
||||
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_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
|
||||
@@ -623,6 +648,7 @@ SUITE(stack_overflow) {
|
||||
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_ts_cyclic_types_no_crash);
|
||||
RUN_TEST(lsp_python_deep_nesting_no_crash);
|
||||
RUN_TEST(lsp_go_deep_nesting_no_crash);
|
||||
|
||||
Reference in New Issue
Block a user