fix(lsp): detach field arrays before overlay refinement

Copy inherited field-type pointer arrays into the writable registry arena
before Python refines an existing field. Keep shared type objects intact
and avoid retaining per-file scratch types in the sealed fallback.

Add deterministic shared-state and later-overlay coverage, retaining the
sequential Python field regression and adding a forced-parallel variant.

Refs #1277

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-03 18:25:03 +02:00
1 parent 519c765e1f
commit bf2daa3603
3 files changed
+203 -20

No files matched your search

+21 -1
View File
@@ -677,8 +677,28 @@ CBMRegisteredType *cbm_registry_type_for_update(CBMTypeRegistry *head, const cha
for (const CBMTypeRegistry *r = head->fallback; r; r = r->fallback) {
const CBMRegisteredType *base = lookup_type_self(r, qualified_name);
if (base) {
CBMRegisteredType copy = *base;
/* Field refinements overwrite entries in this array. The type
* objects remain immutable, but the writable overlay must own
* the pointer array rather than mutate its shared fallback. */
if (base->field_types) {
size_t count = 0;
/* Types are parallel to names; a named field may have no
* type, so an interior NULL does not terminate that array. */
while (base->field_names ? base->field_names[count] != NULL
: base->field_types[count] != NULL) {
count++;
}
const CBMType **field_types = (const CBMType **)cbm_arena_alloc(
head->arena, (count + 1) * sizeof(*field_types));
if (!field_types) {
return NULL;
}
memcpy(field_types, base->field_types, (count + 1) * sizeof(*field_types));
copy.field_types = field_types;
}
int before = head->type_count;
cbm_registry_add_type(head, *base);
cbm_registry_add_type(head, copy);
if (head->type_count == before + 1) {
return &head->types[before];
}
+115
View File
@@ -15350,6 +15350,10 @@ TEST(registry_overlay_chain_iterates_and_copies_on_write) {
memset(&t, 0, sizeof(t));
t.qualified_name = "pkg.T";
t.short_name = "T";
const char *field_names[] = {"untyped", "typed", NULL};
const CBMType *field_types[] = {NULL, cbm_type_named(&arena, "pkg.Value"), NULL};
t.field_names = field_names;
t.field_types = field_types;
cbm_registry_add_type(&base, t);
cbm_registry_finalize(&base);
base.read_only = true;
@@ -15414,6 +15418,10 @@ TEST(registry_overlay_chain_iterates_and_copies_on_write) {
CBMRegisteredType *wt = cbm_registry_type_for_update(&overlay, "pkg.T");
ASSERT_NOT_NULL(wt);
ASSERT_EQ(overlay.type_count, 1);
/* A missing type for an earlier named field must not truncate the copy. */
bool field_slots_preserved = wt->field_types && wt->field_types != field_types &&
wt->field_types[0] == NULL &&
wt->field_types[1] == field_types[1] && wt->field_types[2] == NULL;
cbm_registry_all_types_chain(&overlay, &ti);
seen = 0;
while (cbm_type_short_iter_next(&ti) >= 0) {
@@ -15430,6 +15438,7 @@ TEST(registry_overlay_chain_iterates_and_copies_on_write) {
cbm_arena_destroy(&scratch);
cbm_arena_destroy(&arena);
ASSERT_TRUE(field_slots_preserved);
PASS();
}
@@ -15804,6 +15813,111 @@ TEST(seal_py_shared_registry_readonly_fields) {
PASS();
}
/* #1277: the production resolver receives a writable overlay, so refining an
* existing field must detach its pointer array from the sealed fallback. */
TEST(seal_py_overlay_existing_field_keeps_shared_type) {
CBMArena arena;
cbm_arena_init(&arena);
CBMLSPDef defs[4] = {0};
defs[0].qualified_name = "test.mod.Foo";
defs[0].short_name = "Foo";
defs[0].label = "Class";
defs[0].def_module_qn = "test.mod";
defs[0].field_defs = "x:test.contracts.Contract";
defs[1].qualified_name = "test.mod.Foo.m";
defs[1].short_name = "m";
defs[1].label = "Method";
defs[1].receiver_type = "test.mod.Foo";
defs[1].def_module_qn = "test.mod";
defs[2].qualified_name = "test.contracts.Contract";
defs[2].short_name = "Contract";
defs[2].label = "Class";
defs[2].def_module_qn = "test.contracts";
defs[3].qualified_name = "test.contracts.Contract.process_batch";
defs[3].short_name = "process_batch";
defs[3].label = "Method";
defs[3].receiver_type = "test.contracts.Contract";
defs[3].def_module_qn = "test.contracts";
for (int i = 0; i < 4; i++) {
defs[i].lang = CBM_LANG_PYTHON;
}
CBMTypeRegistry *reg = cbm_py_build_cross_registry(&arena, defs, 4);
ASSERT_NOT_NULL(reg);
ASSERT_TRUE(reg->read_only);
const CBMRegisteredType *base = cbm_registry_lookup_type(reg, "test.mod.Foo");
ASSERT_NOT_NULL(base);
ASSERT_NOT_NULL(base->field_names);
ASSERT_NOT_NULL(base->field_types);
const char **names_before = base->field_names;
const CBMType **types_before = base->field_types;
const CBMType *type_before = base->field_types[0];
ASSERT_NOT_NULL(type_before);
ASSERT_NULL(base->field_types[1]);
CBMArena scratch;
cbm_arena_init(&scratch);
CBMTypeRegistry overlay;
cbm_registry_init(&overlay, &scratch);
overlay.fallback = reg;
const char *writer = "class Foo:\n"
" def m(self):\n"
" self.x = 1\n";
CBMResolvedCallArray out = {0};
cbm_run_py_lsp_cross_with_registry(&scratch, writer, (int)strlen(writer), "test.mod", &overlay,
NULL, NULL, 0, NULL, &out, NULL);
/* Observe while scratch is live, then release both arenas before any
* failure assertion so the regression also fails cleanly under LSan. */
bool shared_names_unchanged = base->field_names == names_before;
bool shared_types_unchanged = base->field_types == types_before;
bool shared_element_unchanged = base->field_types[0] == type_before;
bool shared_terminator_unchanged = base->field_types[1] == NULL;
const CBMRegisteredType *refined = cbm_registry_lookup_type(&overlay, "test.mod.Foo");
bool local_refined = refined && refined != base && refined->field_types &&
refined->field_types != types_before && refined->field_types[0] &&
refined->field_types[0] != type_before &&
refined->field_types[0]->kind == CBM_TYPE_BUILTIN &&
refined->field_types[1] == NULL;
cbm_arena_destroy(&scratch);
/* Never read a corrupted shared pointer after scratch destruction. When
* intact, a later file must still see the original Contract field. */
int hits = 0;
if (shared_names_unchanged && shared_types_unchanged && shared_element_unchanged &&
shared_terminator_unchanged && local_refined) {
cbm_arena_init(&scratch);
cbm_registry_init(&overlay, &scratch);
overlay.fallback = reg;
const char *reader = "from mod import Foo\n\n"
"def run(foo: Foo):\n"
" foo.x.process_batch()\n";
const char *import_names[] = {"Foo"};
const char *import_qns[] = {"test.mod.Foo"};
memset(&out, 0, sizeof(out));
cbm_run_py_lsp_cross_with_registry(&scratch, reader, (int)strlen(reader), "test.reader",
&overlay, import_names, import_qns, 1, NULL, &out, NULL);
for (int i = 0; i < out.count; i++) {
const CBMResolvedCall *call = &out.items[i];
if (call->caller_qn && call->callee_qn &&
strcmp(call->caller_qn, "test.reader.run") == 0 &&
strcmp(call->callee_qn, "test.contracts.Contract.process_batch") == 0 &&
call->kind == CBM_RESOLVED_INVOCATION &&
call->confidence >= CBM_LSP_CONFIDENCE_FLOOR) {
hits++;
}
}
cbm_arena_destroy(&scratch);
}
cbm_arena_destroy(&arena);
ASSERT_TRUE(shared_names_unchanged);
ASSERT_TRUE(shared_types_unchanged);
ASSERT_TRUE(shared_element_unchanged);
ASSERT_TRUE(shared_terminator_unchanged);
ASSERT_TRUE(local_refined);
ASSERT_EQ(hits, 1);
PASS();
}
TEST(seal_cs_shared_registry_readonly) {
CBMArena arena;
cbm_arena_init(&arena);
@@ -16402,6 +16516,7 @@ SUITE(c_lsp) {
RUN_TEST(registry_overlay_chain_iterates_and_copies_on_write);
RUN_TEST(clsp_method_return_refinement_is_copy_on_write);
RUN_TEST(seal_py_shared_registry_readonly_fields);
RUN_TEST(seal_py_overlay_existing_field_keeps_shared_type);
RUN_TEST(seal_cs_shared_registry_readonly);
RUN_TEST(seal_ts_shared_registry_readonly);
RUN_TEST(seal_go_shared_registry_readonly);
+67 -19
View File
@@ -8198,7 +8198,7 @@ static int count_lsp_calls(cbm_store_t *s, const char *project, const char *call
return hits;
}
TEST(python_crossfile_typed_field_calls_issue1277) {
static int check_python_crossfile_typed_field_calls_issue1277(bool parallel) {
const char *contracts = "class Contract:\n"
" def process_batch(self) -> None:\n"
" ...\n";
@@ -8226,31 +8226,70 @@ TEST(python_crossfile_typed_field_calls_issue1277) {
}
char path[512];
snprintf(path, sizeof(path), "%s/loop.py", g_usages_tmpdir);
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs(loop, f);
fclose(f);
if (th_write_file(path, loop) != 0) {
teardown_usages_repo();
FAIL("failed to write loop.py");
}
if (parallel) {
/* Three semantic files plus 50 inert files exceed the production
* threshold for the fused parallel pipeline. */
for (int i = 0; i < 50; i++) {
snprintf(path, sizeof(path), "%s/field_pad_%02d.py", g_usages_tmpdir, i);
if (th_write_file(path, "field_padding = 0\n") != 0) {
teardown_usages_repo();
FAIL("failed to write parallel-selection fixture");
}
}
}
const char *old_workers = getenv("CBM_WORKERS");
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD");
char *saved_single = old_single ? strdup(old_single) : NULL;
if ((old_workers && !saved_workers) || (old_single && !saved_single)) {
free(saved_workers);
free(saved_single);
teardown_usages_repo();
FAIL("failed to save pipeline environment");
}
int pin_workers_rc = parallel ? cbm_setenv("CBM_WORKERS", "4", 1) : 0;
int pin_single_rc = parallel ? cbm_unsetenv("CBM_INDEX_SINGLE_THREAD") : 0;
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/test_1277.db", g_usages_tmpdir);
cbm_pipeline_t *p = cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
const char *project = cbm_pipeline_project_name(p);
cbm_pipeline_t *p = pin_workers_rc == 0 && pin_single_rc == 0
? cbm_pipeline_new(g_usages_tmpdir, db_path, CBM_MODE_FULL)
: NULL;
int run_rc = p ? cbm_pipeline_run(p) : -1;
cbm_store_t *s = run_rc == 0 ? cbm_store_open_path(db_path) : NULL;
bool store_opened = s != NULL;
int via_alias = -1, via_typed = -1, via_class = -1, via_untyped = -1;
if (s) {
const char *project = cbm_pipeline_project_name(p);
via_alias = count_lsp_calls(s, project, "loop.run", "contracts.Contract.process_batch");
via_typed =
count_lsp_calls(s, project, "loop.run_typed", "contracts.Contract.process_batch");
via_class =
count_lsp_calls(s, project, "loop.run_classlevel", "contracts.Contract.process_batch");
via_untyped = count_lsp_calls(s, project, "loop.run_untyped", "process_batch");
cbm_store_close(s);
}
int via_alias = count_lsp_calls(s, project, "loop.run", "contracts.Contract.process_batch");
int via_typed =
count_lsp_calls(s, project, "loop.run_typed", "contracts.Contract.process_batch");
int via_class =
count_lsp_calls(s, project, "loop.run_classlevel", "contracts.Contract.process_batch");
int via_untyped = count_lsp_calls(s, project, "loop.run_untyped", "process_batch");
cbm_store_close(s);
cbm_pipeline_free(p);
int restore_workers_rc =
saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS");
int restore_single_rc = saved_single ? cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1)
: cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
free(saved_workers);
free(saved_single);
teardown_usages_repo();
ASSERT_EQ(pin_workers_rc, 0);
ASSERT_EQ(pin_single_rc, 0);
ASSERT_EQ(restore_workers_rc, 0);
ASSERT_EQ(restore_single_rc, 0);
ASSERT_EQ(run_rc, 0);
ASSERT_TRUE(store_opened);
ASSERT_EQ(via_alias, 1); /* self.x = <annotated __init__ parameter> */
ASSERT_EQ(via_typed, 1); /* self.x: T = ... */
ASSERT_EQ(via_class, 1); /* class-level x: T */
@@ -8258,6 +8297,14 @@ TEST(python_crossfile_typed_field_calls_issue1277) {
PASS();
}
TEST(python_crossfile_typed_field_calls_issue1277) {
return check_python_crossfile_typed_field_calls_issue1277(false);
}
TEST(python_crossfile_typed_field_calls_issue1277_parallel) {
return check_python_crossfile_typed_field_calls_issue1277(true);
}
TEST(usages_creates_edges) {
/* Port of TestPassUsagesCreatesEdges.
* Go source with callback reference → USAGE edge. */
@@ -16471,6 +16518,7 @@ SUITE(pipeline) {
RUN_TEST(implements_no_match);
/* Usages pass (full pipeline integration) */
RUN_TEST(python_crossfile_typed_field_calls_issue1277);
RUN_TEST(python_crossfile_typed_field_calls_issue1277_parallel);
RUN_TEST(usages_creates_edges);
RUN_TEST(usages_no_duplicate_calls);
RUN_TEST(calls_edge_carries_call_site_line);