diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 6e13f54ed..7ebc378eb 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -1268,11 +1268,6 @@ static const CBMRegisteredFunc *py_lookup_attribute_depth(PyLSPContext *ctx, con return NULL; } -static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const char *type_qn, - const char *member_name) { - return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); -} - static PyModuleMember py_module_member_exact(PyLSPContext *ctx, const char *module_qn, const char *name) { PyModuleMember m = {0}; @@ -1352,20 +1347,85 @@ static void py_emit_module_member_call(PyLSPContext *ctx, const char *callee_qn, } } -/* ── super(): the C3 method resolution order ──────────────────────── */ +/* ── The C3 method resolution order ──────────────────────────────── */ /* Longest linearization followed. A longer one (or more bases than this) keeps - * the base-by-base walk of the caller. */ + * the depth-first walk above. */ #define PY_C3_MAX 64 -/* The linearizations computed during one super() lookup: every class of a - * diamond is linearized once, however many paths reach it. */ -typedef struct { - const char *qn[PY_C3_MAX]; - const char **mro[PY_C3_MAX]; - int len[PY_C3_MAX]; - int count; -} py_c3_cache_t; +enum { PY_MRO_CACHE_INITIAL_CAP = 64 }; /* power of two (index masking) */ +enum { PY_MRO_CACHE_MAX_CAP = 1 << 20 }; /* hard stop for adversarial files */ + +static uint32_t py_mro_hash(const char *qn) { + uint32_t h = 2166136261u; /* FNV-1a */ + for (const unsigned char *p = (const unsigned char *)qn; *p; p++) { + h = (h ^ *p) * 16777619u; + } + return h; +} + +static const CBMPyMroCacheEntry *py_mro_cache_find(const PyLSPContext *ctx, const char *qn, + uint32_t h) { + if (!ctx->mro_cache || ctx->mro_cache_cap == 0) + return NULL; + uint32_t mask = (uint32_t)ctx->mro_cache_cap - 1; + uint32_t idx = h & mask; + for (int probed = 0; probed < ctx->mro_cache_cap; probed++) { + const CBMPyMroCacheEntry *e = &ctx->mro_cache[idx]; + if (!e->qn) + return NULL; + if (e->hash == h && strcmp(e->qn, qn) == 0) + return e; + idx = (idx + 1) & mask; + } + return NULL; +} + +/* Doubles the table (arena allocation: the old table is freed with the + * per-file arena). */ +static bool py_mro_cache_grow(PyLSPContext *ctx) { + if (ctx->mro_cache_cap >= PY_MRO_CACHE_MAX_CAP) + return false; + int new_cap = ctx->mro_cache_cap ? ctx->mro_cache_cap * 2 : PY_MRO_CACHE_INITIAL_CAP; + CBMPyMroCacheEntry *entries = (CBMPyMroCacheEntry *)cbm_arena_alloc( + ctx->arena, (size_t)new_cap * sizeof(CBMPyMroCacheEntry)); + if (!entries) + return false; + memset(entries, 0, (size_t)new_cap * sizeof(CBMPyMroCacheEntry)); + uint32_t mask = (uint32_t)new_cap - 1; + for (int i = 0; i < ctx->mro_cache_cap; i++) { + const CBMPyMroCacheEntry *e = &ctx->mro_cache[i]; + if (!e->qn) + continue; + uint32_t idx = e->hash & mask; + while (entries[idx].qn) + idx = (idx + 1) & mask; + entries[idx] = *e; + } + ctx->mro_cache = entries; + ctx->mro_cache_cap = new_cap; + return true; +} + +/* Records the order of `qn` (len -1: none). A table that cannot grow only + * costs a recomputation later. */ +static void py_mro_cache_put(PyLSPContext *ctx, const char *qn, uint32_t h, const char **mro, + int len) { + if ((ctx->mro_cache_count + 1) * 4 > ctx->mro_cache_cap * 3 && !py_mro_cache_grow(ctx)) + return; + const char *key = cbm_arena_strdup(ctx->arena, qn); + if (!key) + return; + uint32_t mask = (uint32_t)ctx->mro_cache_cap - 1; + uint32_t idx = h & mask; + while (ctx->mro_cache[idx].qn) + idx = (idx + 1) & mask; + ctx->mro_cache[idx].qn = key; + ctx->mro_cache[idx].hash = h; + ctx->mro_cache[idx].len = len; + ctx->mro_cache[idx].mro = mro; + ctx->mro_cache_count++; +} static bool py_c3_in_tail(const char *qn, const char *const *seq, int head, int len) { for (int k = head + 1; k < len; k++) { @@ -1393,74 +1453,106 @@ static const char *py_c3_pick(const char **const *seqs, const int *lens, const i return NULL; } -/* Python's method resolution order of `type_qn`: the class, then the C3 merge - * of its bases' linearizations and of the base list. A common base therefore - * follows every class deriving from it. Sets *out to an arena array and returns - * its length, or -1 when there is no C3 order, the hierarchy is deeper than - * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the - * registry does not know (external, unresolved) linearizes to itself. */ -static int py_c3_linearize(PyLSPContext *ctx, py_c3_cache_t *cache, const char *type_qn, - const char ***out, int depth) { - for (int c = 0; c < cache->count; c++) { - if (strcmp(cache->qn[c], type_qn) == 0) { - *out = cache->mro[c]; - return cache->len[c]; - } - } - if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) - return -1; - const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, type_qn); - if (rt && rt->alias_of) - return py_c3_linearize(ctx, cache, rt->alias_of, out, depth + 1); - const char **bases = rt ? rt->embedded_types : NULL; - int nb = 0; - while (bases && bases[nb]) - nb++; - if (nb >= PY_C3_MAX) - return -1; - const char **seqs[PY_C3_MAX]; - int lens[PY_C3_MAX]; - int heads[PY_C3_MAX]; - for (int b = 0; b < nb; b++) { - lens[b] = py_c3_linearize(ctx, cache, bases[b], &seqs[b], depth + 1); - if (lens[b] < 0) - return -1; - heads[b] = 0; - } - seqs[nb] = bases; - lens[nb] = nb; - heads[nb] = 0; - const char **mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(char *)); - if (!mro) - return -1; - int n = 0; - mro[n++] = type_qn; +/* The C3 merge of `nseq` sequences into mro[1..]: returns the order's length, + * or -1 when there is none or it is longer than PY_C3_MAX. */ +static int py_c3_merge(const char **const *seqs, const int *lens, int *heads, int nseq, + const char **mro) { + int n = 1; for (;;) { bool pending = false; - for (int s = 0; s <= nb && !pending; s++) { + for (int s = 0; s < nseq && !pending; s++) { pending = heads[s] < lens[s]; } if (!pending) - break; - const char *pick = py_c3_pick((const char **const *)seqs, lens, heads, nb + 1); + return n; + const char *pick = py_c3_pick(seqs, lens, heads, nseq); if (!pick || n >= PY_C3_MAX) return -1; mro[n++] = pick; - for (int s = 0; s <= nb; s++) { + for (int s = 0; s < nseq; s++) { if (heads[s] < lens[s] && strcmp(seqs[s][heads[s]], pick) == 0) heads[s]++; } } - if (cache->count < PY_C3_MAX) { - cache->qn[cache->count] = type_qn; - cache->mro[cache->count] = mro; - cache->len[cache->count] = n; - cache->count++; +} + +/* Python's method resolution order of `type_qn`: the class, then the C3 merge + * of its bases' linearizations and of the base list. A common base therefore + * follows every class deriving from it. Computed once per class and file + * (ctx->mro_cache). Sets *out to an arena array and returns its length, or -1 + * when there is no C3 order, the hierarchy is deeper than + * CBM_LSP_MAX_LOOKUP_DEPTH, or the order is longer than PY_C3_MAX. A base the + * registry does not know (external, unresolved) linearizes to itself. Only an + * order computed from the top (depth 0) is cached as missing: below it, the + * depth cap may have cut a hierarchy that is fine on its own. */ +static int py_c3_linearize(PyLSPContext *ctx, const char *type_qn, const char ***out, int depth) { + uint32_t h = py_mro_hash(type_qn); + const CBMPyMroCacheEntry *hit = py_mro_cache_find(ctx, type_qn, h); + if (hit) { + *out = hit->mro; + return hit->len; } - *out = mro; + int n = -1; + const char **mro = NULL; + const CBMRegisteredType *rt = + depth <= CBM_LSP_MAX_LOOKUP_DEPTH ? cbm_registry_lookup_type(ctx->registry, type_qn) : NULL; + const char **bases = rt ? rt->embedded_types : NULL; + int nb = 0; + while (bases && bases[nb]) + nb++; + if (depth > CBM_LSP_MAX_LOOKUP_DEPTH || nb >= PY_C3_MAX) { + n = -1; + } else if (rt && rt->alias_of) { + n = py_c3_linearize(ctx, rt->alias_of, &mro, depth + 1); + } else { + const char ***seqs = + (const char ***)cbm_arena_alloc(ctx->arena, (size_t)(nb + 1) * sizeof(const char **)); + int *lens = (int *)cbm_arena_alloc(ctx->arena, (size_t)(nb + 1) * 2 * sizeof(int)); + mro = (const char **)cbm_arena_alloc(ctx->arena, PY_C3_MAX * sizeof(const char *)); + bool ok = seqs && lens && mro; + int *heads = lens ? lens + nb + 1 : NULL; + for (int b = 0; ok && b < nb; b++) { + lens[b] = py_c3_linearize(ctx, bases[b], &seqs[b], depth + 1); + heads[b] = 0; + ok = lens[b] >= 0; + } + if (ok) { + seqs[nb] = bases; + lens[nb] = nb; + heads[nb] = 0; + mro[0] = cbm_arena_strdup(ctx->arena, type_qn); + n = mro[0] ? py_c3_merge((const char **const *)seqs, lens, heads, nb + 1, mro) : -1; + } + } + if (n >= 0 || depth == 0) + py_mro_cache_put(ctx, type_qn, h, n >= 0 ? mro : NULL, n); + *out = n >= 0 ? mro : NULL; return n; } +/* Attribute lookup along the class's C3 order: the class itself, then each + * class of its order that declares the member. A hierarchy without a C3 order + * keeps the depth-first walk. */ +static const CBMRegisteredFunc *py_lookup_attribute(PyLSPContext *ctx, const char *type_qn, + const char *member_name) { + if (!ctx || !type_qn || !member_name) + return NULL; + const CBMRegisteredFunc *f = cbm_registry_lookup_method(ctx->registry, type_qn, member_name); + if (f) + return f; + const char **mro = NULL; + int n = py_c3_linearize(ctx, type_qn, &mro, 0); + if (n < 0) + return py_lookup_attribute_depth(ctx, type_qn, member_name, 0); + /* mro[0] is the class itself, already asked, or an alias's target. */ + for (int i = strcmp(mro[0], type_qn) == 0 ? 1 : 0; i < n; i++) { + f = cbm_registry_lookup_method(ctx->registry, mro[i], member_name); + if (f) + return f; + } + return NULL; +} + /* The method super() reaches from inside `class_qn`: the first class AFTER it in * its C3 order that declares `member` itself. *have_mro is false when the * hierarchy has no C3 order; the caller then keeps its base-by-base walk. */ @@ -1469,10 +1561,8 @@ static const CBMRegisteredFunc *py_lookup_super_attribute(PyLSPContext *ctx, con *have_mro = false; if (!ctx || !class_qn || !member) return NULL; - py_c3_cache_t cache; - cache.count = 0; const char **mro = NULL; - int n = py_c3_linearize(ctx, &cache, class_qn, &mro, 0); + int n = py_c3_linearize(ctx, class_qn, &mro, 0); if (n < 0) return NULL; *have_mro = true; diff --git a/internal/cbm/lsp/py_lsp.h b/internal/cbm/lsp/py_lsp.h index a838f47c5..87eebe2ea 100644 --- a/internal/cbm/lsp/py_lsp.h +++ b/internal/cbm/lsp/py_lsp.h @@ -38,6 +38,16 @@ typedef struct { const CBMType *result; // full-fidelity result, never NULL in a live entry } CBMPyTypeCacheEntry; +// A class's C3 method resolution order (py_c3_linearize), computed once per +// class and file. The registry is complete before the walk starts, so an entry +// never goes stale. +typedef struct { + const char *qn; // class QN; NULL marks an empty slot + uint32_t hash; // hash of qn + int len; // entries in mro; -1 = the class has no C3 order + const char **mro; // arena-allocated, mro[0] is the class (or its alias target) +} CBMPyMroCacheEntry; + // PyLSPContext holds state for one Python file's type evaluation. typedef struct { CBMArena *arena; @@ -113,6 +123,11 @@ typedef struct { int eval_depth; // evaluator recursion depth (PY_LSP_MAX_EVAL_DEPTH) uint32_t eval_truncations; // depth-cap cutoff count — gates memo inserts CBMLSPStatus eval_failure; // sticky: explicit failure makes this walk incomplete + // C3 orders of the classes whose attributes this file looks up: one + // linearization per class, open addressing, arena-allocated. + CBMPyMroCacheEntry *mro_cache; + int mro_cache_count; // occupied slots (kept < 75% of cap) + int mro_cache_cap; // power-of-two capacity // Pending links of the left-associative chains being evaluated // iteratively (operator, receiver, subscript, conditional and // parenthesis chains): one stack shared by nested evaluations. diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 8a7a2f722..269854e48 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15938,6 +15938,92 @@ TEST(pipeline_python_super_follows_c3_mro_parallel) { return pym_run_super_c3(52); } +/* ── attribute lookup follows the C3 method resolution order ─────── */ + +/* The same diamond as the super() test, seen from an ordinary attribute: + * `self.write_state()` in RadarrCalendarEntity and `e.write_state()` on a + * RadarrCalendarEntity both look the method up along the class's C3 order, + * which reaches CalendarEntity before the shared base Entity. */ +static const char pya_radarr_py[] = "from base import CoordinatorEntity, CalendarEntity\n" + "\n" + "class RadarrEntity(CoordinatorEntity):\n" + " pass\n" + "\n" + "class RadarrCalendarEntity(RadarrEntity, CalendarEntity):\n" + " def refresh(self):\n" + " return self.write_state()\n" + "\n" + "class PlainCalendar(CalendarEntity):\n" + " def refresh(self):\n" + " return self.write_state()\n" + "\n" + "def publish(e: RadarrCalendarEntity):\n" + " return e.write_state()\n"; + +static int pya_run_attr_c3(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_attrc3_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "base.py", pym_base_py); + write_temp_file(tmp, "radarr.py", pya_radarr_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/attrc3.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int rc = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc == 0 ? cbm_store_open_path(db_path) : NULL; + const char *pr = p ? cbm_pipeline_project_name(p) : NULL; + const char *c3 = "base.CalendarEntity.write_state"; + const char *dfs = "base.Entity.write_state"; + const char *self_src = "radarr.RadarrCalendarEntity.refresh"; + bool self_c3 = s && pyn_edge(s, pr, self_src, c3, "CALLS"); + bool self_dfs = s && pyn_edge(s, pr, self_src, dfs, "CALLS"); + bool typed_c3 = s && pyn_edge(s, pr, "radarr.publish", c3, "CALLS"); + bool typed_dfs = s && pyn_edge(s, pr, "radarr.publish", dfs, "CALLS"); + bool plain = s && pyn_edge(s, pr, "radarr.PlainCalendar.refresh", c3, "CALLS"); + if (s) { + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(self_c3); /* self.write_state(): CalendarEntity before Entity */ + ASSERT_FALSE(self_dfs); /* ... never Entity through RadarrEntity's ancestry */ + ASSERT_TRUE(typed_c3); /* e.write_state() on a RadarrCalendarEntity */ + ASSERT_FALSE(typed_dfs); + ASSERT_TRUE(plain); /* control: single inheritance */ + PASS(); +} + +TEST(pipeline_python_attribute_follows_c3_mro) { + return pya_run_attr_c3(0); +} + +TEST(pipeline_python_attribute_follows_c3_mro_parallel) { + return pya_run_attr_c3(52); +} + /* L7 review of #1548: a package __init__.py function can share a submodule's QN * (poetry: layouts/__init__.py `def layout` is the QN prefix of every symbol in * layouts/layout.py). The function-local detection is bounded at the symbol's @@ -25896,6 +25982,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_local_def_shadows_module_def_parallel_issue1548); RUN_TEST(pipeline_python_super_follows_c3_mro); RUN_TEST(pipeline_python_super_follows_c3_mro_parallel); + RUN_TEST(pipeline_python_attribute_follows_c3_mro); + RUN_TEST(pipeline_python_attribute_follows_c3_mro_parallel); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); RUN_TEST(pipeline_python_class_body_sibling_base);