From bd640caa918d5819f82c692a20005cdf83689778 Mon Sep 17 00:00:00 2001 From: Henry Hazan Date: Wed, 19 Aug 2026 01:41:32 +0300 Subject: [PATCH 01/76] fix(elixir): Phoenix channel extraction was unreachable in both branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither half of extract_channels_elixir could ever fire, so an Elixir project produced zero Channel nodes regardless of how much Phoenix.PubSub or Phoenix.Channel it used. The emit side read ts_node_child_by_field_name(call, "arguments"). tree-sitter-elixir defines no such field — its whole field set is key, left, operand, operator, quoted_start, quoted_end, right, target and value — so args was always null and elixir_emit_second_arg() returned early every time. extract_defs.c has always used a positional second-child fallback for this; the same fallback is now used here. The listener side dispatched on strcmp(kind, "def"). There is no `def` node type in the grammar: an Elixir definition is a `call` whose target is the macro name. elixir_process_function_def() was therefore dead code, and it also read `name` and `parameters` fields that do not exist. It now takes the def call, unwraps a `when` guard, and reads the head's name and parameters positionally, so guarded clauses like `def handle_in(e, p, s) when is_map(s)` are recognized too. Verified against a Phoenix application; the new test covers Phoenix.PubSub.broadcast/subscribe, a bare push/broadcast emit, and two handle_in clauses, one of them guarded. Signed-off-by: Henry Hazan --- internal/cbm/extract_channels.c | 85 ++++++++++++++++++++++++++++----- tests/test_extraction.c | 49 +++++++++++++++++++ 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/internal/cbm/extract_channels.c b/internal/cbm/extract_channels.c index c12d4ebeb..17ef8f802 100644 --- a/internal/cbm/extract_channels.c +++ b/internal/cbm/extract_channels.c @@ -901,8 +901,42 @@ static void elixir_emit_second_arg(CBMExtractCtx *ctx, TSNode call, TSNode args, } } -static void elixir_process_call(CBMExtractCtx *ctx, TSNode call) { +/* tree-sitter-elixir gives a call's arguments node NO field name — its whole + * field set is key, left, operand, operator, quoted_start, quoted_end, right, + * target and value — so the generic field lookup is always null here and every + * channel below was silently skipped. extract_defs.c has carried the same + * positional fallback for the definition side since Elixir was added. */ +static TSNode elixir_channel_call_args(TSNode call) { + TSNode args = ts_node_child_by_field_name(call, TS_FIELD("arguments")); + if (ts_node_is_null(args) && ts_node_child_count(call) > 1) { + args = ts_node_child(call, 1); + } + return args; +} + +/* The callee of an Elixir `call`. The grammar labels it `target`; child(0) is + * the same node and is what extract_defs.c reads, so accept either. */ +static TSNode elixir_channel_call_target(TSNode call) { TSNode target = ts_node_child_by_field_name(call, TS_FIELD("target")); + if (ts_node_is_null(target) && ts_node_child_count(call) > 0) { + target = ts_node_child(call, 0); + } + return target; +} + +/* Elixir def* macros. `def`/`defp` cover Phoenix channel callbacks; the macro + * forms are included because `handle_in` is generated by macros in some + * libraries. */ +static bool elixir_channel_is_def_macro(const char *macro) { + if (!macro) { + return false; + } + return strcmp(macro, "def") == 0 || strcmp(macro, "defp") == 0 || + strcmp(macro, "defmacro") == 0 || strcmp(macro, "defmacrop") == 0; +} + +static void elixir_process_call(CBMExtractCtx *ctx, TSNode call) { + TSNode target = elixir_channel_call_target(call); if (ts_node_is_null(target)) { return; } @@ -911,7 +945,7 @@ static void elixir_process_call(CBMExtractCtx *ctx, TSNode call) { return; } - TSNode args = ts_node_child_by_field_name(call, TS_FIELD("arguments")); + TSNode args = elixir_channel_call_args(call); if (strstr(target_text, "PubSub.broadcast") || strstr(target_text, "PubSub.local_broadcast")) { elixir_emit_second_arg(ctx, call, args, "phoenix_pubsub", CBM_CHANNEL_EMIT); @@ -927,10 +961,31 @@ static void elixir_process_call(CBMExtractCtx *ctx, TSNode call) { } } -/* Detect handle_in("event", payload, socket) — Phoenix Channel listener */ -static void elixir_process_function_def(CBMExtractCtx *ctx, TSNode func_def) { - TSNode name_node = ts_node_child_by_field_name(func_def, TS_FIELD("name")); - if (ts_node_is_null(name_node)) { +/* Detect handle_in("event", payload, socket) — Phoenix Channel listener. + * `def_call` is the `call` node of a def* macro. Elixir has no `def` node type + * and no `name`/`parameters` fields: a definition is a call whose first + * argument is the head, itself a call of name(params) — wrapped in a `when` + * binary_operator when the clause carries a guard. */ +static void elixir_process_function_def(CBMExtractCtx *ctx, TSNode def_call) { + TSNode args = elixir_channel_call_args(def_call); + if (ts_node_is_null(args) || ts_node_named_child_count(args) == 0) { + return; + } + TSNode head = ts_node_named_child(args, 0); + if (strcmp(ts_node_type(head), "binary_operator") == 0) { + TSNode op = ts_node_child_by_field_name(head, TS_FIELD("operator")); + TSNode left = ts_node_child_by_field_name(head, TS_FIELD("left")); + if (!ts_node_is_null(op) && !ts_node_is_null(left) && + ts_node_end_byte(op) - ts_node_start_byte(op) == 4 && + strncmp(ctx->source + ts_node_start_byte(op), "when", 4) == 0) { + head = left; + } + } + if (strcmp(ts_node_type(head), "call") != 0 || ts_node_child_count(head) == 0) { + return; + } + TSNode name_node = ts_node_child(head, 0); + if (ts_node_is_null(name_node) || strcmp(ts_node_type(name_node), "identifier") != 0) { return; } char *name = cbm_node_text(ctx->arena, name_node, ctx->source); @@ -938,7 +993,7 @@ static void elixir_process_function_def(CBMExtractCtx *ctx, TSNode func_def) { return; } /* First parameter is the event name pattern */ - TSNode params = ts_node_child_by_field_name(func_def, TS_FIELD("parameters")); + TSNode params = elixir_channel_call_args(head); if (ts_node_is_null(params)) { return; } @@ -952,7 +1007,7 @@ static void elixir_process_function_def(CBMExtractCtx *ctx, TSNode func_def) { val = literal_from_first_child(ctx, first_param); } if (val) { - push_channel(ctx, val, "phoenix_channel", CBM_CHANNEL_LISTEN, func_def); + push_channel(ctx, val, "phoenix_channel", CBM_CHANNEL_LISTEN, def_call); } } @@ -964,10 +1019,18 @@ static void extract_channels_elixir(CBMExtractCtx *ctx) { while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); const char *kind = ts_node_type(node); + /* Everything in Elixir is a `call`, including `def`. The old dispatch + * tested for a "def" node type the grammar does not have, so the + * handle_in listener branch was unreachable. */ if (strcmp(kind, "call") == 0) { - elixir_process_call(ctx, node); - } else if (strcmp(kind, "def") == 0) { - elixir_process_function_def(ctx, node); + TSNode target = elixir_channel_call_target(node); + char *macro = + ts_node_is_null(target) ? NULL : cbm_node_text(ctx->arena, target, ctx->source); + if (elixir_channel_is_def_macro(macro)) { + elixir_process_function_def(ctx, node); + } else { + elixir_process_call(ctx, node); + } } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 447e7c104..171f1f08f 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1005,6 +1005,54 @@ TEST(elixir_function) { PASS(); } +/* True when a channel with this name/transport/direction was extracted. */ +static int has_channel(CBMFileResult *r, const char *name, const char *transport, + CBMChannelDirection dir) { + for (int i = 0; i < r->channels.count; i++) { + const CBMChannel *c = &r->channels.items[i]; + if (c->channel_name && strcmp(c->channel_name, name) == 0 && c->transport && + strcmp(c->transport, transport) == 0 && c->direction == dir) + return 1; + } + return 0; +} + +/* Phoenix pub/sub and channel listeners. Both branches were unreachable: the + * emit side looked up an "arguments" FIELD that tree-sitter-elixir does not + * define, and the listener side dispatched on a "def" NODE TYPE that does not + * exist (Elixir defs are `call` nodes), so a Phoenix app produced no channels + * at all. */ +TEST(elixir_channels_pubsub_and_handle_in) { + CBMFileResult *r = + extract("defmodule Acme.Notifier do\n" + " def announce(p) do\n" + " Phoenix.PubSub.broadcast(Acme.PubSub, \"user:created\", p)\n" + " end\n" + " def watch, do: Phoenix.PubSub.subscribe(Acme.PubSub, \"user:updated\")\n" + "end\n" + "defmodule AcmeWeb.RoomChannel do\n" + " def handle_in(\"new_msg\", payload, socket) do\n" + " push(socket, \"pong\", %{})\n" + " {:noreply, socket}\n" + " end\n" + " def handle_in(\"ping\", _p, socket) when is_map(socket) do\n" + " {:noreply, socket}\n" + " end\n" + "end\n", + CBM_LANG_ELIXIR, "t", "chan.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_channel(r, "user:created", "phoenix_pubsub", CBM_CHANNEL_EMIT)); + ASSERT(has_channel(r, "user:updated", "phoenix_pubsub", CBM_CHANNEL_LISTEN)); + /* handle_in clauses are listeners — including the guarded one. */ + ASSERT(has_channel(r, "new_msg", "phoenix_channel", CBM_CHANNEL_LISTEN)); + ASSERT(has_channel(r, "ping", "phoenix_channel", CBM_CHANNEL_LISTEN)); + /* A bare push/broadcast inside the channel module is an emit. */ + ASSERT(has_channel(r, "pong", "phoenix_channel", CBM_CHANNEL_EMIT)); + cbm_free_result(r); + PASS(); +} + /* --- Haskell --- */ TEST(haskell_function) { CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> " @@ -5432,6 +5480,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_channels_pubsub_and_handle_in); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From eaf59046e5371eda24bfafc60743291b429e79d8 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Thu, 20 Aug 2026 01:31:29 +0200 Subject: [PATCH 02/76] fix(registry): stop external package imports binding to project homonyms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare call whose name the calling file imports from a package outside the indexed tree was bound to whatever project symbol shared the simple name. An `import { eq, sql } from "drizzle-orm"` plus an unrelated local module exporting `eq`/`sql` produced two CALLS edges from the caller into that module, strategy unique_name — the same defect on the sequential and the fused-parallel resolver. The TS-LSP is not involved: it declines the external names, and the textual registry fallback fires anyway. The package materializes no node, so resolve_import_node returns NULL, no IMPORTS edge is written, and the per-file import map carries no key for the name. Strategies 1-2 miss and resolve_name_lookup binds the call by simple name. Confidence does not separate the cases: the fabricated edge lands at 0.75 whenever the file also imports anything from the target's module. Add cbm_suppress_external_import_shadow(), a pure predicate beside the existing perl/tsjs/cross-language guards and called at the same two emit sites. It drops the edge only when the callee is a bare identifier, the strategy is a project-wide guess (suffix_match / unique_name / field_type_hint / fuzzy), the file imports that exact local name from a non-relative specifier, and no import-map key binds it. Relative specifiers are excluded on purpose: they name a path inside the tree, so a missing IMPORTS edge there is an in-project resolution gap and the fallback can still be right. A name imported both relatively and from a package keeps its edge, independently of extraction order. Addresses the external-import sub-case of #1355. Signed-off-by: Yyunozor --- src/pipeline/pass_calls.c | 16 ++++- src/pipeline/pass_parallel.c | 8 +++ src/pipeline/pipeline.h | 10 +++ src/pipeline/registry.c | 82 +++++++++++++++++++++++ tests/test_pipeline.c | 124 +++++++++++++++++++++++++++++++++++ tests/test_registry.c | 89 +++++++++++++++++++++++++ 6 files changed, 326 insertions(+), 3 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index c9744dda2..734e9b956 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -476,7 +476,8 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c /* Resolve one call and emit the appropriate edge. Returns 1 if resolved, 0 if not. */ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, - const CBMResolvedCallArray *lsp_calls, const char *rel, + const CBMResolvedCallArray *lsp_calls, + const CBMImportArray *file_imports, const char *rel, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, CBMLanguage lang) { const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn); @@ -668,6 +669,15 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } + /* #1355: `import { eq } from "drizzle-orm"` binds `eq` to a package that is + * not in the indexed tree, so a project-wide same-name guess must not turn + * `eq(...)` into a CALLS edge to an unrelated project `eq`. Placed with the + * #725 guard, after the service-pattern bypasses above, so no HTTP/route + * edge can be lost to it. */ + if (cbm_suppress_external_import_shadow(call->callee_name, res.strategy, file_imports, imp_keys, + imp_count)) { + return 0; + } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; @@ -827,8 +837,8 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file continue; } total_calls++; - if (resolve_single_call(ctx, call, &result->resolved_calls, rel, module_qn, imp_keys, - imp_vals, imp_count, files[i].language)) { + if (resolve_single_call(ctx, call, &result->resolved_calls, &result->imports, rel, + module_qn, imp_keys, imp_vals, imp_count, files[i].language)) { resolved++; } else { unresolved++; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index cdeafe9d8..76ed4e9a4 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2569,6 +2569,14 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * CALLS edge across a language boundary. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports, + imp_keys, imp_count)) { + /* #1355: same guard as pass_calls.c — a bare call bound by an + * external package import must not become a CALLS edge to an + * unrelated project symbol of the same name. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index dfe81f726..f24cc3045 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -291,6 +291,16 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st * Pure; unit-tested in test_registry.c. */ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound, const char *strategy); +/* #1355: drop a project-wide same-name guess (suffix_match / unique_name / + * field_type_hint / fuzzy) for a BARE call whose name the calling file binds to + * a NON-RELATIVE (package) import that resolved to nothing in the graph — + * `import { eq } from "drizzle-orm"` must not make `eq(...)` a CALLS edge to an + * unrelated project `eq`. A name the import map does bind, a relative + * specifier, a member/qualified callee, and every import-/receiver-aware + * strategy are all kept. Pure; unit-tested in test_registry.c. */ +bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy, + const CBMImportArray *file_imports, + const char **import_map_keys, int import_map_count); /* #725: drop a suffix_match CALLS edge when the caller language and the * target file's language disagree. unique_name (candidates == 1) is #1572 diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 97c843355..ff78c4354 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -501,6 +501,88 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b return weak_short_name_strategy(strategy); } +/* A module specifier that names a path inside the indexed tree ("./x", "../x", + * "/abs/x") rather than an external package. Package specifiers are everything + * else — "drizzle-orm", "rxjs/operators", "@scope/pkg". */ +static bool specifier_is_relative(const char *module_path) { + return module_path && (module_path[0] == '.' || module_path[0] == '/'); +} + +static bool import_map_binds(const char **import_map_keys, int import_map_count, + const char *local_name) { + if (!import_map_keys || import_map_count <= 0) { + return false; + } + for (int i = 0; i < import_map_count; i++) { + if (import_map_keys[i] && strcmp(import_map_keys[i], local_name) == 0) { + return true; + } + } + return false; +} + +/* #1355: a bare call whose name the calling file binds to an EXTERNAL package + * import must not fall back to a project-wide same-name guess. + * + * `import { eq } from "drizzle-orm"` binds `eq` in this file's scope. When the + * package is not part of the indexed tree it materializes no IMPORTS edge, so + * the import map has no entry, strategies 1-2 miss, and strategy 3/4 attach + * `eq(users.id, id)` to whatever project symbol happens to share the simple + * name — a CALLS edge into an unrelated local helper. The explicit import + * statement is positive evidence, taken from the caller's own source, that the + * identifier does NOT denote that symbol. + * + * Fires only when ALL of: + * - the callee is a BARE identifier: member (`x.foo()`) and package/namespace + * qualified callees are the receiver-aware guards' business, not this one; + * - the match came from a project-wide guess (suffix_match / unique_name; + * field_type_hint / fuzzy listed for the same defensive reason as the TS/JS + * guard) — every import-, receiver- or module-aware strategy is KEPT; + * - the file imports that exact local name from a NON-RELATIVE specifier; + * - no import-map key binds the name, i.e. that import resolved to nothing in + * the graph. A name the map does bind already had its chance at strategy 1 + * and is import-aware by construction. + * + * Relative specifiers are deliberately excluded: they name a path inside the + * indexed tree, so a missing IMPORTS edge is an in-project resolution gap and + * the same-name fallback can still be right. A name imported from both a + * relative and a package specifier keeps the edge — the relative binding wins + * the tie explicitly, so the outcome does not depend on import order. + * + * Pure + side-effect-free so the contract is unit-testable without a pipeline. */ +bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy, + const CBMImportArray *file_imports, + const char **import_map_keys, int import_map_count) { + if (!callee_name || !callee_name[0] || !strategy || !strategy[0]) { + return false; + } + if (strcmp(strategy, "suffix_match") != 0 && strcmp(strategy, "unique_name") != 0 && + strcmp(strategy, "field_type_hint") != 0 && strcmp(strategy, "fuzzy") != 0) { + return false; + } + if (strchr(callee_name, '.') != NULL || strstr(callee_name, "::") != NULL) { + return false; + } + if (!file_imports || file_imports->count <= 0) { + return false; + } + if (import_map_binds(import_map_keys, import_map_count, callee_name)) { + return false; + } + bool external_binding = false; + for (int i = 0; i < file_imports->count; i++) { + const CBMImport *imp = &file_imports->items[i]; + if (!imp->local_name || !imp->module_path || strcmp(imp->local_name, callee_name) != 0) { + continue; + } + if (specifier_is_relative(imp->module_path)) { + return false; /* an in-tree binding for this name — never suppress */ + } + external_binding = true; + } + return external_binding; +} + static bool js_ts_family(CBMLanguage lang) { return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index c5d3a9539..fd6d51855 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4976,6 +4976,129 @@ static int count_nodes_named(cbm_store_t *s, const char *project, const char *na * (axios.get, api.patch on a renamed-axios instance, supertest request(app).get). * The regex false edge must stay suppressed in parallel too. CBM_WORKERS forces * >1 worker so the parallel path is taken regardless of the host core count. */ +/* #1355: write the shared external-import-shadow fixture into `dir`. + * `pad_files` filler modules push the run over MIN_FILES_FOR_PARALLEL so the + * same tree can be indexed by both resolvers. */ +static void write_external_import_shadow_fixture(const char *dir, int pad_files) { + /* The lone project symbols named eq/sql — ordinary local helpers. */ + write_temp_file(dir, "src/text-utils.ts", + "export function eq(a: string, b: string): boolean {\n" + " return a.trim() === b.trim();\n" + "}\n" + "export function sql(chunk: string): string {\n" + " return chunk.replace(/\\s+/g, ' ');\n" + "}\n" + "export function normalize(s: string): string {\n" + " return s.trim().toLowerCase();\n" + "}\n"); + /* `eq` and `sql` come from an external package that is not in the tree, so + * the registry falls through to a project-wide same-name guess. These are + * the fabricated edges. `normalize` is imported relatively from the SAME + * file the guess would have picked — it must survive. */ + write_temp_file(dir, "src/queries.ts", + "import { eq, sql } from 'drizzle-orm';\n" + "import { normalize } from './text-utils';\n" + "export function buildQuery(id: string): unknown {\n" + " return [eq({ id }, id), sql('select 1'), normalize(id)];\n" + "}\n"); + /* No import of `eq` at all: nothing in the caller's source contradicts the + * guess, so the pre-existing fallback keeps its edge. */ + write_temp_file(dir, "src/no-import.ts", + "export function callsWithoutImport(a: string, b: string): boolean {\n" + " return eq(a, b);\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/pad_%02d.ts", i); + snprintf(body, sizeof(body), "export function shadowPad%02d(): number { return %d; }\n", i, + i); + write_temp_file(dir, name, body); + } +} + +/* #1355: assert the guard's whole contract against one indexed store. */ +static int assert_external_import_shadow_contract(const char *dir, const char *db_name) { + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", dir, db_name); + cbm_pipeline_t *p = cbm_pipeline_new(dir, db_path, CBM_MODE_FULL); + if (!p) { + return 1; + } + if (cbm_pipeline_run(p) != 0) { + cbm_pipeline_free(p); + return 2; + } + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + cbm_pipeline_free(p); + return 3; + } + int rc = 0; + /* (1) the reported bug: an externally-imported name must not bind to the + * local homonym (RED before the fix, on both resolvers). */ + if (cross_file_call_exists(s, project, "buildQuery", "eq")) { + rc = 4; + } + if (rc == 0 && cross_file_call_exists(s, project, "buildQuery", "sql")) { + rc = 5; + } + /* (2) the relative import to the very same file still resolves. */ + if (rc == 0 && !cross_file_call_exists(s, project, "buildQuery", "normalize")) { + rc = 6; + } + /* (3) a bare call the caller never imports keeps its pre-existing edge. */ + if (rc == 0 && !cross_file_call_exists(s, project, "callsWithoutImport", "eq")) { + rc = 7; + } + cbm_store_close(s); + cbm_pipeline_free(p); + return rc; +} + +TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_ext_import_shadow_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + /* Enough files that CBM_WORKERS can take the fused-parallel path; the same + * tree is then indexed by each resolver in turn, because the guard lives at + * two independent emit sites (pass_calls.c and pass_parallel.c). */ + write_external_import_shadow_fixture(tmp, 50); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + int sequential = assert_external_import_shadow_contract(tmp, "shadow-sequential.db"); + + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + int parallel = assert_external_import_shadow_contract(tmp, "shadow-parallel.db"); + + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + th_rmtree(tmp); + + ASSERT_EQ(sequential, 0); + ASSERT_EQ(parallel, 0); + PASS(); +} + TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX"); @@ -13324,6 +13447,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); + RUN_TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); diff --git a/tests/test_registry.c b/tests/test_registry.c index 247ca61ad..eb47679f3 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -970,6 +970,92 @@ TEST(weak_call_guards_share_one_drop_list) { } ASSERT_EQ(member, binding); } +TEST(external_import_shadow_drops_package_bound_bare_call) { + /* #1355: `import { eq, sql } from "drizzle-orm"` binds both names to a + * package outside the indexed tree, so no import-map key exists for them and + * a project-wide same-name guess is a fabricated edge. */ + CBMImport imports[] = { + {.local_name = "eq", .module_path = "drizzle-orm"}, + {.local_name = "sql", .module_path = "drizzle-orm"}, + {.local_name = "users", .module_path = "./schema"}, + }; + CBMImportArray arr = {.items = imports, .count = 3, .cap = 3}; + /* Only the relative import materialized an IMPORTS edge. */ + const char *keys[] = {"users"}; + + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, keys, 1)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, keys, 1)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "suffix_match", &arr, keys, 1)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "field_type_hint", &arr, keys, 1)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "fuzzy", &arr, keys, 1)); + /* A file whose imports all failed to materialize is still covered: the + * evidence is the import statement, not the map. */ + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0)); + PASS(); +} + +TEST(external_import_shadow_keeps_everything_else) { + /* Negative space: every input that is NOT "bare call bound to a package + * import the graph could not resolve" must keep its edge. */ + CBMImport imports[] = { + {.local_name = "eq", .module_path = "drizzle-orm"}, + {.local_name = "normalize", .module_path = "./text-utils"}, + {.local_name = "getRootContainer", .module_path = "@repo/shared"}, + }; + CBMImportArray arr = {.items = imports, .count = 3, .cap = 3}; + /* The relative import and the workspace package both materialized. */ + const char *keys[] = {"normalize", "getRootContainer"}; + const int nkeys = 2; + + /* Import-/receiver-/module-aware strategies are never this guard's business. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map_suffix", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "same_module", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "qualified_suffix", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "lsp_ts_import", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "lsp_ts_method", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "service_pattern", &arr, keys, nkeys)); + /* A relative specifier names a path inside the tree — a missing IMPORTS edge + * there is an in-project gap, not an external binding. */ + ASSERT_FALSE( + cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0)); + /* A package the import map DOES bind resolved in-graph (workspace package). */ + ASSERT_FALSE( + cbm_suppress_external_import_shadow("getRootContainer", "unique_name", &arr, keys, nkeys)); + /* A name the file never imports (called with no import at all). */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("helper", "unique_name", &arr, keys, nkeys)); + /* Member and package/namespace-qualified callees belong to the + * receiver-aware guards, not to this one. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq.apply", "unique_name", &arr, keys, nkeys)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq::apply", "unique_name", &arr, keys, nkeys)); + /* Empty / absent inputs. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", NULL, &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow(NULL, "unique_name", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("", "unique_name", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", NULL, keys, nkeys)); + PASS(); +} + +TEST(external_import_shadow_relative_binding_wins_the_tie) { + /* A name imported from BOTH a package and a relative path keeps its edge, + * and does so independently of the order the imports were extracted in — + * the relative binding is an explicit tie-break, not an emergent property + * of iteration order. */ + CBMImport pkg_first[] = { + {.local_name = "eq", .module_path = "drizzle-orm"}, + {.local_name = "eq", .module_path = "./text-utils"}, + }; + CBMImport rel_first[] = { + {.local_name = "eq", .module_path = "./text-utils"}, + {.local_name = "eq", .module_path = "drizzle-orm"}, + }; + CBMImportArray a = {.items = pkg_first, .count = 2, .cap = 2}; + CBMImportArray b = {.items = rel_first, .count = 2, .cap = 2}; + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &a, NULL, 0)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &b, NULL, 0)); PASS(); } @@ -1072,4 +1158,7 @@ SUITE(registry) { RUN_TEST(local_binding_suppress_drops_weak_shadowed_bare_calls); RUN_TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies); RUN_TEST(weak_call_guards_share_one_drop_list); + RUN_TEST(external_import_shadow_drops_package_bound_bare_call); + RUN_TEST(external_import_shadow_keeps_everything_else); + RUN_TEST(external_import_shadow_relative_binding_wins_the_tie); } From a8293c498ca60c97c4116e40f58dffb751fe22ab Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Thu, 20 Aug 2026 20:41:26 +0200 Subject: [PATCH 03/76] fix: address review feedback on external-import-shadow guard - specifier_is_relative() now also recognizes Windows drive-letter (C:\ / C:/) and UNC (\\server\share) specifiers as in-tree, not external packages. pr-smoke runs this pipeline on Windows, and the previous POSIX-only check ('.' / '/') classified those specifiers as external, which could suppress a real edge on that platform only. Added external_import_shadow_windows_relative_specifier_kept (tests/test_registry.c): red before this fix, green after. - old_workers / old_single in the #1355 pipeline test are now const char*, matching getenv()'s read-only contract. - The pad_files literal (50) is now a named EXTERNAL_IMPORT_SHADOW_PARALLEL_PAD constant with a comment tying it to MIN_FILES_FOR_PARALLEL (a private #define in pipeline.c, not reachable for a static_assert from tests), so a future bump to that threshold can't silently drop the fixture back onto the sequential-only path. Signed-off-by: Yyunozor --- src/pipeline/registry.c | 26 +++++++++++++++++++++++--- tests/test_pipeline.c | 19 ++++++++++++++++--- tests/test_registry.c | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index ff78c4354..6c0cdb42a 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -31,6 +31,7 @@ enum { REG_MAX_CANDIDATES = 256 }; #include "foundation/dyn_array.h" #include "foundation/platform.h" +#include #include #include #include @@ -502,10 +503,29 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b } /* A module specifier that names a path inside the indexed tree ("./x", "../x", - * "/abs/x") rather than an external package. Package specifiers are everything - * else — "drizzle-orm", "rxjs/operators", "@scope/pkg". */ + * "/abs/x", and on Windows "C:\x", "C:/x", "\\server\share\x") rather than an + * external package. Package specifiers are everything else — "drizzle-orm", + * "rxjs/operators", "@scope/pkg". Windows paths must count as in-tree here: + * pr-smoke runs this pipeline on Windows, and misclassifying a drive-letter or + * UNC specifier as "external" is exactly the false-external edge this guard + * exists to prevent (#1355). */ static bool specifier_is_relative(const char *module_path) { - return module_path && (module_path[0] == '.' || module_path[0] == '/'); + if (!module_path || !module_path[0]) { + return false; + } + if (module_path[0] == '.' || module_path[0] == '/') { + return true; + } + /* UNC share: \\server\share\... */ + if (module_path[0] == '\\' && module_path[1] == '\\') { + return true; + } + /* Drive-letter absolute path: C:\repo\... or C:/repo/... */ + if (isalpha((unsigned char)module_path[0]) && module_path[1] == ':' && + (module_path[2] == '\\' || module_path[2] == '/')) { + return true; + } + return false; } static bool import_map_binds(const char **import_map_keys, int import_map_count, diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index fd6d51855..863254fda 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4976,6 +4976,17 @@ static int count_nodes_named(cbm_store_t *s, const char *project, const char *na * (axios.get, api.patch on a renamed-axios instance, supertest request(app).get). * The regex false edge must stay suppressed in parallel too. CBM_WORKERS forces * >1 worker so the parallel path is taken regardless of the host core count. */ +/* #1355: padding count for write_external_import_shadow_fixture, deliberately + * well past MIN_FILES_FOR_PARALLEL (=50, a private #define in + * src/pipeline/pipeline.c — not exposed to tests, so this cannot be a + * static_assert against it) rather than sitting right at the threshold. The + * margin, not the exact value, is what the test depends on: a modest bump to + * the real threshold must not silently drop this fixture back onto the + * sequential-only path and leave the parallel resolver unexercised. Same + * pattern as ET_PARALLEL_PAD / CP_PARALLEL_PAD in test_edge_types_probe.c / + * test_convergence_probe.c. */ +enum { EXTERNAL_IMPORT_SHADOW_PARALLEL_PAD = 64 }; + /* #1355: write the shared external-import-shadow fixture into `dir`. * `pad_files` filler modules push the run over MIN_FILES_FOR_PARALLEL so the * same tree can be indexed by both resolvers. */ @@ -5066,11 +5077,13 @@ TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355) { /* Enough files that CBM_WORKERS can take the fused-parallel path; the same * tree is then indexed by each resolver in turn, because the guard lives at * two independent emit sites (pass_calls.c and pass_parallel.c). */ - write_external_import_shadow_fixture(tmp, 50); + write_external_import_shadow_fixture(tmp, EXTERNAL_IMPORT_SHADOW_PARALLEL_PAD); - char *old_workers = getenv("CBM_WORKERS"); + /* getenv() returns a pointer into the process environment that must be + * treated as read-only; strdup() below only ever reads through it. */ + const char *old_workers = getenv("CBM_WORKERS"); char *saved_workers = old_workers ? strdup(old_workers) : NULL; - char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); char *saved_single = old_single ? strdup(old_single) : NULL; cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); diff --git a/tests/test_registry.c b/tests/test_registry.c index eb47679f3..d4d7545aa 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1059,6 +1059,39 @@ TEST(external_import_shadow_relative_binding_wins_the_tie) { PASS(); } +TEST(external_import_shadow_windows_relative_specifier_kept) { + /* #1355 follow-up: a Windows-style specifier — + * drive-letter absolute (`C:\...` / `C:/...`) or UNC share (`\\server\...`) + * — names a path inside the indexed tree exactly like a POSIX "./x" or + * "/x". pr-smoke runs this pipeline on Windows; before this fix these + * specifiers fell through to "external package" and the same-name guess + * they'd otherwise validate got suppressed on that platform only. */ + CBMImport imports[] = { + {.local_name = "eq", .module_path = "C:\\repo\\src\\text-utils.ts"}, + {.local_name = "sql", .module_path = "C:/repo/src/text-utils.ts"}, + {.local_name = "normalize", .module_path = "\\\\server\\share\\text-utils.ts"}, + }; + CBMImportArray arr = {.items = imports, .count = 3, .cap = 3}; + + /* None of these materialized an import-map key (as if resolution missed + * them, mirroring the reported scenario) — the specifier alone must still + * keep the edge because it is in-tree, not external. RED before the fix + * (specifier_is_relative saw '.' / '/' only, so all three were classified + * external and suppressed); GREEN after. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, NULL, 0)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0)); + /* A bare package specifier without any drive letter or leading separator + * (e.g. "drizzle-orm") must still be external — this predicate must not + * become "anything with a colon or backslash". */ + CBMImport pkg_only[] = { + {.local_name = "eq", .module_path = "drizzle-orm"}, + }; + CBMImportArray parr = {.items = pkg_only, .count = 1, .cap = 1}; + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &parr, NULL, 0)); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -1161,4 +1194,5 @@ SUITE(registry) { RUN_TEST(external_import_shadow_drops_package_bound_bare_call); RUN_TEST(external_import_shadow_keeps_everything_else); RUN_TEST(external_import_shadow_relative_binding_wins_the_tie); + RUN_TEST(external_import_shadow_windows_relative_specifier_kept); } From 797a11a15eff6154428aeb268c0b55edb5647a7a Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Fri, 4 Sep 2026 00:32:18 +0200 Subject: [PATCH 04/76] fix(registry): keep workspace-sibling imports out of the external-import guard A per-language blast-radius census on twelve public repositories found one regression class in the #1355 guard, and it is not per-language: it is per-topology. In a workspace monorepo a BARE specifier can still name code inside the indexed tree. `import { eq } from "drizzle-orm"` written inside the drizzle-orm repository is a sibling package, not a dependency. Measured on drizzle-team/drizzle-orm at b7862528, full index, both resolvers: the guard removed 2609 CALLS edges. Resolving each removed edge's specifier against the repository's own package manifests puts 1377 of them (52.8%) on the file the specifier actually names. 690 of those are strict: the specifier names a subpath, that subpath maps to one directory, and the target file is inside it. The other 687 come from the bare package root, where the check can only confirm the target is somewhere in the package, so read 690 as the defensible floor. Only 373 removed edges pointed at a third-party package, which is the defect #1355 reports. The same census on eleven other repositories (flask, scrapy, express, zustand, got, cobra, gin, chi, ripgrep, gson, jq) removed 12 CALLS edges in total: nine fabricated, one lost (a documentation example importing the repository's own published name), two weak links between a shell command and a manifest key. The regression is specific to trees that ship the package they import. Consult the pipeline package map before calling a specifier external. It is keyed by the `name` of every manifest found in the tree, so a workspace registers each of its own packages there, and a subpath specifier is walked back one slash at a time ("drizzle-orm/pg-core" -> "drizzle-orm"). A specifier the tree itself claims is now treated exactly like a relative one -- kept. The check is deliberately a NAME test rather than a resolution test. A workspace package usually points `main` at a build artifact ("./index.cjs") that is not checked in, so asking whether the entry file exists in the graph answers "no" for exactly the monorepos this has to protect. Whether the tree claims the name is decidable from the manifest alone. Passing NULL restores the previous specifier-shape-only contract, which is what the unit tests exercise. After the change the same census removes 391 edges on drizzle-orm, 375 of them CALLS, and every one resolves to a third-party package; of the other eleven repositories only got changes, recovering its self-referencing edge. Signed-off-by: Yyunozor --- src/pipeline/pass_calls.c | 2 +- src/pipeline/pass_parallel.c | 2 +- src/pipeline/pipeline.h | 13 ++-- src/pipeline/registry.c | 49 +++++++++++++- tests/test_pipeline.c | 111 +++++++++++++++++++++++++++++++ tests/test_registry.c | 125 ++++++++++++++++++++++++++--------- 6 files changed, 263 insertions(+), 39 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 734e9b956..7fc6bc423 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -675,7 +675,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * #725 guard, after the service-pattern bypasses above, so no HTTP/route * edge can be lost to it. */ if (cbm_suppress_external_import_shadow(call->callee_name, res.strategy, file_imports, imp_keys, - imp_count)) { + imp_count, cbm_pipeline_get_pkgmap())) { return 0; } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 76ed4e9a4..a57d594cf 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2571,7 +2571,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } if (target_node && source_node->id != target_node->id && cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports, - imp_keys, imp_count)) { + imp_keys, imp_count, cbm_pipeline_get_pkgmap())) { /* #1355: same guard as pass_calls.c — a bare call bound by an * external package import must not become a CALLS edge to an * unrelated project symbol of the same name. */ diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index f24cc3045..45ef74f84 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -19,8 +19,9 @@ #include #include -#include "discover/discover.h" /* cbm_ignored_file_t (#963) */ -#include "foundation/constants.h" /* CBM_SZ_512 */ +#include "discover/discover.h" /* cbm_ignored_file_t (#963) */ +#include "foundation/constants.h" /* CBM_SZ_512 */ +#include "foundation/hash_table.h" /* CBMHashTable (#1355 pkgmap lookup) */ /* Forward declarations */ typedef struct cbm_store cbm_store_t; @@ -297,10 +298,14 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b * `import { eq } from "drizzle-orm"` must not make `eq(...)` a CALLS edge to an * unrelated project `eq`. A name the import map does bind, a relative * specifier, a member/qualified callee, and every import-/receiver-aware - * strategy are all kept. Pure; unit-tested in test_registry.c. */ + * strategy are all kept. `indexed_packages` is the pipeline package map: a + * specifier naming a package the tree itself declares (a workspace sibling) + * counts as in-tree and is kept too; NULL disables that check. + * Pure; unit-tested in test_registry.c. */ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy, const CBMImportArray *file_imports, - const char **import_map_keys, int import_map_count); + const char **import_map_keys, int import_map_count, + const CBMHashTable *indexed_packages); /* #725: drop a suffix_match CALLS edge when the caller language and the * target file's language disagree. unique_name (candidates == 1) is #1572 diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 6c0cdb42a..adbe2c713 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -528,6 +528,43 @@ static bool specifier_is_relative(const char *module_path) { return false; } +/* True when the specifier names a package that the indexed tree DECLARES — + * a workspace sibling, not a third-party dependency. `indexed_packages` is the + * pipeline's package map, keyed by the `name` of every manifest found in the + * tree (package.json, go.mod, Cargo.toml, ...), so a pnpm/npm/cargo workspace + * registers each of its own packages here. + * + * The key is the bare package name, so a subpath specifier is walked back one + * slash at a time: "drizzle-orm/pg-core" -> "drizzle-orm". Scoped names keep + * their leading segment ("@scope/pkg/sub" -> "@scope/pkg" -> "@scope"). + * + * Deliberately a NAME test, not a resolution test. A workspace package usually + * points `main`/`module` at a build artifact ("./index.cjs") that is not + * checked in, so asking whether the entry file exists in the graph answers + * "no" for exactly the monorepos this has to protect. Whether the tree claims + * the name is decidable from the manifest alone. */ +static bool specifier_names_indexed_package(const CBMHashTable *indexed_packages, + const char *module_path) { + if (!indexed_packages || !module_path || !module_path[0]) { + return false; + } + if (cbm_ht_has(indexed_packages, module_path)) { + return true; + } + char buf[CBM_SZ_512]; + if (strlen(module_path) >= sizeof(buf)) { + return false; + } + snprintf(buf, sizeof(buf), "%s", module_path); + for (char *slash = strrchr(buf, '/'); slash != NULL; slash = strrchr(buf, '/')) { + *slash = '\0'; + if (cbm_ht_has(indexed_packages, buf)) { + return true; + } + } + return false; +} + static bool import_map_binds(const char **import_map_keys, int import_map_count, const char *local_name) { if (!import_map_keys || import_map_count <= 0) { @@ -569,10 +606,17 @@ static bool import_map_binds(const char **import_map_keys, int import_map_count, * relative and a package specifier keeps the edge — the relative binding wins * the tie explicitly, so the outcome does not depend on import order. * + * `indexed_packages` extends that same reasoning to workspace monorepos, where + * a BARE specifier also names in-tree code: `import { eq } from "drizzle-orm"` + * inside the drizzle-orm repository is a sibling package, not a dependency. A + * specifier the tree's own manifests claim is treated exactly like a relative + * one. NULL disables the check and restores the specifier-shape-only contract. + * * Pure + side-effect-free so the contract is unit-testable without a pipeline. */ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy, const CBMImportArray *file_imports, - const char **import_map_keys, int import_map_count) { + const char **import_map_keys, int import_map_count, + const CBMHashTable *indexed_packages) { if (!callee_name || !callee_name[0] || !strategy || !strategy[0]) { return false; } @@ -595,7 +639,8 @@ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *st if (!imp->local_name || !imp->module_path || strcmp(imp->local_name, callee_name) != 0) { continue; } - if (specifier_is_relative(imp->module_path)) { + if (specifier_is_relative(imp->module_path) || + specifier_names_indexed_package(indexed_packages, imp->module_path)) { return false; /* an in-tree binding for this name — never suppress */ } external_binding = true; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 863254fda..f772acc69 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5112,6 +5112,116 @@ TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355) { PASS(); } +/* #1355 / #1732: the same tree, but the bare specifier now names a WORKSPACE + * package that the repository itself ships. `main` deliberately points at a + * build artifact that is not checked in — the shape every real monorepo has, + * and the reason an entry-file lookup cannot answer this question. */ +static void write_workspace_shadow_fixture(const char *dir, int pad_files) { + /* One directory level only: write_temp_file's mkdir is documented as + * "simple version, one level", and a deeper path fails silently — which + * makes the fixture look healthy while the file never lands. */ + write_temp_file(dir, "lib/package.json", + "{\n \"name\": \"@fixture/lib\",\n \"main\": \"./dist/index.js\"\n}\n"); + write_temp_file(dir, "lib/index.ts", + "export function wsEq(a: string, b: string): boolean {\n" + " return a === b;\n" + "}\n"); + write_temp_file(dir, "app/package.json", "{\n \"name\": \"@fixture/app\"\n}\n"); + /* The lone project symbol named wsSpec — an ordinary local helper that a + * project-wide guess would happily bind an external `wsSpec` to. */ + write_temp_file(dir, "app/spec-helpers.ts", + "export function wsSpec(label: string): string {\n" + " return label;\n" + "}\n"); + write_temp_file(dir, "app/query.ts", + "import { wsEq } from '@fixture/lib';\n" + "import { wsSpec } from 'vitest';\n" + "export function wsBuild(id: string): unknown {\n" + " return [wsEq(id, id), wsSpec('case')];\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "app/pad_%02d.ts", i); + snprintf(body, sizeof(body), "export function wsPad%02d(): number { return %d; }\n", i, i); + write_temp_file(dir, name, body); + } +} + +static int assert_workspace_shadow_contract(const char *dir, const char *db_name) { + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", dir, db_name); + cbm_pipeline_t *p = cbm_pipeline_new(dir, db_path, CBM_MODE_FULL); + if (!p) { + return 1; + } + if (cbm_pipeline_run(p) != 0) { + cbm_pipeline_free(p); + return 2; + } + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + cbm_pipeline_free(p); + return 3; + } + int rc = 0; + /* (1) the workspace sibling: the tree declares @fixture/lib, so the bare + * specifier is in-project and the fallback keeps its chance. RED without + * the package-map check — this is the 1377-edge class measured on + * drizzle-team/drizzle-orm. */ + if (!cross_file_call_exists(s, project, "wsBuild", "wsEq")) { + rc = 4; + } + /* (2) a genuine third-party package in the very same file is still cut: + * the check must not disarm the guard wholesale. */ + if (rc == 0 && cross_file_call_exists(s, project, "wsBuild", "wsSpec")) { + rc = 5; + } + cbm_store_close(s); + cbm_pipeline_free(p); + return rc; +} + +TEST(pipeline_external_import_shadow_keeps_workspace_sibling_issue1355) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_ws_import_shadow_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_workspace_shadow_fixture(tmp, EXTERNAL_IMPORT_SHADOW_PARALLEL_PAD); + + 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; + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + int sequential = assert_workspace_shadow_contract(tmp, "ws-sequential.db"); + + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + int parallel = assert_workspace_shadow_contract(tmp, "ws-parallel.db"); + + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + th_rmtree(tmp); + + ASSERT_EQ(sequential, 0); + ASSERT_EQ(parallel, 0); + PASS(); +} + TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX"); @@ -13461,6 +13571,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); RUN_TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355); + RUN_TEST(pipeline_external_import_shadow_keeps_workspace_sibling_issue1355); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); diff --git a/tests/test_registry.c b/tests/test_registry.c index d4d7545aa..d8494ce82 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -970,6 +970,9 @@ TEST(weak_call_guards_share_one_drop_list) { } ASSERT_EQ(member, binding); } + PASS(); +} + TEST(external_import_shadow_drops_package_bound_bare_call) { /* #1355: `import { eq, sql } from "drizzle-orm"` binds both names to a * package outside the indexed tree, so no import-map key exists for them and @@ -983,14 +986,14 @@ TEST(external_import_shadow_drops_package_bound_bare_call) { /* Only the relative import materialized an IMPORTS edge. */ const char *keys[] = {"users"}; - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, keys, 1)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, keys, 1)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "suffix_match", &arr, keys, 1)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "field_type_hint", &arr, keys, 1)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "fuzzy", &arr, keys, 1)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, keys, 1, NULL)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, keys, 1, NULL)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "suffix_match", &arr, keys, 1, NULL)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "field_type_hint", &arr, keys, 1, NULL)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "fuzzy", &arr, keys, 1, NULL)); /* A file whose imports all failed to materialize is still covered: the * evidence is the import statement, not the map. */ - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL)); PASS(); } @@ -1008,34 +1011,42 @@ TEST(external_import_shadow_keeps_everything_else) { const int nkeys = 2; /* Import-/receiver-/module-aware strategies are never this guard's business. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map_suffix", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "same_module", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "qualified_suffix", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "lsp_ts_import", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "lsp_ts_method", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "service_pattern", &arr, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map", &arr, keys, nkeys, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq", "import_map_suffix", &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "same_module", &arr, keys, nkeys, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq", "qualified_suffix", &arr, keys, nkeys, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq", "lsp_ts_import", &arr, keys, nkeys, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq", "lsp_ts_method", &arr, keys, nkeys, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq", "service_pattern", &arr, keys, nkeys, NULL)); /* A relative specifier names a path inside the tree — a missing IMPORTS edge * there is an in-project gap, not an external binding. */ ASSERT_FALSE( - cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0)); - /* A package the import map DOES bind resolved in-graph (workspace package). */ + cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, keys, nkeys, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("getRootContainer", "unique_name", &arr, keys, nkeys)); + cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0, NULL)); + /* A package the import map DOES bind resolved in-graph (workspace package). */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("getRootContainer", "unique_name", &arr, keys, + nkeys, NULL)); /* A name the file never imports (called with no import at all). */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("helper", "unique_name", &arr, keys, nkeys)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("helper", "unique_name", &arr, keys, nkeys, NULL)); /* Member and package/namespace-qualified callees belong to the * receiver-aware guards, not to this one. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq.apply", "unique_name", &arr, keys, nkeys)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq::apply", "unique_name", &arr, keys, nkeys)); + cbm_suppress_external_import_shadow("eq.apply", "unique_name", &arr, keys, nkeys, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq::apply", "unique_name", &arr, keys, nkeys, NULL)); /* Empty / absent inputs. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", NULL, &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow(NULL, "unique_name", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("", "unique_name", &arr, keys, nkeys)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", NULL, keys, nkeys)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", NULL, &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "", &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow(NULL, "unique_name", &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("", "unique_name", &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", NULL, keys, nkeys, NULL)); PASS(); } @@ -1054,8 +1065,8 @@ TEST(external_import_shadow_relative_binding_wins_the_tie) { }; CBMImportArray a = {.items = pkg_first, .count = 2, .cap = 2}; CBMImportArray b = {.items = rel_first, .count = 2, .cap = 2}; - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &a, NULL, 0)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &b, NULL, 0)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &a, NULL, 0, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &b, NULL, 0, NULL)); PASS(); } @@ -1078,9 +1089,10 @@ TEST(external_import_shadow_windows_relative_specifier_kept) { * keep the edge because it is in-tree, not external. RED before the fix * (specifier_is_relative saw '.' / '/' only, so all three were classified * external and suppressed); GREEN after. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, NULL, 0)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, NULL, 0, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0, NULL)); /* A bare package specifier without any drive letter or leading separator * (e.g. "drizzle-orm") must still be external — this predicate must not * become "anything with a colon or backslash". */ @@ -1088,7 +1100,57 @@ TEST(external_import_shadow_windows_relative_specifier_kept) { {.local_name = "eq", .module_path = "drizzle-orm"}, }; CBMImportArray parr = {.items = pkg_only, .count = 1, .cap = 1}; - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &parr, NULL, 0)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &parr, NULL, 0, NULL)); + PASS(); +} + +TEST(external_import_shadow_workspace_sibling_kept) { + /* #1355 / #1732: inside a workspace monorepo a BARE specifier can still name + * in-tree code — `import { eq } from "drizzle-orm"` written inside the + * drizzle-orm repository is a sibling package, not a dependency. Measured on + * drizzle-team/drizzle-orm at b7862528: without this check the guard removed + * 2609 CALLS edges, 1377 of which pointed at exactly the file the specifier + * names. The package map is keyed by each in-tree manifest's `name`, so the + * tree's own claim decides. */ + CBMImport imports[] = { + {.local_name = "eq", .module_path = "drizzle-orm"}, + {.local_name = "pgTable", .module_path = "drizzle-orm/pg-core"}, + {.local_name = "hoisted", .module_path = "@scope/kit/sub"}, + {.local_name = "test", .module_path = "vitest"}, + }; + CBMImportArray arr = {.items = imports, .count = 4, .cap = 4}; + + CBMHashTable *pkgs = cbm_ht_create(8); + ASSERT_NOT_NULL(pkgs); + cbm_ht_set(pkgs, "drizzle-orm", (void *)"qn"); + cbm_ht_set(pkgs, "@scope/kit", (void *)"qn"); + + /* Exact name, subpath, and scoped subpath all resolve to an in-tree + * package — the same-name fallback keeps its chance. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, pkgs)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("pgTable", "suffix_match", &arr, NULL, 0, pkgs)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("hoisted", "unique_name", &arr, NULL, 0, pkgs)); + /* A genuine third-party dependency is untouched by the new check: the tree + * declares no package called "vitest", so the #1355 removal still happens. */ + ASSERT_TRUE(cbm_suppress_external_import_shadow("test", "suffix_match", &arr, NULL, 0, pkgs)); + + /* A tree with no manifests at all (pkgmap NULL) keeps the pre-existing + * specifier-shape-only contract — every one of these is external again. */ + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("pgTable", "suffix_match", &arr, NULL, 0, NULL)); + + /* An unrelated package map must not accidentally match by prefix: "drizzle" + * is not "drizzle-orm", and the walk is by path segment, not by substring. */ + CBMHashTable *other = cbm_ht_create(8); + ASSERT_NOT_NULL(other); + cbm_ht_set(other, "drizzle", (void *)"qn"); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, other)); + + cbm_ht_free(other); + cbm_ht_free(pkgs); PASS(); } @@ -1195,4 +1257,5 @@ SUITE(registry) { RUN_TEST(external_import_shadow_keeps_everything_else); RUN_TEST(external_import_shadow_relative_binding_wins_the_tie); RUN_TEST(external_import_shadow_windows_relative_specifier_kept); + RUN_TEST(external_import_shadow_workspace_sibling_kept); } From 9f57edfa576072398728eda2c6fc2585d2ecb063 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 7 Sep 2026 08:00:09 +0000 Subject: [PATCH 05/76] fix(pipeline): skip an unreadable subdirectory in the control-file walk instead of aborting the whole index semantic_manifest_walk_controls() returns CBM_NOT_FOUND the moment cbm_opendir() fails on any subdirectory, and that propagates all the way up through cbm_pipeline_build_semantic_manifest(), aborting the entire index_repository run. discover.c's walk_dir() already treats the same failure (a permission-denied directory it can't open) as "nothing to discover here" and moves on. The control walk has no reason to be stricter: it only looks for .gitignore/.cbmignore/package-manifest files, which a locked-out directory simply doesn't contribute. Skip it and keep walking, except at depth 0, where an inaccessible manifest root is still a real error (pipeline_semantic_manifest_rejects_non_directory_root pins that). Signed-off-by: Amir Fathi --- src/pipeline/pipeline_incremental.c | 5 +++- tests/test_pipeline.c | 42 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index bf71a1f84..aa7d04321 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -338,7 +338,10 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder, } cbm_dir_t *dir = cbm_opendir(abs_dir); if (!dir) { - return CBM_NOT_FOUND; + /* A nested directory we can't open has no control files to + * contribute; skip it like discover.c's walk_dir already does. + * The root call (depth 0) still fails closed. */ + return depth == 0 ? CBM_NOT_FOUND : 0; } int rc = 0; cbm_dirent_t *entry; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 5608ee559..eae749bc1 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4123,6 +4123,45 @@ TEST(pipeline_semantic_manifest_rejects_non_directory_root) { PASS(); } +#ifndef _WIN32 +/* A nested directory the process cannot open (permission-denied) must not + * abort the whole manifest walk: it has no control files to contribute, so + * it is skipped, and control files in sibling directories still get found. + * Root-not-a-directory (above) is a different, still fail-closed case. */ +TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_locked_dir_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "readable.py", "def Readable():\n return 1\n"); + write_temp_file(tmp, "locked/secret.txt", "should never be opened\n"); + write_temp_file(tmp, "sibling/.gitignore", "*.log\n"); + + char locked_path[512]; + snprintf(locked_path, sizeof(locked_path), "%s/locked", tmp); + ASSERT_EQ(chmod(locked_path, 0), 0); + + cbm_file_hash_t *manifest = NULL; + int manifest_count = -1; + int rc = cbm_pipeline_build_semantic_manifest("manifest-skip-locked", tmp, NULL, 0, NULL, 0, + NULL, NULL, &manifest, &manifest_count); + + bool found_sibling_gitignore = false; + for (int i = 0; i < manifest_count; i++) { + if (manifest[i].rel_path && strcmp(manifest[i].rel_path, "sibling/.gitignore") == 0) { + found_sibling_gitignore = true; + } + } + + chmod(locked_path, 0755); + cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0); + th_rmtree(tmp); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(found_sibling_gitignore); + PASS(); +} +#endif /* !_WIN32 */ + /* A fully validated staged graph must be able to recover from a definitely * non-SQLite destination without deleting evidence or overwriting an earlier * quarantine. The replacement happens only after the corrupt bytes are moved. */ @@ -13862,6 +13901,9 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(pipeline_incremental_successful_publication_preserves_adr); RUN_TEST(pipeline_full_adr_capture_failure_preserves_previous_generation); RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root); +#ifndef _WIN32 + RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory); +#endif RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite); RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine); #endif From b974adf6ed9b82d8669d1a65b1757816ce0d12df Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Mon, 7 Sep 2026 10:19:48 -0400 Subject: [PATCH 06/76] fix: grow the ts_lsp AST walk stacks so wide files keep every declaration Three walks in internal/cbm/lsp/ts_lsp.c held at most 256 AST nodes in a fixed array and stopped pushing when it filled. Nothing reported the stop, so the passes answered as if they had seen the whole file. In rebuild_signatures_from_ast and convert_signature_type_params the seeding loop alone can fill all 256 slots, because it pushes every top-level child of the file. A file with more than 256 top-level declarations therefore skipped every declaration past that point: each one kept whatever signature the first extraction pass had guessed, and a generic function past the cap kept NAMED instead of TYPE_PARAM, so calls through it stopped resolving. In infer_return_type_from_body the same cap made a wide function body answer "unknown return type" for a return the walk had never reached. The repo already carries the module that solves this: internal/cbm/extract_node_stack.h, written for GitHub issue #199 to replace fixed TSNode stack[] arrays that silently drop subtrees. Nine extract files use it; ts_lsp.c was never converted. This converts it. The header gains ts_nstack_init_arena, which takes an arena directly, because the LSP passes hold a TSLSPContext rather than a CBMExtractCtx. ts_nstack_init keeps its context argument, which is what makes handing over the wrong arena a type error, and now delegates to the new function. Traversal order changes from last-child-first to first-child-first, because ts_nstack_push_children reverses on push. That also drops an O(N^2) ts_node_child(n, i) loop for one cursor pass. For infer_return_type_from_body the new order matches the comment the function already carried: it finds the first return, where the old code found the last. New test tslsp_stress_declarations_past_stack_cap makes the same claim as tslsp_generic_identity_inference, 300 declarations deeper in the file. Without this change it fails with "MISSING resolved call: caller~.go -> callee~use"; with it the ts_lsp suite is 304 passed, 0 failed. Signed-off-by: Joshua Richter --- internal/cbm/extract_node_stack.h | 18 +++++--- internal/cbm/lsp/ts_lsp.c | 74 +++++++++++++++---------------- tests/test_ts_lsp.c | 30 +++++++++++++ 3 files changed, 78 insertions(+), 44 deletions(-) diff --git a/internal/cbm/extract_node_stack.h b/internal/cbm/extract_node_stack.h index 0013d53b1..443eb6e6a 100644 --- a/internal/cbm/extract_node_stack.h +++ b/internal/cbm/extract_node_stack.h @@ -34,6 +34,18 @@ typedef struct { CBMArena *scratch; } TSNodeStack; +/* Initialize a stack against an arena directly. The LSP passes hold a + * TSLSPContext, not a CBMExtractCtx, and every allocation in those files + * already comes from that one arena — see collect_children in ts_lsp.c. An + * extraction caller should still use ts_nstack_init below, whose context + * argument is what makes handing over the wrong arena a type error. */ +static inline void ts_nstack_init_arena(TSNodeStack *s, CBMArena *arena, int initial_cap) { + s->scratch = arena; + s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode)); + s->count = 0; + s->cap = s->items ? initial_cap : 0; +} + /* Initialize a stack with the given initial capacity, allocated from the * context's traversal scratch. Taking the context rather than an arena is * deliberate: it makes handing over ctx->arena, or a local alias of it, a type @@ -41,11 +53,7 @@ typedef struct { * scratch falls back to ctx->arena, which is the behaviour that shipped before * #1997, so no caller ever gets a NULL arena and silently loses nodes. */ static inline void ts_nstack_init(TSNodeStack *s, const CBMExtractCtx *ctx, int initial_cap) { - CBMArena *arena = ctx->scratch ? ctx->scratch : ctx->arena; - s->scratch = arena; - s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode)); - s->count = 0; - s->cap = s->items ? initial_cap : 0; + ts_nstack_init_arena(s, ctx->scratch ? ctx->scratch : ctx->arena, initial_cap); } /* Push a node onto the stack, growing 2x if needed. */ diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index fc8b8911f..2e7292416 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -27,6 +27,7 @@ */ #include "ts_lsp.h" +#include "extract_node_stack.h" #include #include #include @@ -4634,13 +4635,15 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body if (ts_node_is_null(body)) return cbm_type_unknown(); - // Iterative DFS using a small fixed-size stack to avoid C-stack blowup on big bodies. - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; - int top = 0; - stack[top++] = body; - while (top > 0) { - TSNode n = stack[--top]; + // Iterative DFS to avoid C-stack blowup on big bodies. The stack grows, so a + // body wider than any fixed cap is still walked in full: a fixed 256 slots + // used to stop pushing without a word, and this function then answered + // "unknown return type" for a body whose return it had never reached. + TSNodeStack stack; + ts_nstack_init_arena(&stack, ctx->arena, 256); + ts_nstack_push(&stack, body); + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); @@ -4662,10 +4665,7 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body strcmp(k, "arrow_function") == 0 || strcmp(k, "method_definition") == 0) continue; - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + ts_nstack_push_children(&stack, n); } return cbm_type_unknown(); } @@ -4681,31 +4681,29 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR if (ts_node_is_null(root) || !reg || !ctx->module_qn) return; - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; - int top = 0; - uint32_t nc = ts_node_child_count(root); - for (uint32_t i = 0; i < nc && top < STACK_CAP; i++) - stack[top++] = ts_node_child(root, i); + /* The stack grows. A fixed 256 slots used to be filled by the seeding loop + * alone on a file with that many top-level children, and every declaration + * past it was then skipped without a word — it kept whatever signature + * extract_defs had guessed. */ + TSNodeStack stack; + ts_nstack_init_arena(&stack, ctx->arena, 256); + ts_nstack_push_children(&stack, root); - while (top > 0) { - TSNode n = stack[--top]; + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); // Recurse into export_statement and class bodies. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + ts_nstack_push_children(&stack, n); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); - if (!ts_node_is_null(body) && top < STACK_CAP) - stack[top++] = body; + if (!ts_node_is_null(body)) + ts_nstack_push(&stack, body); continue; } @@ -4845,31 +4843,29 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp return; // Walk: function_declaration, class_declaration { method_definition }, plus exported. - enum { STACK_CAP = 256 }; - TSNode stack[STACK_CAP]; - int top = 0; - uint32_t nc = ts_node_child_count(root); - for (uint32_t i = 0; i < nc && top < STACK_CAP; i++) - stack[top++] = ts_node_child(root, i); + /* The stack grows. A fixed 256 slots used to be filled by the seeding loop + * alone on a file with that many top-level children, and every declaration + * past it was then skipped without a word — it kept whatever signature + * extract_defs had guessed. */ + TSNodeStack stack; + ts_nstack_init_arena(&stack, ctx->arena, 256); + ts_nstack_push_children(&stack, root); - while (top > 0) { - TSNode n = stack[--top]; + while (stack.count > 0) { + TSNode n = ts_nstack_pop(&stack); if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); // Recurse into export_statement and class_body. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - uint32_t cnt = ts_node_child_count(n); - for (uint32_t i = 0; i < cnt && top < STACK_CAP; i++) { - stack[top++] = ts_node_child(n, i); - } + ts_nstack_push_children(&stack, n); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); - if (!ts_node_is_null(body) && top < STACK_CAP) - stack[top++] = body; + if (!ts_node_is_null(body)) + ts_nstack_push(&stack, body); continue; } diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index 52c3bbacb..49230da43 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -2817,6 +2817,35 @@ TEST(tslsp_stress_many_classes) { PASS(); } +TEST(tslsp_stress_declarations_past_stack_cap) { + /* 300 padding functions, then the generic pair that needs the AST walk. + * The walk used to hold 256 nodes at most, so every top-level declaration + * after the 256th was dropped without a word and kept whatever signature + * the first extraction pass had guessed. */ + enum { BUF_CAP = 64 * 1024 }; + char *buf = (char *)malloc(BUF_CAP); + if (!buf) + PASS(); + char *p = buf; + char *end = buf + BUF_CAP; + for (int i = 0; i < 300; i++) { + p += snprintf(p, (size_t)(end - p), "function pad%d(): void {}\n", i); + } + p += snprintf(p, (size_t)(end - p), "function identity(x: T): T { return x; }\n"); + p += snprintf(p, (size_t)(end - p), "class Box { use(): void {} }\n"); + p += snprintf(p, (size_t)(end - p), + "function go() { const b = identity(new Box()); b.use(); }\n"); + *p = '\0'; + + CBMFileResult *r = extract_ts(buf); + free(buf); + ASSERT_NOT_NULL(r); + /* Same claim as tslsp_generic_identity_inference, 300 declarations deeper. */ + ASSERT_GTE(require_resolved(r, ".go", "use"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_stress_deep_inheritance) { /* Chain of 30 classes via extends, leaf method on root. */ char buf[16 * 1024]; @@ -4565,6 +4594,7 @@ SUITE(ts_lsp) { /* Stress tests */ RUN_TEST(tslsp_stress_many_classes); + RUN_TEST(tslsp_stress_declarations_past_stack_cap); RUN_TEST(tslsp_stress_deep_inheritance); RUN_TEST(tslsp_stress_long_method_chain); RUN_TEST(tslsp_stress_megafile_mixed); From ec56ea8f60f0058bd1cd0fffb38cc0153b99f895 Mon Sep 17 00:00:00 2001 From: Joshua Richter Date: Mon, 7 Sep 2026 12:28:01 -0400 Subject: [PATCH 07/76] test: cover the return-type walk past the old stack cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit converted three walks in ts_lsp.c but tested only two of them. infer_return_type_from_body was changed without a test of its own. tslsp_stress_return_past_stack_cap makes the same claim as the existing tslsp_return_inferred_local, with the return placed after 300 statements — more than the 256 slots the old fixed array held, so the walk used to stop before reaching it and answer "unknown return type". Without the fix the suite is 303 passed, 2 failed, the second failure being this test. With it, 305 passed. Signed-off-by: Joshua Richter --- tests/test_ts_lsp.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index 49230da43..ba0409e90 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -2846,6 +2846,35 @@ TEST(tslsp_stress_declarations_past_stack_cap) { PASS(); } +TEST(tslsp_stress_return_past_stack_cap) { + /* infer_return_type_from_body reads the return type out of a body that + * carries no written one. The return here sits after 300 statements, more + * than the old fixed cap held, so the walk used to stop before reaching it + * and answer "unknown return type". */ + enum { BUF_CAP = 64 * 1024 }; + char *buf = (char *)malloc(BUF_CAP); + if (!buf) + PASS(); + char *p = buf; + char *end = buf + BUF_CAP; + p += snprintf(p, (size_t)(end - p), "class Conn { ping(): void {} }\n"); + p += snprintf(p, (size_t)(end - p), "function makeConn() {\n"); + for (int i = 0; i < 300; i++) { + p += snprintf(p, (size_t)(end - p), " let a%d = 0;\n", i); + } + p += snprintf(p, (size_t)(end - p), " return new Conn();\n}\n"); + p += snprintf(p, (size_t)(end - p), "function go() { const c = makeConn(); c.ping(); }\n"); + *p = '\0'; + + CBMFileResult *r = extract_ts(buf); + free(buf); + ASSERT_NOT_NULL(r); + /* Same claim as tslsp_return_inferred_local, 300 statements deeper. */ + ASSERT_GTE(require_resolved(r, ".go", "ping"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_stress_deep_inheritance) { /* Chain of 30 classes via extends, leaf method on root. */ char buf[16 * 1024]; @@ -4595,6 +4624,7 @@ SUITE(ts_lsp) { /* Stress tests */ RUN_TEST(tslsp_stress_many_classes); RUN_TEST(tslsp_stress_declarations_past_stack_cap); + RUN_TEST(tslsp_stress_return_past_stack_cap); RUN_TEST(tslsp_stress_deep_inheritance); RUN_TEST(tslsp_stress_long_method_chain); RUN_TEST(tslsp_stress_megafile_mixed); From 7054e4ff8af76f4fc1fe89407874e9910d0e412d Mon Sep 17 00:00:00 2001 From: yasmoradi Date: Thu, 17 Sep 2026 22:57:02 +0200 Subject: [PATCH 08/76] fix(extraction): recover C# 14 extension block members The vendored tree-sitter-c-sharp has no extension production, so an extension block parses as a constructor_declaration named extension whose body holds the members as local_function_statement nodes. Nothing recurses into a constructor body, and has_error stays 0, so the members were dropped with no signal at all. Emit them against the enclosing type instead. A constructor carries its own type name, so this shape is only a genuine constructor inside a type literally called extension, which keeps its own test. Fixes #2071 Signed-off-by: yasmoradi --- internal/cbm/extract_defs.c | 64 +++++++++++++++++++++++++++++++++++++ tests/test_extraction.c | 39 ++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b49147dc6..b00604753 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5032,6 +5032,61 @@ static void extract_objc_impl_methods(CBMExtractCtx *ctx, TSNode impl_node, cons } } +/* True for a C# 14 extension block. The vendored tree-sitter-c-sharp has no + * `extension` production, so `extension(string text) { ... }` parses as a + * constructor_declaration named `extension` and nothing flags it: has_error + * stays 0. A constructor carries its own type's name, so this shape can only be + * a genuine constructor inside a type literally called `extension` (#2071). */ +static bool csharp_is_extension_block(TSNode node, TSNode class_node, const char *source) { + if (strcmp(ts_node_type(node), "constructor_declaration") != 0) { + return false; + } + TSNode name = resolve_method_name(node, CBM_LANG_CSHARP); + if (ts_node_is_null(name)) { + return false; + } + const size_t kw_len = sizeof("extension") - 1; + uint32_t len = ts_node_end_byte(name) - ts_node_start_byte(name); + if (len != kw_len || strncmp(source + ts_node_start_byte(name), "extension", kw_len) != 0) { + return false; + } + TSNode type_name = ts_node_child_by_field_name(class_node, TS_FIELD("name")); + if (!ts_node_is_null(type_name)) { + uint32_t tlen = ts_node_end_byte(type_name) - ts_node_start_byte(type_name); + if (tlen == kw_len && + strncmp(source + ts_node_start_byte(type_name), "extension", kw_len) == 0) { + return false; + } + } + return true; +} + +/* Lift the members of a misparsed C# 14 extension block onto the enclosing + * type. They sit in the block the grammar took for a constructor body, which + * nothing recurses into, so every one of them was dropped. Methods land there + * as local_function_statement; a property in the block parses as ERROR and is + * beyond recovery without a grammar that knows the construct (#2071). */ +static void extract_csharp_extension_members(CBMExtractCtx *ctx, TSNode ext_node, TSNode class_node, + const char *class_qn, const CBMLangSpec *spec) { + TSNode body = cbm_find_child_by_kind(ext_node, "block"); + if (ts_node_is_null(body)) { + return; + } + uint32_t count = ts_node_child_count(body); + for (uint32_t i = 0; i < count; i++) { + TSNode member = ts_node_child(body, i); + if (ts_node_is_null(member) || + strcmp(ts_node_type(member), "local_function_statement") != 0) { + continue; + } + TSNode name = resolve_method_name(member, ctx->language); + if (ts_node_is_null(name)) { + continue; + } + push_method_def(ctx, member, class_node, class_qn, spec, name); + } +} + // Extract methods inside a class body static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const char *class_qn, const CBMLangSpec *spec) { @@ -5047,6 +5102,15 @@ static void extract_class_methods(CBMExtractCtx *ctx, TSNode class_node, const c continue; } + /* C# 14 extension block: emit the members it holds as methods of the + * enclosing type, rather than the block itself as a constructor named + * `extension` with its members buried in the body (#2071). */ + if (ctx->language == CBM_LANG_CSHARP && + csharp_is_extension_block(child, class_node, ctx->source)) { + extract_csharp_extension_members(ctx, child, class_node, class_qn, spec); + continue; + } + if (ctx->language == CBM_LANG_OBJC && strcmp(ts_node_type(child), "implementation_definition") == 0) { extract_objc_impl_methods(ctx, child, class_qn, spec); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 14d75bf8b..618561f74 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -772,6 +772,43 @@ TEST(csharp_interface) { PASS(); } +/* Issue #2071: a C# 14 extension block parses as a constructor_declaration + * named `extension` whose body holds the members, and has_error stays 0, so the + * members were dropped without any signal. They belong to the enclosing type. */ +TEST(csharp_extension_block_members) { + CBMFileResult *r = extract("namespace App;\n" + "public static class NumberExtensions\n" + "{\n" + " extension(IEnumerable numbers)\n" + " {\n" + " public int SumPositive() => 0;\n" + " public static int Zero() => 0;\n" + " }\n" + "}\n", + CBM_LANG_CSHARP, "t", "NumberExtensions.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Class", "NumberExtensions")); + ASSERT(has_def(r, "Method", "SumPositive")); + ASSERT(has_def(r, "Method", "Zero")); + ASSERT_FALSE(has_def_any(r, "extension")); + cbm_free_result(r); + PASS(); +} + +/* The same shape is a genuine constructor inside a type actually called + * `extension`, and must still be emitted (#2071). */ +TEST(csharp_constructor_of_type_named_extension) { + CBMFileResult *r = + extract("namespace App { public class extension { public extension() { } } }", + CBM_LANG_CSHARP, "t", "extension.cs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Method", "extension")); + cbm_free_result(r); + PASS(); +} + /* --- Swift --- */ TEST(swift_class) { CBMFileResult *r = extract("class Vehicle {\n var speed: Int = 0\n func accelerate() { " @@ -7580,6 +7617,8 @@ SUITE(extraction) { RUN_TEST(ruby_module); RUN_TEST(csharp_class); RUN_TEST(csharp_interface); + RUN_TEST(csharp_extension_block_members); + RUN_TEST(csharp_constructor_of_type_named_extension); RUN_TEST(swift_class); RUN_TEST(swift_protocol); RUN_TEST(kotlin_function); From 9b4d697a57a03f242164db956843a5313168005d Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Fri, 18 Sep 2026 14:22:37 +0800 Subject: [PATCH 09/76] fix(ui): preserve framework route entry points Signed-off-by: lorenzozanee --- src/ui/layout3d.c | 29 ++++++++++++++++++++++----- tests/test_ui.c | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index 3176da2ff..111be4903 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -554,6 +554,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, /* 3. Query edges — filter during fetch via binary search (O(e log n)) */ int *deg = calloc((size_t)n, sizeof(int)); + bool *framework_entry = calloc((size_t)n, sizeof(bool)); int mapped = 0; int edge_cap = CBM_SZ_256; cbm_edge_t *all_edges = malloc((size_t)edge_cap * sizeof(cbm_edge_t)); @@ -561,8 +562,9 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, int *ed = malloc((size_t)edge_cap * sizeof(int)); cbm_schema_info_t schema; memset(&schema, 0, sizeof(schema)); - if (deg && all_edges && es && ed && - cbm_store_get_schema(store, project, &schema) == CBM_STORE_OK) { + bool framework_entries_valid = deg && framework_entry && all_edges && es && ed; + if (framework_entries_valid) { + if (cbm_store_get_schema(store, project, &schema) == CBM_STORE_OK) { for (int t = 0; t < schema.edge_type_count; t++) { cbm_edge_t *te = NULL; int tc = 0; @@ -571,6 +573,15 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, for (int e = 0; e < tc; e++) { int si = find_node_index(id_map, n, te[e].source_id); int di = find_node_index(id_map, n, te[e].target_id); + if (si >= 0 && te[e].type && strcmp(te[e].type, "HANDLES") == 0) { + cbm_node_t route = {0}; + if (cbm_store_find_node_by_id(store, te[e].target_id, &route) == CBM_STORE_OK) { + if (route.label && strcmp(route.label, "Route") == 0) + framework_entry[si] = true; + cbm_node_free_fields(&route); + } else + framework_entries_valid = false; + } if (si >= 0 && di >= 0) { if (mapped >= edge_cap) { int nc = edge_cap * PAIR_LEN; @@ -578,6 +589,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, int *ts = realloc(es, (size_t)nc * sizeof(int)); int *td = realloc(ed, (size_t)nc * sizeof(int)); if (!te2 || !ts || !td) { + framework_entries_valid = false; if (te2) all_edges = te2; if (ts) @@ -606,7 +618,11 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, } } free(te); - } + } else + framework_entries_valid = false; + } + } else { + framework_entries_valid = false; } edges_done: cbm_store_schema_free(&schema); @@ -630,6 +646,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, if (!result || !bodies) { free(bodies); free(deg); + free(framework_entry); free(es); free(ed); free(cdepth); @@ -735,11 +752,12 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, status = "structural"; else if (testish) status = "test"; - else if (nf.is_entry || nf.is_route) + else if (nf.is_entry || nf.is_route || + (framework_entries_valid && framework_entry && framework_entry[i])) status = "entry"; else if (nf.is_exported) status = "exported"; - else if (ic == 0 && iu == 0) + else if (framework_entries_valid && ic == 0 && iu == 0) status = "dead"; else if (ic == 1) status = "single"; @@ -772,6 +790,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project, free(bodies); free(deg); + free(framework_entry); free(es); free(ed); free(cdepth); diff --git a/tests/test_ui.c b/tests/test_ui.c index a403dbbbe..e4f2129bb 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -627,6 +627,30 @@ TEST(layout_dead_code_classification) { .qualified_name = "dc::exportedfn", .file_path = "src/d.c", .properties_json = "{\"is_exported\":true}"}; + cbm_node_t framework = {.project = "dc", + .label = "Function", + .name = "frameworkfn", + .qualified_name = "dc::frameworkfn", + .file_path = "src/routes.c", + .properties_json = "{}"}; + cbm_node_t route = {.project = "dc", + .label = "Route", + .name = "GET /framework", + .qualified_name = "dc::route", + .file_path = "src/routes.c", + .properties_json = "{}"}; + cbm_node_t unrelated = {.project = "dc", + .label = "Class", + .name = "UnrelatedTarget", + .qualified_name = "dc::unrelated", + .file_path = "src/routes.c", + .properties_json = "{}"}; + cbm_node_t nonroute = {.project = "dc", + .label = "Function", + .name = "nonroutefn", + .qualified_name = "dc::nonroutefn", + .file_path = "src/routes.c", + .properties_json = "{}"}; cbm_node_t single = {.project = "dc", .label = "Function", .name = "calledonce", @@ -658,6 +682,14 @@ TEST(layout_dead_code_classification) { cbm_store_upsert_node(store, &tst); cbm_store_upsert_node(store, &tstpath); cbm_store_upsert_node(store, &exp); + int64_t id_framework = cbm_store_upsert_node(store, &framework); + int64_t id_route = cbm_store_upsert_node(store, &route); + int64_t id_unrelated = cbm_store_upsert_node(store, &unrelated); + int64_t id_nonroute = cbm_store_upsert_node(store, &nonroute); + ASSERT_GT(id_framework, 0); + ASSERT_GT(id_route, 0); + ASSERT_GT(id_unrelated, 0); + ASSERT_GT(id_nonroute, 0); int64_t id_single = cbm_store_upsert_node(store, &single); int64_t id_norm = cbm_store_upsert_node(store, &norm); int64_t id_caller = cbm_store_upsert_node(store, &caller); @@ -670,9 +702,15 @@ TEST(layout_dead_code_classification) { cbm_edge_t e2 = { .project = "dc", .source_id = id_caller, .target_id = id_norm, .type = "CALLS"}; cbm_edge_t e3 = {.project = "dc", .source_id = id_dead, .target_id = id_norm, .type = "CALLS"}; + cbm_edge_t framework_route = { + .project = "dc", .source_id = id_framework, .target_id = id_route, .type = "HANDLES"}; + cbm_edge_t framework_unrelated = { + .project = "dc", .source_id = id_nonroute, .target_id = id_unrelated, .type = "HANDLES"}; cbm_store_insert_edge(store, &e1); cbm_store_insert_edge(store, &e2); cbm_store_insert_edge(store, &e3); + ASSERT_GT(cbm_store_insert_edge(store, &framework_route), 0); + ASSERT_GT(cbm_store_insert_edge(store, &framework_unrelated), 0); cbm_layout_result_t *r = cbm_layout_compute(store, "dc", CBM_LAYOUT_OVERVIEW, NULL, 0, 100); ASSERT_NOT_NULL(r); @@ -700,6 +738,18 @@ TEST(layout_dead_code_classification) { ASSERT_NOT_NULL(ln); ASSERT_STR_EQ(ln->status, "exported"); + ln = find_layout_node(r, "frameworkfn"); + ASSERT_NOT_NULL(ln); + ASSERT_STR_EQ(ln->status, "entry"); + + ln = find_layout_node(r, "nonroutefn"); + ASSERT_NOT_NULL(ln); + ASSERT_STR_EQ(ln->status, "dead"); + + ln = find_layout_node(r, "GET /framework"); + ASSERT_NOT_NULL(ln); + ASSERT_STR_EQ(ln->status, "structural"); + ln = find_layout_node(r, "calledonce"); ASSERT_NOT_NULL(ln); ASSERT_STR_EQ(ln->status, "single"); From 138a62253d28b66650cf2d4ea25ba7c3b4f3a111 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Sat, 19 Sep 2026 23:46:59 +0200 Subject: [PATCH 10/76] fix(registry): resolve package-path imports against the project's own packages JVM package imports were read as external. A Kotlin or Java specifier is a dotted package path whether the package belongs to the indexed tree or to a dependency -- `import org.jetbrains.exposed.v1.tests.shared.assertEquals` and `import kotlin.test.assertEquals` have the same shape, and neither is "relative". The package map cannot separate them either: the manifests it reads for the JVM (pom.xml, build.gradle) name build artifacts, not packages. So every package-path specifier fell through to "external" and the guard cut calls into the project's own code. Measured on JetBrains/Exposed at 0e4d81a5, full index: the guard removed 426 CALLS edges, all of them suffix_match, all of them into the repository's own exposed-tests/.../shared/Assert.kt. Every callee was assertEquals, assertTrue or assertFalse -- names that file declares and that the test files import twice, once from the project package and once from kotlin.test. The double import is what leaves the import map without a key and hands the call to the project-wide guess. The evidence that separates the two is the tree's own `package` declarations, and the pipeline already collects them: the import passes build a namespace map keyed by the dot-normalized `package` / `namespace` / `use` of every indexed file, to resolve namespace imports. Publish that map for the duration of the run instead of freeing it at the end of the import pass, and consult it before calling a package-path specifier external. The trailing member segment is walked off one at a time, the same way the namespace-map lookup in cbm_pipeline_resolve_import_node does it. This keeps the effect the census called valuable. `java.net.URL` walks to "java.net" and then "java", neither of which any project file declares, so a bare `URL` bound to an in-tree URL class is still cut. Only specifiers the tree itself claims are kept. Passing NULL restores the previous contract, which is what the unit tests exercise. After the change Exposed carries 31195 CALLS edges, the same as the merge-base, with none removed and none added -- stable across 28 indexations of the repository (8 with this branch, 20 single-threaded). Total edge counts are not a usable invariant here: roughly one indexation in ten materializes a `.properties` resource file as a File node under Folder nodes instead of a Module with its Variables, on the merge-base binary as well, and the file it picks varies. That flip never touches a CALLS edge and disappears entirely under CBM_INDEX_SINGLE_THREAD=1. The walk that strips the trailing member segment has no floor, on purpose: a third-party package published under a prefix the tree declares reads as in-tree and the guard stands down, leaving exactly the edge the merge-base emits. A call-resolution suppressor has to fail on that side. Signed-off-by: Yyunozor --- src/pipeline/pass_calls.c | 3 +- src/pipeline/pass_definitions.c | 4 +- src/pipeline/pass_parallel.c | 9 +- src/pipeline/pipeline.c | 14 ++ src/pipeline/pipeline.h | 7 +- src/pipeline/pipeline_incremental.c | 6 + src/pipeline/pipeline_internal.h | 10 ++ src/pipeline/registry.c | 93 ++++++++++++- tests/test_pipeline.c | 127 +++++++++++++++++ tests/test_registry.c | 206 ++++++++++++++++++++++------ 10 files changed, 430 insertions(+), 49 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 7fc6bc423..f0f9b499f 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -675,7 +675,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * #725 guard, after the service-pattern bypasses above, so no HTTP/route * edge can be lost to it. */ if (cbm_suppress_external_import_shadow(call->callee_name, res.strategy, file_imports, imp_keys, - imp_count, cbm_pipeline_get_pkgmap())) { + imp_count, cbm_pipeline_get_pkgmap(), + cbm_pipeline_get_nsmap())) { return 0; } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7ac98e9cd..eac9d0a96 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -844,7 +844,9 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t create_channel_edges_for_file(ctx, result, files[i].rel_path); cbm_pipeline_create_env_configures_for_file(ctx, result, files[i].rel_path); } - cbm_pipeline_namespace_map_free(namespace_map); + /* Publish instead of free — see the twin in pass_parallel.c (#1355). */ + cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap()); + cbm_pipeline_set_nsmap(namespace_map); if (owns_local_cache) { for (int i = 0; i < file_count; i++) { if (local_cache[i]) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a57d594cf..d7aa7bba9 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1343,7 +1343,11 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t cbm_pipeline_create_env_configures_for_file(ctx, result, rel); } - cbm_pipeline_namespace_map_free(namespace_map); + /* Publish instead of free: the call pass needs the same declared-package + * set to tell an in-tree package path from a third-party one (#1355). + * The pipeline owns it from here and frees it in its cleanup. */ + cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap()); + cbm_pipeline_set_nsmap(namespace_map); cbm_log_info("parallel.registry.done", "entries", itoa_log(reg_entries), "defines", itoa_log(defines_edges), "imports", itoa_log(imports_edges)); @@ -2571,7 +2575,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } if (target_node && source_node->id != target_node->id && cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports, - imp_keys, imp_count, cbm_pipeline_get_pkgmap())) { + imp_keys, imp_count, cbm_pipeline_get_pkgmap(), + cbm_pipeline_get_nsmap())) { /* #1355: same guard as pass_calls.c — a bare call bound by an * external package import must not become a CALLS edge to an * unrelated project symbol of the same name. */ diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 938437549..1f15263a4 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -229,6 +229,18 @@ void cbm_pipeline_set_pkgmap(CBMHashTable *map) { g_pkgmap = map; } +/* ── Global declared-package map (one active pipeline at a time) ── */ + +static CBMHashTable *g_nsmap = NULL; + +CBMHashTable *cbm_pipeline_get_nsmap(void) { + return g_nsmap; +} + +void cbm_pipeline_set_nsmap(CBMHashTable *map) { + g_nsmap = map; +} + bool cbm_pipeline_had_format_migration(const cbm_pipeline_t *p) { return p && p->format_migration; } @@ -2349,6 +2361,8 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p) { cleanup: cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); + cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap()); + cbm_pipeline_set_nsmap(NULL); cbm_discover_free(files, file_count); cbm_pipeline_free_semantic_manifest(baseline_manifest, baseline_count); cbm_gbuf_free(p->gbuf); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 45ef74f84..81afe6b9a 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -301,11 +301,16 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b * strategy are all kept. `indexed_packages` is the pipeline package map: a * specifier naming a package the tree itself declares (a workspace sibling) * counts as in-tree and is kept too; NULL disables that check. + * `declared_packages` is the pipeline namespace map and does the same job for + * package-path specifiers, which have no in-tree/external shape of their own: + * `import org.example.util.assertThing` is kept when some indexed file + * declares `package org.example.util`; NULL disables that check. * Pure; unit-tested in test_registry.c. */ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy, const CBMImportArray *file_imports, const char **import_map_keys, int import_map_count, - const CBMHashTable *indexed_packages); + const CBMHashTable *indexed_packages, + const CBMHashTable *declared_packages); /* #725: drop a suffix_match CALLS edge when the caller language and the * target file's language disagree. unique_name (candidates == 1) is #1572 diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index bf71a1f84..6f147b25a 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1612,6 +1612,8 @@ static int closure_probe_surfaces(cbm_pipeline_t *p, const char *project, * leaked one map per probed run (LSan, macOS CI). */ cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); + cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap()); + cbm_pipeline_set_nsmap(NULL); cbm_path_alias_collection_free(aliases); cbm_gbuf_free(probe_gbuf); return rc; @@ -2183,6 +2185,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char } cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); + cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap()); + cbm_pipeline_set_nsmap(NULL); if (phase_rc != 0) { cbm_log_error("delta.err", "phase", "extract_resolve", "rc", itoa_buf(phase_rc)); goto out; @@ -2762,6 +2766,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil * full pipeline's ownership boundary on both success and failure. */ cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); + cbm_pipeline_namespace_map_free(cbm_pipeline_get_nsmap()); + cbm_pipeline_set_nsmap(NULL); if (phase_rc != 0) { cbm_log_error("incremental.err", "phase", "extract_resolve", "rc", itoa_buf(phase_rc)); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 45b0baf6b..a8e0d5616 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -172,6 +172,16 @@ static inline int cbm_pipeline_relpath_is_excluded(const char *rel_path, char *c CBMHashTable *cbm_pipeline_get_pkgmap(void); void cbm_pipeline_set_pkgmap(CBMHashTable *map); +/* Get the current pipeline's declared-package map (NULL if none): the + * namespace/package → File-QN map built by cbm_pipeline_namespace_map_build + * from every indexed file's own `package`/`namespace`/`using` declaration. + * The import passes build it to resolve namespace imports; it is published + * here so the call passes can ask whether a package-path specifier names a + * package the tree itself declares (#1355). Set before resolve starts, read + * only afterwards — same single-writer contract as the pkgmap above. */ +CBMHashTable *cbm_pipeline_get_nsmap(void); +void cbm_pipeline_set_nsmap(CBMHashTable *map); + /* Unified module resolver: relative → pkgmap → fqn_module fallback. * Handles bare specifiers via pkgmap lookup with prefix matching. * Caller must free() the returned string. */ diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index adbe2c713..f16c0ddb8 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -565,6 +565,84 @@ static bool specifier_names_indexed_package(const CBMHashTable *indexed_packages return false; } +/* True when the specifier is a package PATH the indexed tree itself DECLARES. + * `declared_packages` is the pipeline's namespace map, keyed by the + * dot-normalized `package` / `namespace` / `use` declaration of every indexed + * file (cbm_pipeline_namespace_map_build), so this is the JVM/CLR/PHP + * counterpart of the manifest-name test above: those languages have no + * specifier shape that separates in-tree from third-party, and their manifests + * (pom.xml, build.gradle) name artifacts, not packages. + * + * A package-path specifier names a member inside a package, so the trailing + * segment is walked off one at a time, exactly like the namespace-map lookup in + * cbm_pipeline_resolve_import_node: `org.jetbrains.exposed.v1.tests.shared + * .assertEquals` -> `org.jetbrains.exposed.v1.tests.shared`, which Exposed's + * own Assert.kt declares, so the call keeps its edge. `java.net.URL` walks to + * `java.net` then `java`, which no project file declares, so it stays external + * and the guard still cuts its bind to an in-tree `URL` class. + * + * Separator normalization mirrors the map builder's: PHP writes `\`, some + * grammars `::` or `/`. NULL disables the check. */ +static bool specifier_names_declared_package(const CBMHashTable *declared_packages, + const char *module_path) { + if (!declared_packages || !module_path || !module_path[0]) { + return false; + } + char buf[CBM_SZ_512]; + if (strlen(module_path) >= sizeof(buf)) { + return false; + } + snprintf(buf, sizeof(buf), "%s", module_path); + char *brace = strchr(buf, '{'); + if (brace) { + *brace = '\0'; /* grouped import — the prefix is the package */ + } + char *alias = strstr(buf, " as "); + if (alias) { + *alias = '\0'; + } + for (char *p = buf; *p; p++) { + if (*p == '\\' || *p == ':' || *p == '/') { + *p = '.'; + } + } + /* Collapse the empty segments "::" just produced, then strip glob/ + * separator noise off the tail. */ + for (;;) { + char *dd = strstr(buf, ".."); + if (!dd) { + break; + } + memmove(dd, dd + 1, strlen(dd + 1) + 1); + } + size_t len = strlen(buf); + while (len > 0 && (buf[len - 1] == '*' || buf[len - 1] == '.' || buf[len - 1] == ' ')) { + buf[--len] = '\0'; + } + if (buf[0] == '\0') { + return false; + } + if (cbm_ht_has(declared_packages, buf)) { + return true; + } + /* The walk has no floor, and that is a deliberate false-NEGATIVE: once the + * tree declares `com.example`, EVERY specifier under `com.example.**` reads + * as in-tree, including a third-party library that happens to publish under + * a declared prefix. Such a call keeps the same-name fallback it has on + * main, so the cost is a fabricated edge this guard could have removed — + * never a real edge lost. Stopping earlier would need a rule for where a + * package path ends and a type begins, and getting that wrong fails the + * other way. Pinned by external_import_shadow_declared_prefix_is_permissive + * in test_registry.c. */ + for (char *dot = strrchr(buf, '.'); dot != NULL; dot = strrchr(buf, '.')) { + *dot = '\0'; + if (cbm_ht_has(declared_packages, buf)) { + return true; + } + } + return false; +} + static bool import_map_binds(const char **import_map_keys, int import_map_count, const char *local_name) { if (!import_map_keys || import_map_count <= 0) { @@ -612,11 +690,21 @@ static bool import_map_binds(const char **import_map_keys, int import_map_count, * specifier the tree's own manifests claim is treated exactly like a relative * one. NULL disables the check and restores the specifier-shape-only contract. * + * `declared_packages` closes the same hole for the package-path languages, + * where NO specifier shape says in-tree: `import + * org.jetbrains.exposed.v1.tests.shared.assertEquals` is spelled exactly like + * `import kotlin.test.assertEquals`, and only the tree's own `package` + * declarations tell them apart. It is permissive by construction — any + * specifier UNDER a declared package prefix counts as in-tree, see the note on + * the walk — so it can leave a fabricated edge in place, never remove a real + * one. NULL disables the check. + * * Pure + side-effect-free so the contract is unit-testable without a pipeline. */ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *strategy, const CBMImportArray *file_imports, const char **import_map_keys, int import_map_count, - const CBMHashTable *indexed_packages) { + const CBMHashTable *indexed_packages, + const CBMHashTable *declared_packages) { if (!callee_name || !callee_name[0] || !strategy || !strategy[0]) { return false; } @@ -640,7 +728,8 @@ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *st continue; } if (specifier_is_relative(imp->module_path) || - specifier_names_indexed_package(indexed_packages, imp->module_path)) { + specifier_names_indexed_package(indexed_packages, imp->module_path) || + specifier_names_declared_package(declared_packages, imp->module_path)) { return false; /* an in-tree binding for this name — never suppress */ } external_binding = true; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index f772acc69..36bb243a3 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5222,6 +5222,132 @@ TEST(pipeline_external_import_shadow_keeps_workspace_sibling_issue1355) { PASS(); } +/* #1355: the same tree once more, in a package-path language. A Kotlin/Java + * specifier is a dotted package path whether the package is the project's own + * or a dependency, so specifier shape decides nothing and the package map only + * knows manifest artifact names. The tree's own `package` declarations are the + * evidence, and the pipeline already collects them (the namespace map the + * import passes build). `assertEquals` is imported twice — from the project's + * package and from kotlin.test — exactly as JetBrains/Exposed writes it, which + * is what leaves the import map without a key and hands the call to the + * project-wide guess. */ +static void write_declared_package_shadow_fixture(const char *dir, int pad_files) { + /* One directory level only — write_temp_file's mkdir does not recurse, and + * a deeper path fails silently. The package path lives in the `package` + * declaration, not in the directory layout, which is the whole point. */ + write_temp_file(dir, "util/Assert.kt", + "package com.example.util\n" + "\n" + "fun pkgEq(a: String, b: String): Boolean {\n" + " return a == b\n" + "}\n"); + /* The lone project symbol named pkgSpec, in a third package so the guess + * that reaches it is a project-wide one and not a same-module hit. */ + write_temp_file(dir, "spec/SpecHelpers.kt", + "package com.example.spec\n" + "\n" + "fun pkgSpec(label: String): Boolean {\n" + " return label.isNotEmpty()\n" + "}\n"); + write_temp_file(dir, "app/Query.kt", + "package com.example.app\n" + "\n" + "import com.example.util.pkgEq\n" + "import kotlin.test.pkgEq\n" + "import kotlin.test.pkgSpec\n" + "import org.junit.pkgSpec\n" + "\n" + "fun pkgBuild(id: String): Boolean {\n" + " return pkgEq(id, id) && pkgSpec(id)\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "app/Pad%02d.kt", i); + snprintf(body, sizeof(body), + "package com.example.app\n\nfun pkgPad%02d(): Int {\n return %d\n}\n", i, i); + write_temp_file(dir, name, body); + } +} + +static int assert_declared_package_shadow_contract(const char *dir, const char *db_name) { + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", dir, db_name); + cbm_pipeline_t *p = cbm_pipeline_new(dir, db_path, CBM_MODE_FULL); + if (!p) { + return 1; + } + if (cbm_pipeline_run(p) != 0) { + cbm_pipeline_free(p); + return 2; + } + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + cbm_pipeline_free(p); + return 3; + } + int rc = 0; + /* The package the tree declares: `com.example.util` is in-project, so the + * call keeps its edge. RED without the declared-package check — this is the + * 426-edge class measured on JetBrains/Exposed. + * + * The matching negative — an undeclared package is still cut — is asserted + * where it can be reached on BOTH resolvers: directly on the predicate in + * test_registry.c (external_import_shadow_declared_package_kept, including + * `java.net.URL`), and end-to-end in the route fixture below. The + * sequential resolver builds its own import map straight from the + * extraction records, without the ambiguity check its parallel twin + * applies, so a doubly-imported Kotlin name resolves by `import_map` there + * and never reaches this guard at all — asserting the negative here would + * assert that quirk, not this contract. */ + if (!cross_file_call_exists(s, project, "pkgBuild", "pkgEq")) { + rc = 4; + } + cbm_store_close(s); + cbm_pipeline_free(p); + return rc; +} + +TEST(pipeline_external_import_shadow_keeps_declared_package_issue1355) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_pkg_import_shadow_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_declared_package_shadow_fixture(tmp, EXTERNAL_IMPORT_SHADOW_PARALLEL_PAD); + + 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; + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + int sequential = assert_declared_package_shadow_contract(tmp, "pkg-sequential.db"); + + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + int parallel = assert_declared_package_shadow_contract(tmp, "pkg-parallel.db"); + + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + th_rmtree(tmp); + + ASSERT_EQ(sequential, 0); + ASSERT_EQ(parallel, 0); + PASS(); +} + TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX"); @@ -13572,6 +13698,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); RUN_TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355); RUN_TEST(pipeline_external_import_shadow_keeps_workspace_sibling_issue1355); + RUN_TEST(pipeline_external_import_shadow_keeps_declared_package_issue1355); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); diff --git a/tests/test_registry.c b/tests/test_registry.c index d8494ce82..d12048d83 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -986,14 +986,19 @@ TEST(external_import_shadow_drops_package_bound_bare_call) { /* Only the relative import materialized an IMPORTS edge. */ const char *keys[] = {"users"}; - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, keys, 1, NULL)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, keys, 1, NULL)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "suffix_match", &arr, keys, 1, NULL)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "field_type_hint", &arr, keys, 1, NULL)); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "fuzzy", &arr, keys, 1, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("eq", "unique_name", &arr, keys, 1, NULL, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("sql", "unique_name", &arr, keys, 1, NULL, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("eq", "suffix_match", &arr, keys, 1, NULL, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("eq", "field_type_hint", &arr, keys, 1, NULL, NULL)); + ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "fuzzy", &arr, keys, 1, NULL, NULL)); /* A file whose imports all failed to materialize is still covered: the * evidence is the import statement, not the map. */ - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL, NULL)); PASS(); } @@ -1011,42 +1016,47 @@ TEST(external_import_shadow_keeps_everything_else) { const int nkeys = 2; /* Import-/receiver-/module-aware strategies are never this guard's business. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map", &arr, keys, nkeys, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq", "import_map_suffix", &arr, keys, nkeys, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "same_module", &arr, keys, nkeys, NULL)); + cbm_suppress_external_import_shadow("eq", "import_map", &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "import_map_suffix", &arr, keys, nkeys, + NULL, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq", "qualified_suffix", &arr, keys, nkeys, NULL)); + cbm_suppress_external_import_shadow("eq", "same_module", &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "qualified_suffix", &arr, keys, nkeys, + NULL, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq", "lsp_ts_import", &arr, keys, nkeys, NULL)); + cbm_suppress_external_import_shadow("eq", "lsp_ts_import", &arr, keys, nkeys, NULL, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq", "lsp_ts_method", &arr, keys, nkeys, NULL)); - ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq", "service_pattern", &arr, keys, nkeys, NULL)); + cbm_suppress_external_import_shadow("eq", "lsp_ts_method", &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "service_pattern", &arr, keys, nkeys, + NULL, NULL)); /* A relative specifier names a path inside the tree — a missing IMPORTS edge * there is an in-project gap, not an external binding. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, keys, nkeys, + NULL, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, keys, nkeys, NULL)); - ASSERT_FALSE( - cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0, NULL)); + cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0, NULL, NULL)); /* A package the import map DOES bind resolved in-graph (workspace package). */ ASSERT_FALSE(cbm_suppress_external_import_shadow("getRootContainer", "unique_name", &arr, keys, - nkeys, NULL)); + nkeys, NULL, NULL)); /* A name the file never imports (called with no import at all). */ - ASSERT_FALSE( - cbm_suppress_external_import_shadow("helper", "unique_name", &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("helper", "unique_name", &arr, keys, nkeys, + NULL, NULL)); /* Member and package/namespace-qualified callees belong to the * receiver-aware guards, not to this one. */ - ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq.apply", "unique_name", &arr, keys, nkeys, NULL)); - ASSERT_FALSE( - cbm_suppress_external_import_shadow("eq::apply", "unique_name", &arr, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq.apply", "unique_name", &arr, keys, nkeys, + NULL, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq::apply", "unique_name", &arr, keys, nkeys, + NULL, NULL)); /* Empty / absent inputs. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", NULL, &arr, keys, nkeys, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "", &arr, keys, nkeys, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow(NULL, "unique_name", &arr, keys, nkeys, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("", "unique_name", &arr, keys, nkeys, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", NULL, keys, nkeys, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", NULL, &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "", &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow(NULL, "unique_name", &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("", "unique_name", &arr, keys, nkeys, NULL, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("eq", "unique_name", NULL, keys, nkeys, NULL, NULL)); PASS(); } @@ -1065,8 +1075,8 @@ TEST(external_import_shadow_relative_binding_wins_the_tie) { }; CBMImportArray a = {.items = pkg_first, .count = 2, .cap = 2}; CBMImportArray b = {.items = rel_first, .count = 2, .cap = 2}; - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &a, NULL, 0, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &b, NULL, 0, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &a, NULL, 0, NULL, NULL)); + ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &b, NULL, 0, NULL, NULL)); PASS(); } @@ -1089,10 +1099,12 @@ TEST(external_import_shadow_windows_relative_specifier_kept) { * keep the edge because it is in-tree, not external. RED before the fix * (specifier_is_relative saw '.' / '/' only, so all three were classified * external and suppressed); GREEN after. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL)); - ASSERT_FALSE(cbm_suppress_external_import_shadow("sql", "unique_name", &arr, NULL, 0, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0, NULL)); + cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("sql", "unique_name", &arr, NULL, 0, NULL, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("normalize", "unique_name", &arr, NULL, 0, NULL, NULL)); /* A bare package specifier without any drive letter or leading separator * (e.g. "drizzle-orm") must still be external — this predicate must not * become "anything with a colon or backslash". */ @@ -1100,7 +1112,8 @@ TEST(external_import_shadow_windows_relative_specifier_kept) { {.local_name = "eq", .module_path = "drizzle-orm"}, }; CBMImportArray parr = {.items = pkg_only, .count = 1, .cap = 1}; - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &parr, NULL, 0, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("eq", "unique_name", &parr, NULL, 0, NULL, NULL)); PASS(); } @@ -1127,33 +1140,140 @@ TEST(external_import_shadow_workspace_sibling_kept) { /* Exact name, subpath, and scoped subpath all resolve to an in-tree * package — the same-name fallback keeps its chance. */ - ASSERT_FALSE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, pkgs)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("pgTable", "suffix_match", &arr, NULL, 0, pkgs)); + cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, pkgs, NULL)); ASSERT_FALSE( - cbm_suppress_external_import_shadow("hoisted", "unique_name", &arr, NULL, 0, pkgs)); + cbm_suppress_external_import_shadow("pgTable", "suffix_match", &arr, NULL, 0, pkgs, NULL)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("hoisted", "unique_name", &arr, NULL, 0, pkgs, NULL)); /* A genuine third-party dependency is untouched by the new check: the tree * declares no package called "vitest", so the #1355 removal still happens. */ - ASSERT_TRUE(cbm_suppress_external_import_shadow("test", "suffix_match", &arr, NULL, 0, pkgs)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("test", "suffix_match", &arr, NULL, 0, pkgs, NULL)); /* A tree with no manifests at all (pkgmap NULL) keeps the pre-existing * specifier-shape-only contract — every one of these is external again. */ - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL)); ASSERT_TRUE( - cbm_suppress_external_import_shadow("pgTable", "suffix_match", &arr, NULL, 0, NULL)); + cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, NULL, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("pgTable", "suffix_match", &arr, NULL, 0, NULL, NULL)); /* An unrelated package map must not accidentally match by prefix: "drizzle" * is not "drizzle-orm", and the walk is by path segment, not by substring. */ CBMHashTable *other = cbm_ht_create(8); ASSERT_NOT_NULL(other); cbm_ht_set(other, "drizzle", (void *)"qn"); - ASSERT_TRUE(cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, other)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("eq", "unique_name", &arr, NULL, 0, other, NULL)); cbm_ht_free(other); cbm_ht_free(pkgs); PASS(); } +TEST(external_import_shadow_declared_package_kept) { + /* #1355: package-path specifiers (Java, Kotlin, C#, PHP) carry NO shape + * that separates in-tree from third-party — `import + * org.jetbrains.exposed.v1.tests.shared.assertEquals` is spelled exactly + * like `import kotlin.test.assertEquals`, and neither is "relative". Only + * the tree's own `package` declarations tell them apart; the manifests the + * package map reads (pom.xml, build.gradle) name artifacts, not packages. + * Measured on JetBrains/Exposed at 0e4d81a4: without this check the guard + * removed 426 CALLS edges, every one of them a real in-project call into + * the repository's own Assert.kt. */ + CBMImport imports[] = { + {.local_name = "assertEquals", .module_path = "org.example.tests.shared.assertEquals"}, + {.local_name = "assertEquals", .module_path = "kotlin.test.assertEquals"}, + {.local_name = "Helper", .module_path = "org.example.util.Helper"}, + {.local_name = "URL", .module_path = "java.net.URL"}, + {.local_name = "Logger", .module_path = "App\\Support\\Logger"}, + }; + CBMImportArray arr = {.items = imports, .count = 5, .cap = 5}; + + CBMHashTable *nspkgs = cbm_ht_create(8); + ASSERT_NOT_NULL(nspkgs); + cbm_ht_set(nspkgs, "org.example.tests.shared", (void *)"qn"); + cbm_ht_set(nspkgs, "org.example.util", (void *)"qn"); + cbm_ht_set(nspkgs, "App.Support", (void *)"qn"); + + /* The specifier names a member of a package the tree declares, so the walk + * strips the member segment and finds it — the call keeps its edge. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("assertEquals", "suffix_match", &arr, NULL, 0, + NULL, nspkgs)); + ASSERT_FALSE( + cbm_suppress_external_import_shadow("Helper", "unique_name", &arr, NULL, 0, NULL, nspkgs)); + /* PHP writes its separator as a backslash; the key normalization has to + * match the namespace map's, which stores dots. */ + ASSERT_FALSE( + cbm_suppress_external_import_shadow("Logger", "unique_name", &arr, NULL, 0, NULL, nspkgs)); + /* Negative space, and the effect the census called valuable: `java.net.URL` + * walks to "java.net" then "java", neither declared by any project file, so + * a bare `URL` bound to an in-tree URL class is still cut. */ + ASSERT_TRUE( + cbm_suppress_external_import_shadow("URL", "suffix_match", &arr, NULL, 0, NULL, nspkgs)); + /* NULL restores the previous contract: without the declared-package set + * every one of these reads as external again. */ + ASSERT_TRUE(cbm_suppress_external_import_shadow("assertEquals", "suffix_match", &arr, NULL, 0, + NULL, NULL)); + ASSERT_TRUE( + cbm_suppress_external_import_shadow("Helper", "unique_name", &arr, NULL, 0, NULL, NULL)); + + /* The walk is by dotted segment, not by substring: a tree that declares + * "org.example.tests" must not claim "org.example.testsuite.helper". */ + CBMImport near[] = { + {.local_name = "near", .module_path = "org.example.testsuite.helper.near"}, + }; + CBMImportArray narr = {.items = near, .count = 1, .cap = 1}; + CBMHashTable *prefix_only = cbm_ht_create(8); + ASSERT_NOT_NULL(prefix_only); + cbm_ht_set(prefix_only, "org.example.tests", (void *)"qn"); + ASSERT_TRUE(cbm_suppress_external_import_shadow("near", "unique_name", &narr, NULL, 0, NULL, + prefix_only)); + + cbm_ht_free(prefix_only); + cbm_ht_free(nspkgs); + PASS(); +} + +TEST(external_import_shadow_declared_prefix_is_permissive) { + /* #1355, known limitation pinned deliberately. The declared-package walk + * has no floor: once the tree declares `com.example`, a third-party + * library publishing under `com.example.vendor` reads as in-tree and the + * guard stands down. The call then keeps exactly the edge it has on main, + * so this direction costs a fabricated edge the guard could have removed — + * it never removes a real one, which is the side a call-resolution + * suppressor has to fail on. + * + * This test exists so the behaviour cannot change silently: tightening the + * walk (stopping at the first type-shaped segment, say) must flip these + * two assertions, not slip through unnoticed. */ + CBMImport imports[] = { + {.local_name = "vendorCall", .module_path = "com.example.vendor.pkg.vendorCall"}, + }; + CBMImportArray arr = {.items = imports, .count = 1, .cap = 1}; + + CBMHashTable *declares_prefix = cbm_ht_create(8); + ASSERT_NOT_NULL(declares_prefix); + cbm_ht_set(declares_prefix, "com.example", (void *)"qn"); + /* The tree declares only the prefix, yet the whole subtree is treated as + * in-tree — the permissive direction. */ + ASSERT_FALSE(cbm_suppress_external_import_shadow("vendorCall", "unique_name", &arr, NULL, 0, + NULL, declares_prefix)); + + /* Change nothing but the declared package and the guard fires again, which + * is what proves the previous assertion came from the prefix walk and not + * from some other clause standing down. */ + CBMHashTable *declares_other = cbm_ht_create(8); + ASSERT_NOT_NULL(declares_other); + cbm_ht_set(declares_other, "com.other", (void *)"qn"); + ASSERT_TRUE(cbm_suppress_external_import_shadow("vendorCall", "unique_name", &arr, NULL, 0, + NULL, declares_other)); + + cbm_ht_free(declares_other); + cbm_ht_free(declares_prefix); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -1258,4 +1378,6 @@ SUITE(registry) { RUN_TEST(external_import_shadow_relative_binding_wins_the_tie); RUN_TEST(external_import_shadow_windows_relative_specifier_kept); RUN_TEST(external_import_shadow_workspace_sibling_kept); + RUN_TEST(external_import_shadow_declared_package_kept); + RUN_TEST(external_import_shadow_declared_prefix_is_permissive); } From 81820262e61f6e7f11920a5c4596b4297e793cf8 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Sat, 19 Sep 2026 23:46:59 +0200 Subject: [PATCH 11/76] fix(pipeline): run the external-import guard after route classification The #1355 guard dropped the call before the emitters ran, so it did not only remove the fabricated CALLS edge it is meant to remove: it also skipped the route, HTTP, CONFIG and URL classification that happens inside emit_classified_edge / emit_service_edge. A PR that claims to touch nothing but weak same-name CALLS edges was silently costing Route nodes, and with them every HANDLES edge that pass_route_nodes.c later bridges onto those routes. The shape is not hypothetical. A route registration whose router is a dependency is exactly a bare call bound by a package specifier: the resolved QN carries the router library name, emit_classified_edge reads it as CBM_SVC_ROUTE_REG, and the first argument is path-shaped. On a 66-file TypeScript fixture main mints __route__ANY__/orders and the guard removed it, on both resolvers. The fix is the seam the #592/#606 member guard already uses, for the same reason and with the same comment: join the predicate to drop_plain_call and let the emitter decide. Only the plain-CALLS fall-through is suppressed, so the fabricated edge still goes and every service edge stays main-identical. The two resolvers keep identical gates, as the notes in both files require. The new pipeline test asserts both halves on both resolvers: the Route node survives, and a second bare call from the same external package in the same file is still cut, so the first assertion cannot pass by the guard having been disarmed. Signed-off-by: Yyunozor --- src/pipeline/pass_calls.c | 24 ++++---- src/pipeline/pass_parallel.c | 19 +++--- tests/test_pipeline.c | 109 +++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index f0f9b499f..508db7310 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -634,10 +634,22 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * flag is set only for Python — this gate MUST match pass_parallel.c's * exactly, for the same divergence reason noted above. */ bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON; + /* #1355: `import { eq } from "drizzle-orm"` binds `eq` to a package that is + * not in the indexed tree, so a project-wide same-name guess must not turn + * `eq(...)` into a CALLS edge to an unrelated project `eq`. Joined to + * drop_plain_call for the reason spelled out above: dropping the call here + * would also skip route/HTTP/CONFIG classification, and a route + * registration reached through a static import (`import static + * spark.Spark.get` + `get("/x", handler)`) is exactly a bare call bound by + * a package specifier. Suppressing only the plain-CALLS fall-through keeps + * every Route node and service edge main-identical. */ bool drop_plain_call = cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, - call->callee_is_locally_bound, res.strategy); + call->callee_is_locally_bound, res.strategy) || + cbm_suppress_external_import_shadow(call->callee_name, res.strategy, file_imports, imp_keys, + imp_count, cbm_pipeline_get_pkgmap(), + cbm_pipeline_get_nsmap()); /* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g. * `requests.get("/api/orders/{id}")`) resolve to a QN containing the library @@ -669,16 +681,6 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, if (cbm_suppress_cross_language_suffix_match(lang, target_node->file_path, res.strategy)) { return 0; } - /* #1355: `import { eq } from "drizzle-orm"` binds `eq` to a package that is - * not in the indexed tree, so a project-wide same-name guess must not turn - * `eq(...)` into a CALLS edge to an unrelated project `eq`. Placed with the - * #725 guard, after the service-pattern bypasses above, so no HTTP/route - * edge can be lost to it. */ - if (cbm_suppress_external_import_shadow(call->callee_name, res.strategy, file_imports, imp_keys, - imp_count, cbm_pipeline_get_pkgmap(), - cbm_pipeline_get_nsmap())) { - return 0; - } emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals, imp_count, drop_plain_call); return SKIP_ONE; diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index d7aa7bba9..7af3f5c7a 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2499,10 +2499,18 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB /* Bare-call local-binding suppression — see the note in pass_calls.c. * This gate MUST stay identical to the one there. */ bool suppress_weak_local_binding = lang == CBM_LANG_PYTHON; + /* #1355: same guard as pass_calls.c, and joined to drop_plain_call for + * the same reason — a `continue` here would also skip route/HTTP/CONFIG + * classification, and a static-import route registration is a bare call + * bound by a package specifier. Suppress only the plain-CALLS + * fall-through so every Route node stays main-identical. */ bool drop_plain_call = cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, - call->callee_is_locally_bound, res.strategy); + call->callee_is_locally_bound, res.strategy) || + cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports, + imp_keys, imp_count, cbm_pipeline_get_pkgmap(), + cbm_pipeline_get_nsmap()); /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the * service signal lives in the callee_name. The registry can mis-resolve @@ -2573,15 +2581,6 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * CALLS edge across a language boundary. */ continue; } - if (target_node && source_node->id != target_node->id && - cbm_suppress_external_import_shadow(call->callee_name, res.strategy, &result->imports, - imp_keys, imp_count, cbm_pipeline_get_pkgmap(), - cbm_pipeline_get_nsmap())) { - /* #1355: same guard as pass_calls.c — a bare call bound by an - * external package import must not become a CALLS edge to an - * unrelated project symbol of the same name. */ - continue; - } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 36bb243a3..fa6e16fb3 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5348,6 +5348,114 @@ TEST(pipeline_external_import_shadow_keeps_declared_package_issue1355) { PASS(); } +/* #1355: the guard must suppress ONLY the plain-CALLS fall-through, never the + * route/HTTP/CONFIG classification that runs inside the emitters. A bare call + * bound by a package import is exactly the shape a route registration takes + * when the router is a dependency, and dropping it before classification costs + * the Route node and every HANDLES edge downstream of it. Same reasoning, and + * the same drop_plain_call seam, as the #592/#606 member guard. */ +static void write_route_shadow_fixture(const char *dir, int pad_files) { + /* The project symbols the guess would bind to. The file name puts "express" + * in their qualified names, which is what makes the resolved QN classify as + * a route registration — the same substring match main uses on real trees. */ + write_temp_file(dir, "app/express-routes.ts", + "export function regGet(path: string, handler: unknown): string {\n" + " return path;\n" + "}\n" + "export function regSpec(label: string): string {\n" + " return label;\n" + "}\n"); + /* Both names come from a package outside the tree. `regGet` carries a + * path-shaped first argument, so it is a route registration; `regSpec` is + * an ordinary fabricated edge and must still go. */ + write_temp_file(dir, "app/server.ts", + "import { regGet, regSpec } from 'vendor-router';\n" + "export function boot(): string {\n" + " return regGet('/orders', () => 'ok') + regSpec('case');\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "app/pad_%02d.ts", i); + snprintf(body, sizeof(body), "export function routePad%02d(): number { return %d; }\n", i, + i); + write_temp_file(dir, name, body); + } +} + +static int assert_route_shadow_contract(const char *dir, const char *db_name) { + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s", dir, db_name); + cbm_pipeline_t *p = cbm_pipeline_new(dir, db_path, CBM_MODE_FULL); + if (!p) { + return 1; + } + if (cbm_pipeline_run(p) != 0) { + cbm_pipeline_free(p); + return 2; + } + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + if (!s) { + cbm_pipeline_free(p); + return 3; + } + int rc = 0; + /* (1) the Route node survives the guard. RED when the guard returns before + * the emitter: main mints __route__ANY__/orders here and the published + * guard lost it, together with every HANDLES edge that hangs off it. */ + if (count_nodes_named(s, project, "/orders") < 1) { + rc = 4; + } + /* (2) the fabricated plain-CALLS edge in the same file is still removed, so + * (1) cannot pass by the guard having been disarmed. */ + if (rc == 0 && cross_file_call_exists(s, project, "boot", "regSpec")) { + rc = 5; + } + cbm_store_close(s); + cbm_pipeline_free(p); + return rc; +} + +TEST(pipeline_external_import_shadow_keeps_route_registration_issue1355) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_route_import_shadow_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_route_shadow_fixture(tmp, EXTERNAL_IMPORT_SHADOW_PARALLEL_PAD); + + 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; + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + int sequential = assert_route_shadow_contract(tmp, "route-sequential.db"); + + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + int parallel = assert_route_shadow_contract(tmp, "route-parallel.db"); + + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + th_rmtree(tmp); + + ASSERT_EQ(sequential, 0); + ASSERT_EQ(parallel, 0); + PASS(); +} + TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX"); @@ -13699,6 +13807,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_external_import_shadow_not_bound_to_local_homonym_issue1355); RUN_TEST(pipeline_external_import_shadow_keeps_workspace_sibling_issue1355); RUN_TEST(pipeline_external_import_shadow_keeps_declared_package_issue1355); + RUN_TEST(pipeline_external_import_shadow_keeps_route_registration_issue1355); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); From 2a2be4a2e0e20d8102e3c3eaa0d97250891baf7a Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 2 Sep 2026 07:03:29 +0800 Subject: [PATCH 12/76] fix(ts): resolve inferred imported constructor receivers Signed-off-by: mikemikimike <13286568797@163.com> --- internal/cbm/lsp/ts_lsp.c | 17 +++++++++++++---- tests/repro/repro_lsp_ts.c | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index fc8b8911f..ec0b80e0e 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -2176,10 +2176,19 @@ const CBMType *ts_eval_expr_type(TSLSPContext *ctx, TSNode node) { if (!ts_node_is_null(ctor)) { char *cname = node_text(ctx, ctor); if (cname) { - // Bare class name → qualify against module. - if (strchr(cname, '.') == NULL && ctx->module_qn) { - const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname); - result = cbm_type_named(ctx->arena, qn); + // Resolve lexical/import bindings before falling back to the + // current module. This preserves the imported class QN for + // inferred locals such as `const x = new ImportedClass()`. + if (strchr(cname, '.') == NULL) { + const CBMType *bound = type_of_identifier(ctx, cname); + if (bound && bound->kind == CBM_TYPE_NAMED && + bound->data.named.qualified_name) { + result = bound; + } else if (ctx->module_qn) { + const char *qn = + cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, cname); + result = cbm_type_named(ctx->arena, qn); + } } else { result = cbm_type_named(ctx->arena, cname); } diff --git a/tests/repro/repro_lsp_ts.c b/tests/repro/repro_lsp_ts.c index bedcb33d8..c41bd8291 100644 --- a/tests/repro/repro_lsp_ts.c +++ b/tests/repro/repro_lsp_ts.c @@ -297,6 +297,23 @@ static const RFile kTsImport[] = { "function caller(v: number): number { return helper(v); }\n"}, }; +/* An imported class constructed through an inferred local variable must retain + * the imported class QN for subsequent member dispatch. The explicit type + * annotation path already covers this shape; this fixture isolates inference. + */ +static const RFile kTsInferredImportedMethod[] = { + {"store.ts", + "export class WidgetStore {\n" + " findById(id: string): string { return id; }\n" + "}\n"}, + {"service.ts", + "import { WidgetStore } from \"./store\";\n" + "function load(id: string): string {\n" + " const store = new WidgetStore();\n" + " return store.findById(id);\n" + "}\n"}, +}; + /* lsp_ts_jsx — JSX element whose tag is a module-local component * function (ts_lsp.c:2643-2647). TSX only (jsx_mode); the tag's first letter is * uppercase so it is NOT treated as an intrinsic HTML element; it resolves via @@ -370,6 +387,14 @@ TEST(repro_lsp_ts_import) { "lsp_ts_import"); } +TEST(repro_lsp_ts_inferred_import_receiver) { + return assert_lsp_strategy_files( + kTsInferredImportedMethod, + (int)(sizeof(kTsInferredImportedMethod) / + sizeof(kTsInferredImportedMethod[0])), + "lsp_ts_method"); +} + TEST(repro_lsp_ts_jsx) { return assert_lsp_strategy("app.tsx", kTsxJsx, "lsp_ts_jsx"); } @@ -403,6 +428,7 @@ SUITE(repro_lsp_ts) { RUN_TEST(repro_lsp_ts_method); RUN_TEST(repro_lsp_ts_namespace); RUN_TEST(repro_lsp_ts_import); + RUN_TEST(repro_lsp_ts_inferred_import_receiver); RUN_TEST(repro_lsp_ts_jsx); RUN_TEST(repro_lsp_ts_jsx_import); RUN_TEST(repro_lsp_ts_default); From aa4da14d23dc28df58b9881dc66d89c0d1c0d788 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 2 Sep 2026 08:58:42 +0800 Subject: [PATCH 13/76] test(ts): cover inferred stdlib constructor receivers Signed-off-by: mikemikimike <13286568797@163.com> --- tests/repro/repro_lsp_ts.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/repro/repro_lsp_ts.c b/tests/repro/repro_lsp_ts.c index c41bd8291..8c441af22 100644 --- a/tests/repro/repro_lsp_ts.c +++ b/tests/repro/repro_lsp_ts.c @@ -314,6 +314,14 @@ static const RFile kTsInferredImportedMethod[] = { "}\n"}, }; +/* A standard-library class constructed through an inferred local variable must + * retain the bare stdlib QN so subsequent member dispatch finds Map.get. */ +static const char kTsInferredStdlibMethod[] = + "function lookup(id: string): string {\n" + " const m = new Map();\n" + " return m.get(id);\n" + "}\n"; + /* lsp_ts_jsx — JSX element whose tag is a module-local component * function (ts_lsp.c:2643-2647). TSX only (jsx_mode); the tag's first letter is * uppercase so it is NOT treated as an intrinsic HTML element; it resolves via @@ -395,6 +403,11 @@ TEST(repro_lsp_ts_inferred_import_receiver) { "lsp_ts_method"); } +TEST(repro_lsp_ts_inferred_stdlib_receiver) { + return assert_lsp_strategy("main.ts", kTsInferredStdlibMethod, + "lsp_ts_method"); +} + TEST(repro_lsp_ts_jsx) { return assert_lsp_strategy("app.tsx", kTsxJsx, "lsp_ts_jsx"); } @@ -429,6 +442,7 @@ SUITE(repro_lsp_ts) { RUN_TEST(repro_lsp_ts_namespace); RUN_TEST(repro_lsp_ts_import); RUN_TEST(repro_lsp_ts_inferred_import_receiver); + RUN_TEST(repro_lsp_ts_inferred_stdlib_receiver); RUN_TEST(repro_lsp_ts_jsx); RUN_TEST(repro_lsp_ts_jsx_import); RUN_TEST(repro_lsp_ts_default); From 9e793df87b51f07d3741c55ea6e27367ee1a7b7d Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 21 Sep 2026 11:48:06 -0500 Subject: [PATCH 14/76] fix(daemon): keep held runtime files fresh and exit when one is lost macOS com.apple.tmp_cleaner deletes /tmp files whose atime, mtime and ctime are all older than three days. The daemon's long-held lock files are empty and never written, so under a daemon that lives past three days the cleaner unlinks them while they are held. The next participant then creates and locks a fresh inode: forked index workers see the cohort daemon marker as free, read the live daemon as uncoordinated and exit 1 on every reindex, and a lost IPC lifetime lock or cohort lifetime file stops protecting the generation at all. A lost socket identity marker additionally makes listener close leave the socket pair behind. The host lifetime loop now runs a heartbeat every 60 s over every file the generation holds (cohort daemon claim and lease, participant guard, IPC lifetime reservation, identity marker). Each file is refreshed with futimens through its held descriptor, only after proving the path still names that descriptor, so the cleaner never selects it. If any file is already lost, the daemon logs daemon.lifetime_end reason=coordination_file_lost and stops cleanly; the next client starts a coordinated generation. Re-claiming in place would race that client. Windows needs no refresh (no age-based cleaner; held lock handles deny deletion), so its entry points only validate the handles. Closes #2178 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Shane McCarron --- src/daemon/host.c | 57 ++++++++++++++++++- src/daemon/ipc.c | 57 +++++++++++++++++++ src/daemon/ipc.h | 13 +++++ src/daemon/runtime.c | 4 ++ src/daemon/runtime.h | 4 ++ src/daemon/version_cohort.c | 19 +++++++ src/daemon/version_cohort.h | 9 +++ src/foundation/private_file_lock.c | 32 +++++++++++ src/foundation/private_file_lock_internal.h | 7 +++ tests/test_daemon_ipc.c | 56 ++++++++++++++++-- tests/test_private_file_lock.c | 63 ++++++++++++++++++++- 11 files changed, 314 insertions(+), 7 deletions(-) diff --git a/src/daemon/host.c b/src/daemon/host.c index 3e509e207..856af3ee4 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -52,6 +52,9 @@ enum { HOST_HTTP_RETRY_INITIAL_MS = 1000, HOST_HTTP_RETRY_MAX_MS = 30000, HOST_WATCH_INTERVAL_MS = 5000, + /* Age-based temp cleaners need days of inactivity (macOS tmp_cleaner: 3); + * a minute bounds the blind window after a long sleep (#2178). */ + HOST_COORDINATION_TOUCH_MS = 60000, HOST_CONFLICT_LOG_CAP = 1024 * 1024, HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024, HOST_PATH_CAP = 4096, @@ -868,10 +871,42 @@ static bool host_background_start(host_state_t *host) { return true; } +/* Coordination handles the host retains for the generation's whole lifetime. */ +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_version_cohort_lease_t *cohort_lease; + cbm_version_cohort_daemon_claim_t *daemon_claim; + cbm_daemon_ipc_participant_guard_t *participant_guard; +} host_coordination_t; + +/* Refresh every long-held runtime file and name the first one found lost, or + * return NULL. A held lock whose path now names another inode coordinates + * nothing: forked index workers read the daemon as uncoordinated and a peer + * can start a second generation (#2178). */ +static const char *host_coordination_touch(const host_coordination_t *coordination, + cbm_daemon_runtime_service_t *service) { + if (!cbm_version_cohort_daemon_claim_touch(coordination->daemon_claim)) { + return "cohort_daemon_claim"; + } + if (!cbm_version_cohort_lease_touch(coordination->cohort_lease)) { + return "cohort_lease"; + } + if (!cbm_daemon_ipc_participant_guard_touch(coordination->endpoint, + coordination->participant_guard)) { + return "participant_guard"; + } + if (!cbm_daemon_runtime_service_touch_listener(service)) { + return "listener"; + } + return NULL; +} + static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, - atomic_int *stop_requested, host_state_t *host, bool permanent) { + atomic_int *stop_requested, host_state_t *host, bool permanent, + const host_coordination_t *coordination) { uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS; uint64_t stopping_deadline = 0; + uint64_t next_touch = 0; for (;;) { cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(service); if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { @@ -903,6 +938,17 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, stop ? "stop_requested" : "initial_window_expired"); return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS); } + /* Permanent generations too: exiting lets the next client start a + * coordinated generation, while re-claiming would race that client. */ + if (cbm_now_ms() >= next_touch) { + next_touch = cbm_now_ms() + HOST_COORDINATION_TOUCH_MS; + const char *lost = host_coordination_touch(coordination, service); + if (lost) { + cbm_log_warn("daemon.lifetime_end", "reason", "coordination_file_lost", "file", + lost); + return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS); + } + } host_http_reconcile_at(host, cbm_now_ms(), false); /* Retire an ephemeral generation that lingered for cold-storm cohort * participants once they drain, or once its bounded linger elapses. @@ -1123,7 +1169,14 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { : "unavailable", "memory_budget_bytes", memory_budget, "physical_job_limit", physical_job_limit, "worker_memory_budget_bytes", worker_memory_budget); - if (!host_wait_for_lifetime(service, config->stop_requested, &host, config->permanent)) { + host_coordination_t coordination = { + .endpoint = config->endpoint, + .cohort_lease = cohort_lease, + .daemon_claim = daemon_claim, + .participant_guard = participant_guard, + }; + if (!host_wait_for_lifetime(service, config->stop_requested, &host, config->permanent, + &coordination)) { host_force_terminate("runtime"); } diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index a92befb08..53da43542 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3220,6 +3220,46 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { free(listener); } +/* Touch through the held descriptor only after proving the path still names + * it: refreshing by path would keep a replacement inode fresh and hide the + * loss (#2178). */ +static bool posix_held_file_touch(int directory_fd, const char *base_name, int fd) { + return private_regular_file_snapshot(directory_fd, base_name, fd, 1, NULL) && + futimens(fd, NULL) == 0; +} + +static bool posix_held_lock_touch(int directory_fd, const process_lock_entry_t *entry, int fd) { + return entry && posix_held_file_touch(directory_fd, entry->lock_name, fd); +} + +bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener) { + if (!listener || listener->dir_fd < 0 || listener->owner_pid != getpid() || + !listener->lifetime_reservation) { + return false; + } + bool lifetime = + posix_held_lock_touch(listener->dir_fd, listener->lifetime_reservation->process_entry, + listener->lifetime_reservation->fd); + const cbm_daemon_ipc_participant_guard_t *guard = listener->participant_guard; + bool participant = + !guard || + posix_held_lock_touch(listener->dir_fd, guard->legacy_process_entry, guard->legacy_fd); + /* The marker is not held open; reopen it and require the published inode. */ + int marker_fd = openat(listener->dir_fd, listener->socket_identity_name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + struct stat marker_status; + bool marker = marker_fd >= 0 && + private_regular_file_snapshot(listener->dir_fd, listener->socket_identity_name, + marker_fd, 1, &marker_status) && + marker_status.st_dev == listener->identity_device && + marker_status.st_ino == listener->identity_inode && + futimens(marker_fd, NULL) == 0; + if (marker_fd >= 0) { + (void)close(marker_fd); + } + return lifetime && participant && marker; +} + int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms, cbm_daemon_ipc_connection_t **connection_out) { if (connection_out) { @@ -3603,6 +3643,12 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t return true; } +bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard) { + return endpoint && guard && guard->owner_pid == getpid() && + posix_held_lock_touch(endpoint->dir_fd, guard->legacy_process_entry, guard->legacy_fd); +} + int cbm_daemon_ipc_local_transition_try_acquire( const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) { if (transition_out) { @@ -6746,6 +6792,17 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t return true; } +/* Windows has no age-based cleaner of the private runtime directory, and its + * held handles deny deletion; the #2178 heartbeat has nothing to refresh. */ +bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener) { + return listener != NULL; +} + +bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard) { + return endpoint && guard; +} + int cbm_daemon_ipc_local_transition_try_acquire( const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) { if (transition_out) { diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h index 545ecb357..ed214ea79 100644 --- a/src/daemon/ipc.h +++ b/src/daemon/ipc.h @@ -76,6 +76,15 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( cbm_daemon_ipc_lifetime_reservation_t **reservation_io); void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener); +/* Heartbeat for the daemon's long-lived runtime artifacts: refresh the held + * lifetime reservation, the listener's participant guard (if it owns one) and + * the published identity marker so an age-based temp cleaner never deletes + * them (#2178). Returns false once any of them no longer names the file this + * listener owns: a lost lifetime file lets a peer start a second generation, + * and a lost identity marker makes listener close leave the socket pair + * behind. Only the owning thread may call it while the listener is open. */ +bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener); + /* Create or validate one private current-user directory at an already * canonical local path. Ancestors are handle-validated without mutation: * POSIX permits only root/current-user owners and no group/other write (apart @@ -270,6 +279,10 @@ bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_participant_guard_t **guard_out); bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io); +/* Same heartbeat contract as cbm_daemon_ipc_listener_touch, for a guard the + * caller retains itself. */ +bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard); /* Standalone CLI work joins the legacy-compatible current participant group * without becoming a daemon client. Acquisition retains startup-v2 only for diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index efa41a020..7fb7d32a9 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -2578,6 +2578,10 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_ return count; } +bool cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service) { + return service && service->listener && cbm_daemon_ipc_listener_touch(service->listener); +} + void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service) { if (!service) { return; diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 7a31dab61..304d7c898 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -336,6 +336,10 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_ * an unbounded idle hang. The host lifetime loop calls this every tick; it is a * no-op unless a last-committed-client linger is armed. */ void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service); +/* #2178 heartbeat over the listener's runtime artifacts; see + * cbm_daemon_ipc_listener_touch. Call only from the thread that stops and + * frees the service, which is the only thread that closes the listener. */ +bool cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service); size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service, const char *project_key); uint64_t cbm_daemon_runtime_service_client_process_id(cbm_daemon_runtime_service_t *service, diff --git a/src/daemon/version_cohort.c b/src/daemon/version_cohort.c index 3742a1330..82cc63b32 100644 --- a/src/daemon/version_cohort.c +++ b/src/daemon/version_cohort.c @@ -363,6 +363,25 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( return result; } +static bool version_cohort_lock_touch(cbm_private_file_lock_t *lock) { + return !lock || cbm_private_file_lock_touch(lock) == CBM_PRIVATE_FILE_LOCK_OK; +} + +bool cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease) { + if (!lease) { + return false; + } + /* Evaluate all three so one lost file does not leave the others to age. */ + bool lifetime = version_cohort_lock_touch(lease->lifetime); + bool admission = version_cohort_lock_touch(lease->admission); + bool maintenance = version_cohort_lock_touch(lease->maintenance); + return lifetime && admission && maintenance; +} + +bool cbm_version_cohort_daemon_claim_touch(cbm_version_cohort_daemon_claim_t *claim) { + return claim && claim->marker && version_cohort_lock_touch(claim->marker); +} + static cbm_version_cohort_status_t version_cohort_failed(cbm_version_cohort_lease_t *lease, cbm_version_cohort_status_t status, cbm_version_cohort_lease_t **lease_out) { diff --git a/src/daemon/version_cohort.h b/src/daemon/version_cohort.h index 51304cc95..ac79c8b86 100644 --- a/src/daemon/version_cohort.h +++ b/src/daemon/version_cohort.h @@ -145,6 +145,15 @@ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence_under_tr cbm_private_file_lock_status_t cbm_version_cohort_lease_release( cbm_version_cohort_lease_t **lease_io); +/* Heartbeat for a long-lived holder: refresh every lock file the lease or + * claim holds and report false once any held file was unlinked or replaced. + * An age-based temp cleaner deleting a held file silently ends coordination, + * because the next participant creates and locks a fresh inode (#2178). The + * only safe response to false is an orderly exit; re-claiming would race + * that participant. */ +bool cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease); +bool cbm_version_cohort_daemon_claim_touch(cbm_version_cohort_daemon_claim_t *claim); + /* Refuses teardown while a lease or retryable cleanup handle remains. */ cbm_private_file_lock_status_t cbm_version_cohort_manager_free( cbm_version_cohort_manager_t **manager_io); diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index 80df0d17c..a7d83172e 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -482,6 +482,24 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; } +cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) { + if (!lock) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + /* An unlinked file keeps its inode alive for this handle with st_nlink 0, + * which the payload validity check already rejects. */ + bool linked = private_payload_fd_valid(lock, NULL); + bool touched = linked && futimens(lock->fd, NULL) == 0; + cbm_private_file_lock_fork_guard_leave(); + if (!linked) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + return touched ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; +} + cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io) { if (!lock_io || !*lock_io) { return CBM_PRIVATE_FILE_LOCK_IO; @@ -1450,6 +1468,20 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; } +cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) { + /* Windows has no age-based cleaner of the private lock directory; only + * confirm the held handle still names a linked file. */ + if (!lock) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool linked = private_win_payload_handle_valid(lock); + cbm_private_file_lock_fork_guard_leave(); + return linked ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_UNSAFE; +} + static bool private_win_release_unlock(cbm_private_file_lock_t *lock) { lock->test_unlock_attempts++; if (lock->test_fail_unlock_once) { diff --git a/src/foundation/private_file_lock_internal.h b/src/foundation/private_file_lock_internal.h index 8801d9b1a..b8ec34ac7 100644 --- a/src/foundation/private_file_lock_internal.h +++ b/src/foundation/private_file_lock_internal.h @@ -61,6 +61,13 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f const void *buffer, size_t length); +/* Long-lived holders call this periodically. It refreshes the held file's + * timestamps through the validated handle so age-based temp cleaners (macOS + * tmp_cleaner, systemd-tmpfiles) never select it, and returns UNSAFE once the + * handle no longer names a linked file: a peer opening the path would then + * lock a different inode and the lock coordinates nothing (#2178). */ +cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock); + /* Forces the next successfully acquired native lock down the post-lock * validation cleanup path and injects pre-call release failures there. */ bool cbm_private_lock_directory_fail_post_acquire_cleanup_for_test( diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index b451f9bb3..d29a1e6c0 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -1004,8 +1004,8 @@ TEST(daemon_ipc_windows_sid_trust_accepts_local_admin_rejects_foreign_500) { (void)CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, NULL, &builtin_needed); if (builtin_needed > 0) { builtin_admins = malloc(builtin_needed); - if (builtin_admins && - !CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, builtin_admins, &builtin_needed)) { + if (builtin_admins && !CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, builtin_admins, + &builtin_needed)) { free(builtin_admins); builtin_admins = NULL; } @@ -2063,6 +2063,54 @@ TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen) { PASS(); } +/* #2178: an age-based temp cleaner unlinked the live daemon's lifetime lock + * and identity marker. The heartbeat must report either loss, so the daemon + * exits instead of serving under a lifetime file that coordinates nothing or + * leaving an unremovable socket pair behind at close. */ +TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { +#ifdef _WIN32 + SKIP_PLATFORM("unlink-while-held of runtime files is POSIX behavior"); +#else + static const char key[] = "2178a2178a2178a0"; + static const char *const lost_files[] = {"lifetime.lock", "sock.identity"}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + bool started[2] = {false, false}; + bool touched_intact[2] = {false, false}; + bool unlinked[2] = {false, false}; + bool touched_after_loss[2] = {true, true}; + + if (ipc_test_parent_new(parent, "listener-touch")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + for (size_t i = 0; endpoint && i < 2; i++) { + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); + started[i] = listener != NULL; + touched_intact[i] = started[i] && cbm_daemon_ipc_listener_touch(listener); + char path[TEST_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, lost_files[i]); + unlinked[i] = + touched_intact[i] && written > 0 && written < (int)sizeof(path) && unlink(path) == 0; + touched_after_loss[i] = unlinked[i] && cbm_daemon_ipc_listener_touch(listener); + cbm_daemon_ipc_listener_close(listener); + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + for (size_t i = 0; i < 2; i++) { + ASSERT_TRUE(started[i]); + ASSERT_TRUE(touched_intact[i]); + ASSERT_TRUE(unlinked[i]); + ASSERT_FALSE(touched_after_loss[i]); + } + PASS(); +#endif +} + TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window) { static const char key[] = "1029384756abcdef"; char parent[TEST_PATH_CAP] = {0}; @@ -5260,8 +5308,7 @@ TEST(daemon_ipc_posix_single_uid_userns_real_smoke_issue1830) { if (WEXITSTATUS(status) != 0 && WEXITSTATUS(status) != 1) { char unexpected[128]; (void)snprintf(unexpected, sizeof(unexpected), - "userns probe exited %d -- not a security verdict", - WEXITSTATUS(status)); + "userns probe exited %d -- not a security verdict", WEXITSTATUS(status)); FAIL(unexpected); } ASSERT_EQ(0, WEXITSTATUS(status)); @@ -5376,6 +5423,7 @@ SUITE(daemon_ipc) { RUN_TEST(daemon_ipc_rejects_uppercase_instance_key); RUN_TEST(daemon_ipc_no_spawn_probe_distinguishes_absent_active_and_busy); RUN_TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen); + RUN_TEST(daemon_ipc_listener_touch_detects_lost_runtime_files); RUN_TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window); RUN_TEST(daemon_ipc_local_frame_roundtrip); RUN_TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload); diff --git a/tests/test_private_file_lock.c b/tests/test_private_file_lock.c index 2760bf55b..b795f9176 100644 --- a/tests/test_private_file_lock.c +++ b/tests/test_private_file_lock.c @@ -166,7 +166,8 @@ static void private_lock_fixture_finish(private_lock_fixture_t *fixture) { "lock-attempt-cleanup.lock", "acl-directory.lock", "acl-file.lock", - "fork.lock"}; + "fork.lock", + "touch.lock"}; if (fixture->root[0]) { for (size_t index = 0; index < sizeof(files) / sizeof(files[0]); index++) { if (!private_lock_path(path, fixture, files[index])) { @@ -480,6 +481,65 @@ TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd) { #endif } +/* #2178: macOS tmp_cleaner unlinks /tmp files whose atime, mtime and ctime are + * all older than three days, including empty lock files a daemon holds for its + * whole life. The next participant then creates and locks a fresh inode, so + * the old lock coordinates nothing. Touch must keep a held file young and + * report the loss once the path stops naming it. */ +TEST(private_file_lock_touch_refreshes_held_file_and_detects_unlink) { +#ifdef _WIN32 + SKIP_PLATFORM("age-based temp cleaning and unlink-while-held are POSIX behaviors"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + char path[PRIVATE_LOCK_TEST_PATH_CAP]; + bool path_ok = started && private_lock_path(path, &fixture, "touch.lock"); + cbm_private_file_lock_t *held = NULL; + cbm_private_file_lock_t *successor = NULL; + cbm_private_file_lock_status_t acquired = + path_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "touch.lock", + CBM_PRIVATE_FILE_LOCK_EX, &held) + : CBM_PRIVATE_FILE_LOCK_IO; + const time_t stale = 1000000000; + struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}}; + bool aged = acquired == CBM_PRIVATE_FILE_LOCK_OK && + futimens(cbm_private_file_lock_native_fd_for_test(held), stale_times) == 0; + cbm_private_file_lock_status_t linked_touch = + aged ? cbm_private_file_lock_touch(held) : CBM_PRIVATE_FILE_LOCK_IO; + struct stat refreshed = {0}; + bool refreshed_ok = linked_touch == CBM_PRIVATE_FILE_LOCK_OK && stat(path, &refreshed) == 0; + + bool unlinked = refreshed_ok && unlink(path) == 0; + cbm_private_file_lock_status_t unlinked_touch = + unlinked ? cbm_private_file_lock_touch(held) : CBM_PRIVATE_FILE_LOCK_IO; + /* The hazard itself: with the path gone, a peer's EX succeeds while the + * original holder still believes it is exclusive. */ + cbm_private_file_lock_status_t successor_status = + unlinked ? cbm_private_file_lock_try_acquire(fixture.directory, "touch.lock", + CBM_PRIVATE_FILE_LOCK_EX, &successor) + : CBM_PRIVATE_FILE_LOCK_IO; + if (successor) { + (void)cbm_private_file_lock_release(&successor); + } + if (held) { + (void)cbm_private_file_lock_release(&held); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(aged); + ASSERT_EQ(linked_touch, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(refreshed_ok); + ASSERT_TRUE(refreshed.st_atime > stale); + ASSERT_TRUE(refreshed.st_mtime > stale); + ASSERT_TRUE(unlinked); + ASSERT_EQ(unlinked_touch, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(successor_status, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner) { private_lock_fixture_t fixture; bool started = private_lock_fixture_start(&fixture); @@ -863,6 +923,7 @@ SUITE(private_file_lock) { RUN_TEST(private_file_lock_unlock_failure_retains_retryable_lock); RUN_TEST(private_file_lock_close_failure_retries_without_duplicate_unlock); RUN_TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd); + RUN_TEST(private_file_lock_touch_refreshes_held_file_and_detects_unlink); RUN_TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner); RUN_TEST(private_file_lock_windows_lock_attempt_failure_returns_cleanup_owner); RUN_TEST(private_file_lock_rejects_unsafe_entries_and_replaced_root); From 3cf2a6663f36a6a366806bc78e4cc0678e1f1b55 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 21 Sep 2026 13:02:07 -0500 Subject: [PATCH 15/76] fix(daemon): address QA round 1 - Separate loss from transient failure. Touch helpers now report OK / lost / transient (version_cohort: OK/UNSAFE/IO; ipc: 1/0/-1). Only a proven unlink or replacement stops the daemon. A refresh failure on a still-valid file (EROFS, EPERM, EMFILE on the marker reopen) logs daemon.coordination_touch_failed once, retries every tick, and logs daemon.coordination_touch_recovered when it clears. - Cohort and claim locks now prove the path still names the held inode (private_file_revalidate against the stored directory + name), not just st_nlink, so rename-away, hard-link+unlink and a replaced directory are caught like unlink. - The runtime service's self-owned participant guard is refreshed by the listener heartbeat too. - Tests: rename-away detection; cohort lease/claim touch (lease with only lifetime held is OK, each unlinked file reported independently); IPC listener touch now checks timestamps are actually refreshed and covers the participant guard and identity-marker replacement. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Shane McCarron --- src/daemon/host.c | 71 +++++++++---- src/daemon/ipc.c | 107 ++++++++++++-------- src/daemon/ipc.h | 26 +++-- src/daemon/runtime.c | 6 +- src/daemon/runtime.h | 10 +- src/daemon/version_cohort.c | 40 +++++--- src/daemon/version_cohort.h | 16 +-- src/foundation/private_file_lock.c | 13 ++- src/foundation/private_file_lock_internal.h | 9 +- tests/test_daemon_ipc.c | 59 +++++++---- tests/test_private_file_lock.c | 47 ++++++++- tests/test_version_cohort.c | 54 +++++++++- 12 files changed, 329 insertions(+), 129 deletions(-) diff --git a/src/daemon/host.c b/src/daemon/host.c index 856af3ee4..3dea464cf 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -879,26 +879,41 @@ typedef struct { cbm_daemon_ipc_participant_guard_t *participant_guard; } host_coordination_t; -/* Refresh every long-held runtime file and name the first one found lost, or - * return NULL. A held lock whose path now names another inode coordinates - * nothing: forked index workers read the daemon as uncoordinated and a peer - * can start a second generation (#2178). */ -static const char *host_coordination_touch(const host_coordination_t *coordination, - cbm_daemon_runtime_service_t *service) { - if (!cbm_version_cohort_daemon_claim_touch(coordination->daemon_claim)) { - return "cohort_daemon_claim"; +typedef struct { + const char *lost; /* first held file whose path no longer names it */ + const char *transient; /* first still-valid file that could not be refreshed */ +} host_touch_result_t; + +static void host_touch_note(host_touch_result_t *result, const char *file, int status) { + if (status == 0 && !result->lost) { + result->lost = file; + } else if (status < 0 && !result->transient) { + result->transient = file; } - if (!cbm_version_cohort_lease_touch(coordination->cohort_lease)) { - return "cohort_lease"; - } - if (!cbm_daemon_ipc_participant_guard_touch(coordination->endpoint, - coordination->participant_guard)) { - return "participant_guard"; - } - if (!cbm_daemon_runtime_service_touch_listener(service)) { - return "listener"; - } - return NULL; +} + +static int host_cohort_touch_status(cbm_version_cohort_status_t status) { + return status == CBM_VERSION_COHORT_OK ? 1 : (status == CBM_VERSION_COHORT_IO ? -1 : 0); +} + +/* Refresh every long-held runtime file. A held lock whose path now names + * another inode coordinates nothing: forked index workers read the daemon as + * uncoordinated and a peer can start a second generation (#2178). Every file + * is visited even after one fails so the rest keep being refreshed. */ +static host_touch_result_t host_coordination_touch(const host_coordination_t *coordination, + cbm_daemon_runtime_service_t *service) { + host_touch_result_t result = {0}; + host_touch_note(&result, "cohort_daemon_claim", + host_cohort_touch_status( + cbm_version_cohort_daemon_claim_touch(coordination->daemon_claim))); + host_touch_note( + &result, "cohort_lease", + host_cohort_touch_status(cbm_version_cohort_lease_touch(coordination->cohort_lease))); + host_touch_note(&result, "participant_guard", + cbm_daemon_ipc_participant_guard_touch(coordination->endpoint, + coordination->participant_guard)); + host_touch_note(&result, "listener", cbm_daemon_runtime_service_touch_listener(service)); + return result; } static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, @@ -907,6 +922,7 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS; uint64_t stopping_deadline = 0; uint64_t next_touch = 0; + bool touch_degraded = false; for (;;) { cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(service); if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { @@ -942,12 +958,23 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, * coordinated generation, while re-claiming would race that client. */ if (cbm_now_ms() >= next_touch) { next_touch = cbm_now_ms() + HOST_COORDINATION_TOUCH_MS; - const char *lost = host_coordination_touch(coordination, service); - if (lost) { + host_touch_result_t touched = host_coordination_touch(coordination, service); + if (touched.lost) { cbm_log_warn("daemon.lifetime_end", "reason", "coordination_file_lost", "file", - lost); + touched.lost); return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS); } + /* A refresh failure on a still-valid file (read-only remount, + * EMFILE) is not loss: keep serving, retry next tick, and log + * only transitions so a persistent condition cannot flood. */ + if ((touched.transient != NULL) != touch_degraded) { + touch_degraded = touched.transient != NULL; + if (touch_degraded) { + cbm_log_warn("daemon.coordination_touch_failed", "file", touched.transient); + } else { + cbm_log_info("daemon.coordination_touch_recovered"); + } + } } host_http_reconcile_at(host, cbm_now_ms(), false); /* Retire an ephemeral generation that lingered for cold-storm cohort diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index 53da43542..bedc5884d 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3220,44 +3220,65 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { free(listener); } -/* Touch through the held descriptor only after proving the path still names - * it: refreshing by path would keep a replacement inode fresh and hide the - * loss (#2178). */ -static bool posix_held_file_touch(int directory_fd, const char *base_name, int fd) { - return private_regular_file_snapshot(directory_fd, base_name, fd, 1, NULL) && - futimens(fd, NULL) == 0; +/* Touch results: 1 refreshed, 0 lost (the path no longer names the owned + * file), -1 transient (a still-valid file could not be refreshed). Touch + * through the held descriptor only after proving the path still names it: + * refreshing by path would keep a replacement inode fresh and hide the loss + * (#2178). */ +static int posix_touch_fold(int result, int next) { + return result == 0 || next == 0 ? 0 : (result < 0 || next < 0 ? -1 : 1); } -static bool posix_held_lock_touch(int directory_fd, const process_lock_entry_t *entry, int fd) { - return entry && posix_held_file_touch(directory_fd, entry->lock_name, fd); -} - -bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener) { - if (!listener || listener->dir_fd < 0 || listener->owner_pid != getpid() || - !listener->lifetime_reservation) { - return false; +static int posix_held_file_touch(int directory_fd, const char *base_name, int fd) { + if (!private_regular_file_snapshot(directory_fd, base_name, fd, 1, NULL)) { + return 0; } - bool lifetime = - posix_held_lock_touch(listener->dir_fd, listener->lifetime_reservation->process_entry, - listener->lifetime_reservation->fd); - const cbm_daemon_ipc_participant_guard_t *guard = listener->participant_guard; - bool participant = - !guard || - posix_held_lock_touch(listener->dir_fd, guard->legacy_process_entry, guard->legacy_fd); - /* The marker is not held open; reopen it and require the published inode. */ + return futimens(fd, NULL) == 0 ? 1 : -1; +} + +static int posix_held_lock_touch(int directory_fd, const process_lock_entry_t *entry, int fd) { + return entry ? posix_held_file_touch(directory_fd, entry->lock_name, fd) : 0; +} + +static int posix_identity_marker_touch(const cbm_daemon_ipc_listener_t *listener) { + /* The marker is not held open; reopen it and require the published inode. + * Only ENOENT proves loss: EMFILE and friends say nothing about the file. */ int marker_fd = openat(listener->dir_fd, listener->socket_identity_name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); - struct stat marker_status; - bool marker = marker_fd >= 0 && - private_regular_file_snapshot(listener->dir_fd, listener->socket_identity_name, - marker_fd, 1, &marker_status) && - marker_status.st_dev == listener->identity_device && - marker_status.st_ino == listener->identity_inode && - futimens(marker_fd, NULL) == 0; - if (marker_fd >= 0) { - (void)close(marker_fd); + if (marker_fd < 0) { + return errno == ENOENT ? 0 : -1; } - return lifetime && participant && marker; + struct stat marker_status; + int result = 0; + if (private_regular_file_snapshot(listener->dir_fd, listener->socket_identity_name, marker_fd, + 1, &marker_status) && + marker_status.st_dev == listener->identity_device && + marker_status.st_ino == listener->identity_inode) { + result = futimens(marker_fd, NULL) == 0 ? 1 : -1; + } + (void)close(marker_fd); + return result; +} + +int cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener, + cbm_daemon_ipc_participant_guard_t *external_guard) { + if (!listener || listener->dir_fd < 0 || listener->owner_pid != getpid() || + !listener->lifetime_reservation) { + return 0; + } + int result = + posix_held_lock_touch(listener->dir_fd, listener->lifetime_reservation->process_entry, + listener->lifetime_reservation->fd); + const cbm_daemon_ipc_participant_guard_t *guards[] = {listener->participant_guard, + external_guard}; + for (size_t i = 0; i < sizeof(guards) / sizeof(guards[0]); i++) { + if (guards[i]) { + result = posix_touch_fold(result, posix_held_lock_touch(listener->dir_fd, + guards[i]->legacy_process_entry, + guards[i]->legacy_fd)); + } + } + return posix_touch_fold(result, posix_identity_marker_touch(listener)); } int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms, @@ -3643,10 +3664,12 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t return true; } -bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_participant_guard_t *guard) { - return endpoint && guard && guard->owner_pid == getpid() && - posix_held_lock_touch(endpoint->dir_fd, guard->legacy_process_entry, guard->legacy_fd); +int cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard) { + if (!endpoint || !guard || guard->owner_pid != getpid()) { + return 0; + } + return posix_held_lock_touch(endpoint->dir_fd, guard->legacy_process_entry, guard->legacy_fd); } int cbm_daemon_ipc_local_transition_try_acquire( @@ -6794,13 +6817,15 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t /* Windows has no age-based cleaner of the private runtime directory, and its * held handles deny deletion; the #2178 heartbeat has nothing to refresh. */ -bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener) { - return listener != NULL; +int cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener, + cbm_daemon_ipc_participant_guard_t *external_guard) { + (void)external_guard; + return listener ? 1 : 0; } -bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_participant_guard_t *guard) { - return endpoint && guard; +int cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard) { + return endpoint && guard ? 1 : 0; } int cbm_daemon_ipc_local_transition_try_acquire( diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h index ed214ea79..9a1e7b044 100644 --- a/src/daemon/ipc.h +++ b/src/daemon/ipc.h @@ -77,13 +77,17 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener); /* Heartbeat for the daemon's long-lived runtime artifacts: refresh the held - * lifetime reservation, the listener's participant guard (if it owns one) and - * the published identity marker so an age-based temp cleaner never deletes - * them (#2178). Returns false once any of them no longer names the file this - * listener owns: a lost lifetime file lets a peer start a second generation, - * and a lost identity marker makes listener close leave the socket pair - * behind. Only the owning thread may call it while the listener is open. */ -bool cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener); + * lifetime reservation, the listener's participant guard (if it owns one), + * external_guard (optional, a guard the caller retains beside this listener) + * and the published identity marker so an age-based temp cleaner never + * deletes them (#2178). Returns 1 when all were refreshed, 0 once any no + * longer names the file this listener owns (a lost lifetime file lets a peer + * start a second generation; a lost identity marker makes listener close + * leave the socket pair behind), and -1 when a still-valid file could not be + * refreshed (transient). Only the thread that closes the listener may call it + * while the listener is open. */ +int cbm_daemon_ipc_listener_touch(cbm_daemon_ipc_listener_t *listener, + cbm_daemon_ipc_participant_guard_t *external_guard); /* Create or validate one private current-user directory at an already * canonical local path. Ancestors are handle-validated without mutation: @@ -279,10 +283,10 @@ bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_participant_guard_t **guard_out); bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io); -/* Same heartbeat contract as cbm_daemon_ipc_listener_touch, for a guard the - * caller retains itself. */ -bool cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_participant_guard_t *guard); +/* Same heartbeat contract and 1/0/-1 result as cbm_daemon_ipc_listener_touch, + * for a guard the caller retains itself. */ +int cbm_daemon_ipc_participant_guard_touch(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t *guard); /* Standalone CLI work joins the legacy-compatible current participant group * without becoming a daemon client. Acquisition retains startup-v2 only for diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 7fb7d32a9..bce7118f4 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -2578,8 +2578,10 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_ return count; } -bool cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service) { - return service && service->listener && cbm_daemon_ipc_listener_touch(service->listener); +int cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service) { + return service && service->listener + ? cbm_daemon_ipc_listener_touch(service->listener, service->owned_participant_guard) + : 0; } void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service) { diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 304d7c898..5bcd10a34 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -336,10 +336,12 @@ size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_ * an unbounded idle hang. The host lifetime loop calls this every tick; it is a * no-op unless a last-committed-client linger is armed. */ void cbm_daemon_runtime_service_reconcile_lifetime(cbm_daemon_runtime_service_t *service); -/* #2178 heartbeat over the listener's runtime artifacts; see - * cbm_daemon_ipc_listener_touch. Call only from the thread that stops and - * frees the service, which is the only thread that closes the listener. */ -bool cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service); +/* #2178 heartbeat over the listener's runtime artifacts, plus the participant + * guard the service owns when it started itself; see + * cbm_daemon_ipc_listener_touch for the 1/0/-1 result. Call only from the + * thread that stops and frees the service, which is the only thread that + * closes the listener. */ +int cbm_daemon_runtime_service_touch_listener(cbm_daemon_runtime_service_t *service); size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service, const char *project_key); uint64_t cbm_daemon_runtime_service_client_process_id(cbm_daemon_runtime_service_t *service, diff --git a/src/daemon/version_cohort.c b/src/daemon/version_cohort.c index 82cc63b32..5acaa6d5e 100644 --- a/src/daemon/version_cohort.c +++ b/src/daemon/version_cohort.c @@ -363,23 +363,35 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( return result; } -static bool version_cohort_lock_touch(cbm_private_file_lock_t *lock) { - return !lock || cbm_private_file_lock_touch(lock) == CBM_PRIVATE_FILE_LOCK_OK; -} - -bool cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease) { - if (!lease) { - return false; +/* Folds one lock's touch into an accumulated result: a lost file (UNSAFE) + * outranks a transient refresh failure (IO), which outranks OK. A lease holds + * only lifetime after admission, so absent locks are skipped. */ +static cbm_version_cohort_status_t version_cohort_lock_touch(cbm_private_file_lock_t *lock, + cbm_version_cohort_status_t result) { + if (!lock || result == CBM_VERSION_COHORT_UNSAFE) { + return result; } - /* Evaluate all three so one lost file does not leave the others to age. */ - bool lifetime = version_cohort_lock_touch(lease->lifetime); - bool admission = version_cohort_lock_touch(lease->admission); - bool maintenance = version_cohort_lock_touch(lease->maintenance); - return lifetime && admission && maintenance; + cbm_private_file_lock_status_t status = cbm_private_file_lock_touch(lock); + if (status == CBM_PRIVATE_FILE_LOCK_UNSAFE) { + return CBM_VERSION_COHORT_UNSAFE; + } + return status == CBM_PRIVATE_FILE_LOCK_OK ? result : CBM_VERSION_COHORT_IO; } -bool cbm_version_cohort_daemon_claim_touch(cbm_version_cohort_daemon_claim_t *claim) { - return claim && claim->marker && version_cohort_lock_touch(claim->marker); +cbm_version_cohort_status_t cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease) { + if (!lease || !lease->lifetime) { + return CBM_VERSION_COHORT_UNSAFE; + } + cbm_version_cohort_status_t result = + version_cohort_lock_touch(lease->lifetime, CBM_VERSION_COHORT_OK); + result = version_cohort_lock_touch(lease->admission, result); + return version_cohort_lock_touch(lease->maintenance, result); +} + +cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_touch( + cbm_version_cohort_daemon_claim_t *claim) { + return claim && claim->marker ? version_cohort_lock_touch(claim->marker, CBM_VERSION_COHORT_OK) + : CBM_VERSION_COHORT_UNSAFE; } static cbm_version_cohort_status_t version_cohort_failed(cbm_version_cohort_lease_t *lease, diff --git a/src/daemon/version_cohort.h b/src/daemon/version_cohort.h index ac79c8b86..0a6be9ce9 100644 --- a/src/daemon/version_cohort.h +++ b/src/daemon/version_cohort.h @@ -146,13 +146,15 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( cbm_version_cohort_lease_t **lease_io); /* Heartbeat for a long-lived holder: refresh every lock file the lease or - * claim holds and report false once any held file was unlinked or replaced. - * An age-based temp cleaner deleting a held file silently ends coordination, - * because the next participant creates and locks a fresh inode (#2178). The - * only safe response to false is an orderly exit; re-claiming would race - * that participant. */ -bool cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease); -bool cbm_version_cohort_daemon_claim_touch(cbm_version_cohort_daemon_claim_t *claim); + * claim holds. An age-based temp cleaner deleting a held file silently ends + * coordination, because the next participant creates and locks a fresh inode + * (#2178). Returns OK when all held files were refreshed, UNSAFE once any is + * lost (its path no longer names the held file), and IO when a still-valid + * file could not be refreshed. The only safe response to UNSAFE is an orderly + * exit; re-claiming would race that participant. IO is transient. */ +cbm_version_cohort_status_t cbm_version_cohort_lease_touch(cbm_version_cohort_lease_t *lease); +cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_touch( + cbm_version_cohort_daemon_claim_t *claim); /* Refuses teardown while a lease or retryable cleanup handle remains. */ cbm_private_file_lock_status_t cbm_version_cohort_manager_free( diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index a7d83172e..a5fdf797d 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -38,6 +38,9 @@ struct cbm_private_file_lock { int fd; pid_t owner_pid; cbm_private_file_lock_mode_t mode; + /* Borrowed; the directory outlives every lock acquired through it. */ + const cbm_private_lock_directory_t *directory; + char base_name[NAME_MAX + 1]; struct cbm_private_file_lock *next_tracked; bool unlocked; bool test_fail_unlock_once; @@ -345,6 +348,8 @@ cbm_private_file_lock_status_t cbm_private_file_lock_try_acquire( lock->fd = fd; lock->owner_pid = getpid(); lock->mode = mode; + lock->directory = directory; + memcpy(lock->base_name, base_name, strlen(base_name) + 1); int operation = mode == CBM_PRIVATE_FILE_LOCK_SH ? LOCK_SH : LOCK_EX; if (private_flock_set(fd, operation | LOCK_NB) != 0) { @@ -489,9 +494,11 @@ cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock if (!cbm_private_file_lock_fork_guard_enter()) { return CBM_PRIVATE_FILE_LOCK_IO; } - /* An unlinked file keeps its inode alive for this handle with st_nlink 0, - * which the payload validity check already rejects. */ - bool linked = private_payload_fd_valid(lock, NULL); + /* Unlink leaves st_nlink 0 on the held inode, but a rename-away or a + * replaced directory keeps it linked elsewhere; only the path-vs-handle + * identity check proves the canonical path still names this lock. */ + bool linked = private_payload_fd_valid(lock, NULL) && lock->directory && + private_file_revalidate(lock->directory, lock->base_name, lock->fd, NULL); bool touched = linked && futimens(lock->fd, NULL) == 0; cbm_private_file_lock_fork_guard_leave(); if (!linked) { diff --git a/src/foundation/private_file_lock_internal.h b/src/foundation/private_file_lock_internal.h index b8ec34ac7..d39fe7d5f 100644 --- a/src/foundation/private_file_lock_internal.h +++ b/src/foundation/private_file_lock_internal.h @@ -63,9 +63,12 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f /* Long-lived holders call this periodically. It refreshes the held file's * timestamps through the validated handle so age-based temp cleaners (macOS - * tmp_cleaner, systemd-tmpfiles) never select it, and returns UNSAFE once the - * handle no longer names a linked file: a peer opening the path would then - * lock a different inode and the lock coordinates nothing (#2178). */ + * tmp_cleaner, systemd-tmpfiles) never select it. Returns UNSAFE once the + * lock's path in its directory no longer names the held file (unlinked, + * replaced, renamed away, or the directory itself replaced): a peer opening + * the path would then lock a different inode and the lock coordinates nothing + * (#2178). IO reports a refresh failure on a still-valid lock, which callers + * should treat as transient. The directory must outlive the lock. */ cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock); /* Forces the next successfully acquired native lock down the post-lock diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index d29a1e6c0..8107a0ef5 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -2064,22 +2064,34 @@ TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen) { } /* #2178: an age-based temp cleaner unlinked the live daemon's lifetime lock - * and identity marker. The heartbeat must report either loss, so the daemon - * exits instead of serving under a lifetime file that coordinates nothing or - * leaving an unremovable socket pair behind at close. */ + * and identity marker. The heartbeat must keep every listener-owned file + * young, and report loss (unlink, or replacement by a fresh file at the same + * name) so the daemon exits instead of serving under a lifetime file that + * coordinates nothing or leaving an unremovable socket pair behind at close. */ TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { #ifdef _WIN32 SKIP_PLATFORM("unlink-while-held of runtime files is POSIX behavior"); #else + enum { TOUCH_CASES = 4 }; static const char key[] = "2178a2178a2178a0"; - static const char *const lost_files[] = {"lifetime.lock", "sock.identity"}; + static const struct { + const char *suffix; + bool replace; + } cases[TOUCH_CASES] = { + {"lifetime.lock", false}, /* lifetime reservation */ + {"lock", false}, /* listener-owned participant guard */ + {"sock.identity", false}, /* identity marker, unlinked */ + {"sock.identity", true}, /* identity marker, replaced by a fresh inode */ + }; + const time_t stale = 1000000000; char parent[TEST_PATH_CAP] = {0}; char runtime_dir[TEST_PATH_CAP] = {0}; cbm_daemon_ipc_endpoint_t *endpoint = NULL; - bool started[2] = {false, false}; - bool touched_intact[2] = {false, false}; - bool unlinked[2] = {false, false}; - bool touched_after_loss[2] = {true, true}; + bool started[TOUCH_CASES] = {false}; + int touched_intact[TOUCH_CASES] = {0}; + bool refreshed[TOUCH_CASES] = {false}; + bool lost[TOUCH_CASES] = {false}; + int touched_after_loss[TOUCH_CASES] = {1, 1, 1, 1}; if (ipc_test_parent_new(parent, "listener-touch")) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); @@ -2087,25 +2099,36 @@ TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { if (endpoint) { ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } - for (size_t i = 0; endpoint && i < 2; i++) { + for (size_t i = 0; endpoint && i < TOUCH_CASES; i++) { cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); started[i] = listener != NULL; - touched_intact[i] = started[i] && cbm_daemon_ipc_listener_touch(listener); char path[TEST_PATH_CAP]; - int written = snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, lost_files[i]); - unlinked[i] = - touched_intact[i] && written > 0 && written < (int)sizeof(path) && unlink(path) == 0; - touched_after_loss[i] = unlinked[i] && cbm_daemon_ipc_listener_touch(listener); + int written = + snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, cases[i].suffix); + bool path_ok = started[i] && written > 0 && written < (int)sizeof(path); + struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}}; + bool aged = path_ok && utimensat(AT_FDCWD, path, stale_times, AT_SYMLINK_NOFOLLOW) == 0; + touched_intact[i] = aged ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2; + struct stat after; + refreshed[i] = touched_intact[i] == 1 && stat(path, &after) == 0 && + after.st_atime > stale && after.st_mtime > stale; + lost[i] = refreshed[i] && unlink(path) == 0; + if (lost[i] && cases[i].replace) { + int fd = open(path, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + lost[i] = fd >= 0 && close(fd) == 0; + } + touched_after_loss[i] = lost[i] ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2; cbm_daemon_ipc_listener_close(listener); } cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); - for (size_t i = 0; i < 2; i++) { + for (size_t i = 0; i < TOUCH_CASES; i++) { ASSERT_TRUE(started[i]); - ASSERT_TRUE(touched_intact[i]); - ASSERT_TRUE(unlinked[i]); - ASSERT_FALSE(touched_after_loss[i]); + ASSERT_EQ(touched_intact[i], 1); + ASSERT_TRUE(refreshed[i]); + ASSERT_TRUE(lost[i]); + ASSERT_EQ(touched_after_loss[i], 0); } PASS(); #endif diff --git a/tests/test_private_file_lock.c b/tests/test_private_file_lock.c index b795f9176..988b73e71 100644 --- a/tests/test_private_file_lock.c +++ b/tests/test_private_file_lock.c @@ -167,7 +167,9 @@ static void private_lock_fixture_finish(private_lock_fixture_t *fixture) { "acl-directory.lock", "acl-file.lock", "fork.lock", - "touch.lock"}; + "touch.lock", + "touch-rename.lock", + "touch-renamed.lock"}; if (fixture->root[0]) { for (size_t index = 0; index < sizeof(files) / sizeof(files[0]); index++) { if (!private_lock_path(path, fixture, files[index])) { @@ -540,6 +542,48 @@ TEST(private_file_lock_touch_refreshes_held_file_and_detects_unlink) { #endif } +/* A rename-away keeps the held inode linked (st_nlink stays 1) while the + * canonical path no longer names it, so a peer would create and lock a fresh + * file there. Touch must compare the path with the handle, not just count + * links. */ +TEST(private_file_lock_touch_detects_rename_away) { +#ifdef _WIN32 + SKIP_PLATFORM("renaming a held lock file away is POSIX behavior"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + char path[PRIVATE_LOCK_TEST_PATH_CAP]; + char moved[PRIVATE_LOCK_TEST_PATH_CAP]; + bool paths_ok = started && private_lock_path(path, &fixture, "touch-rename.lock") && + private_lock_path(moved, &fixture, "touch-renamed.lock"); + cbm_private_file_lock_t *held = NULL; + cbm_private_file_lock_status_t acquired = + paths_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "touch-rename.lock", + CBM_PRIVATE_FILE_LOCK_EX, &held) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t intact_touch = acquired == CBM_PRIVATE_FILE_LOCK_OK + ? cbm_private_file_lock_touch(held) + : CBM_PRIVATE_FILE_LOCK_IO; + bool renamed = intact_touch == CBM_PRIVATE_FILE_LOCK_OK && rename(path, moved) == 0; + struct stat moved_status = {0}; + bool still_linked = renamed && stat(moved, &moved_status) == 0 && moved_status.st_nlink == 1; + cbm_private_file_lock_status_t renamed_touch = + renamed ? cbm_private_file_lock_touch(held) : CBM_PRIVATE_FILE_LOCK_IO; + if (held) { + (void)cbm_private_file_lock_release(&held); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(intact_touch, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(renamed); + ASSERT_TRUE(still_linked); + ASSERT_EQ(renamed_touch, CBM_PRIVATE_FILE_LOCK_UNSAFE); + PASS(); +#endif +} + TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner) { private_lock_fixture_t fixture; bool started = private_lock_fixture_start(&fixture); @@ -924,6 +968,7 @@ SUITE(private_file_lock) { RUN_TEST(private_file_lock_close_failure_retries_without_duplicate_unlock); RUN_TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd); RUN_TEST(private_file_lock_touch_refreshes_held_file_and_detects_unlink); + RUN_TEST(private_file_lock_touch_detects_rename_away); RUN_TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner); RUN_TEST(private_file_lock_windows_lock_attempt_failure_returns_cleanup_owner); RUN_TEST(private_file_lock_rejects_unsafe_entries_and_replaced_root); diff --git a/tests/test_version_cohort.c b/tests/test_version_cohort.c index 13e0f8065..f40e05c5e 100644 --- a/tests/test_version_cohort.c +++ b/tests/test_version_cohort.c @@ -330,9 +330,9 @@ TEST(version_cohort_conflict_is_retried_until_the_deadline) { /* An indefinite deadline never waits on a conflicting holder. */ retries_before = cbm_version_cohort_conflict_retries_for_testing(); - ASSERT_EQ(cbm_version_cohort_acquire(second, &requested, UINT64_MAX, &requested_lease, - &conflict), - CBM_VERSION_COHORT_CONFLICT); + ASSERT_EQ( + cbm_version_cohort_acquire(second, &requested, UINT64_MAX, &requested_lease, &conflict), + CBM_VERSION_COHORT_CONFLICT); ASSERT_NULL(requested_lease); ASSERT_EQ(cbm_version_cohort_conflict_retries_for_testing(), retries_before); @@ -669,6 +669,53 @@ TEST(version_cohort_does_not_repurpose_daemon_startup_lock_for_lifetime) { PASS(); } +/* #2178: a live daemon's lease holds only the lifetime file after admission + * (admission and maintenance are released), and its claim holds the marker. + * Touch must report both intact, then name each as lost once an age-based + * temp cleaner unlinks it, independently of the other. */ +TEST(version_cohort_touch_reports_each_lost_file) { +#ifdef _WIN32 + SKIP_PLATFORM("unlinking a held lock file is POSIX behavior"); +#else + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "touch")); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(manager); + cbm_daemon_build_identity_t identity = version_cohort_identity("1.0.0", VERSION_COHORT_BUILD_A); + cbm_daemon_conflict_t conflict; + cbm_version_cohort_lease_t *lease = NULL; + cbm_version_cohort_daemon_claim_t *claim = NULL; + ASSERT_EQ( + cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 5000, &lease, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), CBM_VERSION_COHORT_OK); + + ASSERT_EQ(cbm_version_cohort_lease_touch(lease), CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_touch(claim), CBM_VERSION_COHORT_OK); + + const char *runtime_dir = cbm_daemon_ipc_endpoint_runtime_dir(fixture.endpoint); + char marker[VERSION_COHORT_TEST_PATH_CAP]; + char lifetime[VERSION_COHORT_TEST_PATH_CAP]; + ASSERT_TRUE(snprintf(marker, sizeof(marker), "%s/cbm-version-cohort-daemon-v1.lock", + runtime_dir) < (int)sizeof(marker)); + ASSERT_TRUE(snprintf(lifetime, sizeof(lifetime), "%s/cbm-version-cohort-lifetime-v1.lock", + runtime_dir) < (int)sizeof(lifetime)); + + ASSERT_EQ(unlink(marker), 0); + ASSERT_EQ(cbm_version_cohort_daemon_claim_touch(claim), CBM_VERSION_COHORT_UNSAFE); + ASSERT_EQ(cbm_version_cohort_lease_touch(lease), CBM_VERSION_COHORT_OK); + + ASSERT_EQ(unlink(lifetime), 0); + ASSERT_EQ(cbm_version_cohort_lease_touch(lease), CBM_VERSION_COHORT_UNSAFE); + + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), CBM_PRIVATE_FILE_LOCK_OK); + version_cohort_release(&lease); + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +#endif +} + TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "daemon-marker")); @@ -1034,6 +1081,7 @@ SUITE(version_cohort) { RUN_TEST(version_cohort_mutation_waits_for_every_lifetime_participant); RUN_TEST(version_cohort_mutation_timeout_releases_all_guards); RUN_TEST(version_cohort_does_not_repurpose_daemon_startup_lock_for_lifetime); + RUN_TEST(version_cohort_touch_reports_each_lost_file); RUN_TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting); RUN_TEST(version_cohort_transition_presence_is_authoritative_and_marker_checked); RUN_TEST(version_cohort_transition_shutdown_order_has_no_false_conflict); From 34d172c9038a9b7cee78e42bb7768b93fbb8ed70 Mon Sep 17 00:00:00 2001 From: News Date: Tue, 22 Sep 2026 22:33:51 +0900 Subject: [PATCH 16/76] fix(windows): pair aligned delete with its allocator Signed-off-by: News --- src/foundation/mem_override_win.c | 52 ++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/foundation/mem_override_win.c b/src/foundation/mem_override_win.c index e18b09d11..bb86a2e31 100644 --- a/src/foundation/mem_override_win.c +++ b/src/foundation/mem_override_win.c @@ -56,6 +56,7 @@ void *__real_malloc(size_t size); void *__real_calloc(size_t count, size_t size); void *__real_realloc(void *block, size_t size); void __real_free(void *block); +void __real__aligned_free(void *block); char *__real_strdup(const char *text); size_t __real__msize(void *block); /* Wrappers must call __real_* for anything that is itself wrapped: a plain @@ -155,9 +156,58 @@ void __wrap__aligned_free(void *block) { mi_free(block); return; } - __real_free(block); + /* A CRT aligned block has its own bookkeeping header. Plain free is not + * its matching deallocator. */ + __real__aligned_free(block); } +#if defined(CBM_MEM_GLOBAL_OVERRIDE) && CBM_MEM_GLOBAL_OVERRIDE +/* LLVM-MinGW's static C++ runtime calls _aligned_malloc through the linker's + * wrapper but its aligned operator delete can call the CRT import pointer + * __imp__aligned_free directly. --wrap=_aligned_free does not rewrite that + * import-pointer reference. Provide the six C++ ABI delete entry points so + * every aligned new/delete pair reaches the same owner-aware deallocator. */ +void cbm_cxx_aligned_delete(void *block) __asm__("_ZdlPvSt11align_val_t"); +void cbm_cxx_aligned_delete(void *block) { + __wrap__aligned_free(block); +} + +void cbm_cxx_aligned_delete_nothrow(void *block, + const void *tag) __asm__("_ZdlPvSt11align_val_tRKSt9nothrow_t"); +void cbm_cxx_aligned_delete_nothrow(void *block, const void *tag) { + (void)tag; + __wrap__aligned_free(block); +} + +void cbm_cxx_aligned_delete_sized(void *block, size_t size, + size_t alignment) __asm__("_ZdlPvySt11align_val_t"); +void cbm_cxx_aligned_delete_sized(void *block, size_t size, size_t alignment) { + (void)size; + (void)alignment; + __wrap__aligned_free(block); +} + +void cbm_cxx_aligned_array_delete(void *block) __asm__("_ZdaPvSt11align_val_t"); +void cbm_cxx_aligned_array_delete(void *block) { + __wrap__aligned_free(block); +} + +void cbm_cxx_aligned_array_delete_nothrow(void *block, const void *tag) __asm__( + "_ZdaPvSt11align_val_tRKSt9nothrow_t"); +void cbm_cxx_aligned_array_delete_nothrow(void *block, const void *tag) { + (void)tag; + __wrap__aligned_free(block); +} + +void cbm_cxx_aligned_array_delete_sized(void *block, size_t size, + size_t alignment) __asm__("_ZdaPvySt11align_val_t"); +void cbm_cxx_aligned_array_delete_sized(void *block, size_t size, size_t alignment) { + (void)size; + (void)alignment; + __wrap__aligned_free(block); +} +#endif + void *__wrap_realloc(void *block, size_t size) { if (!block) { void *fresh = mi_malloc(size); From 1133ba324656042f8c73578a788ebf5c77ec8172 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Tue, 22 Sep 2026 11:27:26 -0500 Subject: [PATCH 17/76] fix(daemon): address QA round 2 - Classify touch failures explicitly. A file is lost only on proof that the path no longer names it: ENOENT, st_nlink 0, another inode, a symlink or other non-regular entry, or a replaced lock directory. Any other syscall failure (EIO, ESTALE, ENOMEM, EACCES on reopen) is transient, so a flaky runtime volume no longer stops the daemon. The POSIX private-lock touch and the ipc lock/marker touches share this rule; the Windows touch reports a failed handle query as transient. - The identity marker is classified by path (fstatat, no follow) before it is reopened, so replacement by a symlink is now loss, not a transient reopen failure. - Tests: symlink replacement of the identity marker (lost); an unreadable marker is transient (-1) and recovers (1) once readable; the cohort touch test ages both held files and asserts they were refreshed. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Shane McCarron --- src/daemon/ipc.c | 47 ++++++++++++++++----- src/foundation/private_file_lock.c | 65 ++++++++++++++++++++++++------ tests/test_daemon_ipc.c | 47 +++++++++++++++++---- tests/test_version_cohort.c | 19 +++++++-- 4 files changed, 144 insertions(+), 34 deletions(-) diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index bedc5884d..a6259e1dd 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3229,9 +3229,29 @@ static int posix_touch_fold(int result, int next) { return result == 0 || next == 0 ? 0 : (result < 0 || next < 0 ? -1 : 1); } +/* Whether base_name still names the regular file (device, inode): 1 yes, + * 0 proven not (absent, another inode, a symlink or other non-regular + * entry), -1 undetermined (any other lookup failure, e.g. EIO or ESTALE). */ +static int posix_path_names_inode(int directory_fd, const char *base_name, dev_t device, + ino_t inode) { + struct stat by_path; + if (fstatat(directory_fd, base_name, &by_path, AT_SYMLINK_NOFOLLOW) != 0) { + return errno == ENOENT ? 0 : -1; + } + return S_ISREG(by_path.st_mode) && by_path.st_dev == device && by_path.st_ino == inode ? 1 : 0; +} + static int posix_held_file_touch(int directory_fd, const char *base_name, int fd) { - if (!private_regular_file_snapshot(directory_fd, base_name, fd, 1, NULL)) { - return 0; + struct stat by_handle; + if (fstat(fd, &by_handle) != 0) { + return -1; + } + int identity = + by_handle.st_nlink == 0 + ? 0 + : posix_path_names_inode(directory_fd, base_name, by_handle.st_dev, by_handle.st_ino); + if (identity <= 0) { + return identity; } return futimens(fd, NULL) == 0 ? 1 : -1; } @@ -3241,20 +3261,27 @@ static int posix_held_lock_touch(int directory_fd, const process_lock_entry_t *e } static int posix_identity_marker_touch(const cbm_daemon_ipc_listener_t *listener) { - /* The marker is not held open; reopen it and require the published inode. - * Only ENOENT proves loss: EMFILE and friends say nothing about the file. */ + /* The marker is not held open. Classify by path first, so a symlink or + * other replacement is loss; only then reopen it to refresh, where a + * failure (EMFILE, EACCES) says nothing about the file and is transient. */ + int identity = posix_path_names_inode(listener->dir_fd, listener->socket_identity_name, + listener->identity_device, listener->identity_inode); + if (identity <= 0) { + return identity; + } int marker_fd = openat(listener->dir_fd, listener->socket_identity_name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); if (marker_fd < 0) { return errno == ENOENT ? 0 : -1; } struct stat marker_status; - int result = 0; - if (private_regular_file_snapshot(listener->dir_fd, listener->socket_identity_name, marker_fd, - 1, &marker_status) && - marker_status.st_dev == listener->identity_device && - marker_status.st_ino == listener->identity_inode) { - result = futimens(marker_fd, NULL) == 0 ? 1 : -1; + int result = -1; + if (fstat(marker_fd, &marker_status) == 0) { + /* Replaced between the lookup and the open. */ + result = marker_status.st_dev != listener->identity_device || + marker_status.st_ino != listener->identity_inode + ? 0 + : (futimens(marker_fd, NULL) == 0 ? 1 : -1); } (void)close(marker_fd); return result; diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index a5fdf797d..46111cc1a 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -487,6 +487,39 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; } +/* Whether the lock's canonical path still names the held inode: 1 yes, 0 no + * (proven lost), -1 undetermined. Only proof counts as loss: unlink leaves + * st_nlink 0, a rename-away or replacement makes the path name another inode + * or nothing, and a replaced directory changes the directory's identity. Any + * other syscall failure (EIO, ESTALE, ENOMEM) says nothing about the file and + * is reported as undetermined so the caller retries instead of exiting. */ +static int private_held_identity(const cbm_private_file_lock_t *lock) { + const cbm_private_lock_directory_t *directory = lock->directory; + struct stat by_handle; + struct stat directory_by_path; + struct stat by_path; + if (fstat(lock->fd, &by_handle) != 0) { + return -1; + } + if (by_handle.st_nlink == 0) { + return 0; + } + if (lstat(directory->path, &directory_by_path) != 0) { + return errno == ENOENT || errno == ENOTDIR ? 0 : -1; + } + if (!S_ISDIR(directory_by_path.st_mode) || directory_by_path.st_dev != directory->device || + directory_by_path.st_ino != directory->inode) { + return 0; + } + if (fstatat(directory->fd, lock->base_name, &by_path, AT_SYMLINK_NOFOLLOW) != 0) { + return errno == ENOENT ? 0 : -1; + } + return S_ISREG(by_path.st_mode) && by_path.st_dev == by_handle.st_dev && + by_path.st_ino == by_handle.st_ino + ? 1 + : 0; +} + cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) { if (!lock) { return CBM_PRIVATE_FILE_LOCK_UNSAFE; @@ -494,17 +527,17 @@ cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock if (!cbm_private_file_lock_fork_guard_enter()) { return CBM_PRIVATE_FILE_LOCK_IO; } - /* Unlink leaves st_nlink 0 on the held inode, but a rename-away or a - * replaced directory keeps it linked elsewhere; only the path-vs-handle - * identity check proves the canonical path still names this lock. */ - bool linked = private_payload_fd_valid(lock, NULL) && lock->directory && - private_file_revalidate(lock->directory, lock->base_name, lock->fd, NULL); - bool touched = linked && futimens(lock->fd, NULL) == 0; - cbm_private_file_lock_fork_guard_leave(); - if (!linked) { - return CBM_PRIVATE_FILE_LOCK_UNSAFE; + cbm_private_file_lock_status_t status = CBM_PRIVATE_FILE_LOCK_UNSAFE; + if (lock->fd >= 0 && !lock->unlocked && lock->owner_pid == getpid() && lock->directory && + private_lock_is_tracked(lock)) { + int identity = private_held_identity(lock); + status = identity > 0 ? (futimens(lock->fd, NULL) == 0 ? CBM_PRIVATE_FILE_LOCK_OK + : CBM_PRIVATE_FILE_LOCK_IO) + : identity < 0 ? CBM_PRIVATE_FILE_LOCK_IO + : CBM_PRIVATE_FILE_LOCK_UNSAFE; } - return touched ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_fork_guard_leave(); + return status; } cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io) { @@ -1478,15 +1511,21 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_f cbm_private_file_lock_status_t cbm_private_file_lock_touch(cbm_private_file_lock_t *lock) { /* Windows has no age-based cleaner of the private lock directory; only * confirm the held handle still names a linked file. */ - if (!lock) { + if (!lock || lock->handle == INVALID_HANDLE_VALUE || lock->unlocked) { return CBM_PRIVATE_FILE_LOCK_UNSAFE; } if (!cbm_private_file_lock_fork_guard_enter()) { return CBM_PRIVATE_FILE_LOCK_IO; } - bool linked = private_win_payload_handle_valid(lock); + BY_HANDLE_FILE_INFORMATION information; + bool queried = GetFileInformationByHandle(lock->handle, &information) != 0; cbm_private_file_lock_fork_guard_leave(); - return linked ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_UNSAFE; + /* A failed query says nothing about the file; only a proven unlink is loss. */ + if (!queried) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + return information.nNumberOfLinks == 0 ? CBM_PRIVATE_FILE_LOCK_UNSAFE + : CBM_PRIVATE_FILE_LOCK_OK; } static bool private_win_release_unlock(cbm_private_file_lock_t *lock) { diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index 8107a0ef5..5fcf8c1fc 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -2072,16 +2072,18 @@ TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { #ifdef _WIN32 SKIP_PLATFORM("unlink-while-held of runtime files is POSIX behavior"); #else - enum { TOUCH_CASES = 4 }; + enum { TOUCH_CASES = 5 }; + enum { LOSS_UNLINK, LOSS_REPLACE_FILE, LOSS_REPLACE_SYMLINK }; static const char key[] = "2178a2178a2178a0"; static const struct { const char *suffix; - bool replace; + int loss; } cases[TOUCH_CASES] = { - {"lifetime.lock", false}, /* lifetime reservation */ - {"lock", false}, /* listener-owned participant guard */ - {"sock.identity", false}, /* identity marker, unlinked */ - {"sock.identity", true}, /* identity marker, replaced by a fresh inode */ + {"lifetime.lock", LOSS_UNLINK}, /* lifetime reservation */ + {"lock", LOSS_UNLINK}, /* listener-owned participant guard */ + {"sock.identity", LOSS_UNLINK}, /* identity marker, unlinked */ + {"sock.identity", LOSS_REPLACE_FILE}, /* ... replaced by a fresh inode */ + {"sock.identity", LOSS_REPLACE_SYMLINK} /* ... replaced by a symlink */, }; const time_t stale = 1000000000; char parent[TEST_PATH_CAP] = {0}; @@ -2091,7 +2093,9 @@ TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { int touched_intact[TOUCH_CASES] = {0}; bool refreshed[TOUCH_CASES] = {false}; bool lost[TOUCH_CASES] = {false}; - int touched_after_loss[TOUCH_CASES] = {1, 1, 1, 1}; + int touched_after_loss[TOUCH_CASES] = {1, 1, 1, 1, 1}; + int unreadable_touch = 0; + int readable_again_touch = 0; if (ipc_test_parent_new(parent, "listener-touch")) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); @@ -2099,6 +2103,19 @@ TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { if (endpoint) { ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } + /* A still-valid marker that merely cannot be reopened (EACCES) is a + * transient refresh failure, not loss: the daemon must keep serving. */ + if (endpoint) { + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); + char marker[TEST_PATH_CAP]; + int written = snprintf(marker, sizeof(marker), "%s/cbm-%s.sock.identity", runtime_dir, key); + if (listener && written > 0 && written < (int)sizeof(marker) && chmod(marker, 0) == 0) { + unreadable_touch = cbm_daemon_ipc_listener_touch(listener, NULL); + readable_again_touch = + chmod(marker, 0600) == 0 ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2; + } + cbm_daemon_ipc_listener_close(listener); + } for (size_t i = 0; endpoint && i < TOUCH_CASES; i++) { cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); started[i] = listener != NULL; @@ -2113,16 +2130,30 @@ TEST(daemon_ipc_listener_touch_detects_lost_runtime_files) { refreshed[i] = touched_intact[i] == 1 && stat(path, &after) == 0 && after.st_atime > stale && after.st_mtime > stale; lost[i] = refreshed[i] && unlink(path) == 0; - if (lost[i] && cases[i].replace) { + if (lost[i] && cases[i].loss == LOSS_REPLACE_FILE) { int fd = open(path, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); lost[i] = fd >= 0 && close(fd) == 0; + } else if (lost[i] && cases[i].loss == LOSS_REPLACE_SYMLINK) { + lost[i] = symlink("/dev/null", path) == 0; } touched_after_loss[i] = lost[i] ? cbm_daemon_ipc_listener_touch(listener, NULL) : -2; cbm_daemon_ipc_listener_close(listener); + /* A tampered marker makes close (correctly) keep the socket pair; clear + * this case's simulated leftovers so the next case can listen. */ + static const char *const leftovers[] = {"sock.identity", "sock", "anc"}; + for (size_t j = 0; j < sizeof(leftovers) / sizeof(leftovers[0]); j++) { + written = snprintf(path, sizeof(path), "%s/cbm-%s.%s", runtime_dir, key, leftovers[j]); + if (written > 0 && written < (int)sizeof(path)) { + (void)unlink(path); + } + } } cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); + /* Root bypasses mode 0, so the reopen succeeds there and the touch is 1. */ + ASSERT_EQ(unreadable_touch, geteuid() == 0 ? 1 : -1); + ASSERT_EQ(readable_again_touch, 1); for (size_t i = 0; i < TOUCH_CASES; i++) { ASSERT_TRUE(started[i]); ASSERT_EQ(touched_intact[i], 1); diff --git a/tests/test_version_cohort.c b/tests/test_version_cohort.c index f40e05c5e..d5b6d4c5a 100644 --- a/tests/test_version_cohort.c +++ b/tests/test_version_cohort.c @@ -22,6 +22,8 @@ #endif #include #else +#include +#include #include #include #endif @@ -690,9 +692,6 @@ TEST(version_cohort_touch_reports_each_lost_file) { CBM_VERSION_COHORT_OK); ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), CBM_VERSION_COHORT_OK); - ASSERT_EQ(cbm_version_cohort_lease_touch(lease), CBM_VERSION_COHORT_OK); - ASSERT_EQ(cbm_version_cohort_daemon_claim_touch(claim), CBM_VERSION_COHORT_OK); - const char *runtime_dir = cbm_daemon_ipc_endpoint_runtime_dir(fixture.endpoint); char marker[VERSION_COHORT_TEST_PATH_CAP]; char lifetime[VERSION_COHORT_TEST_PATH_CAP]; @@ -701,6 +700,20 @@ TEST(version_cohort_touch_reports_each_lost_file) { ASSERT_TRUE(snprintf(lifetime, sizeof(lifetime), "%s/cbm-version-cohort-lifetime-v1.lock", runtime_dir) < (int)sizeof(lifetime)); + /* Age both held files the way a cleaner would see them, then prove the + * touch refreshed them through the held handles. */ + const time_t stale = 1000000000; + struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}}; + ASSERT_EQ(utimensat(AT_FDCWD, marker, stale_times, 0), 0); + ASSERT_EQ(utimensat(AT_FDCWD, lifetime, stale_times, 0), 0); + ASSERT_EQ(cbm_version_cohort_lease_touch(lease), CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_touch(claim), CBM_VERSION_COHORT_OK); + struct stat refreshed; + ASSERT_EQ(stat(marker, &refreshed), 0); + ASSERT_TRUE(refreshed.st_mtime > stale && refreshed.st_atime > stale); + ASSERT_EQ(stat(lifetime, &refreshed), 0); + ASSERT_TRUE(refreshed.st_mtime > stale && refreshed.st_atime > stale); + ASSERT_EQ(unlink(marker), 0); ASSERT_EQ(cbm_version_cohort_daemon_claim_touch(claim), CBM_VERSION_COHORT_UNSAFE); ASSERT_EQ(cbm_version_cohort_lease_touch(lease), CBM_VERSION_COHORT_OK); From fee73ced23e4f0753523bc2157f1fbb853f51fdd Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Fri, 25 Sep 2026 14:09:27 -0500 Subject: [PATCH 18/76] test(daemon): cover host loop stop on coordination file loss Add cbm_daemon_host_wait_for_lifetime_for_test, which runs the production lifetime loop over caller-held coordination handles, and a runtime test that ages the claim marker, unlinks the cohort lifetime lock, and asserts the tick refreshes the marker, logs daemon.lifetime_end reason=coordination_file_lost file=cohort_lease, and stops the runtime. The seam runs an ephemeral generation, so a missed loss ends at the initial client window instead of hanging. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NbYKGdU7pqG6AEuMQuJxiF Signed-off-by: Shane McCarron --- src/daemon/host.c | 16 +++++ src/daemon/host_internal.h | 14 ++++ tests/test_daemon_runtime.c | 131 ++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/src/daemon/host.c b/src/daemon/host.c index 3dea464cf..fb44acdd3 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -985,6 +985,22 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, } } +bool cbm_daemon_host_wait_for_lifetime_for_test(cbm_daemon_runtime_service_t *service, + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_version_cohort_lease_t *cohort_lease, + cbm_version_cohort_daemon_claim_t *daemon_claim, + cbm_daemon_ipc_participant_guard_t *guard) { + host_state_t host = {0}; + atomic_int stop_requested = 0; + host_coordination_t coordination = { + .endpoint = endpoint, + .cohort_lease = cohort_lease, + .daemon_claim = daemon_claim, + .participant_guard = guard, + }; + return host_wait_for_lifetime(service, &stop_requested, &host, false, &coordination); +} + static bool host_application_shutdown(host_state_t *host) { if (cbm_daemon_application_shutdown(host->application, HOST_APPLICATION_SHUTDOWN_MS)) { return true; diff --git a/src/daemon/host_internal.h b/src/daemon/host_internal.h index dd8856161..8573c0913 100644 --- a/src/daemon/host_internal.h +++ b/src/daemon/host_internal.h @@ -9,6 +9,10 @@ #include struct cbm_daemon_ipc_endpoint; +struct cbm_daemon_ipc_participant_guard; +struct cbm_daemon_runtime_service; +struct cbm_version_cohort_daemon_claim; +struct cbm_version_cohort_lease; typedef bool (*cbm_daemon_host_cleanup_release_for_test_fn)(void *context); @@ -68,4 +72,14 @@ bool cbm_daemon_host_http_reconcile_free_refusal_for_test( * the failure, the adapter must cancel so the final free succeeds. */ bool cbm_daemon_host_http_thread_create_failure_lifecycle_for_test(void); +/* Run the production lifetime loop over caller-held coordination handles with + * no HTTP host state and no stop request. The generation is ephemeral, so a + * loop that misses a loss still ends at the initial client window instead of + * hanging the test. Returns the loop's own result. */ +bool cbm_daemon_host_wait_for_lifetime_for_test( + struct cbm_daemon_runtime_service *service, const struct cbm_daemon_ipc_endpoint *endpoint, + struct cbm_version_cohort_lease *cohort_lease, + struct cbm_version_cohort_daemon_claim *daemon_claim, + struct cbm_daemon_ipc_participant_guard *guard); + #endif /* CBM_DAEMON_HOST_INTERNAL_H */ diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 827a8cf34..5ca4c72dd 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -1262,6 +1262,19 @@ static bool runtime_test_fixture_start_failed(const char *tag, const char *stage static bool runtime_test_fixture_permanent = false; +typedef struct { + cbm_version_cohort_manager_t *manager; + cbm_version_cohort_lease_t *lease; + cbm_daemon_ipc_participant_guard_t *guard; + cbm_version_cohort_daemon_claim_t *claim; + bool held; +} runtime_test_coordination_t; + +/* When set, the fixture takes the host's coordination handles in the host's + * order before starting the service: cohort admission refuses once a + * generation is already serving. */ +static runtime_test_coordination_t *runtime_test_fixture_coordination = NULL; + static bool runtime_test_fixture_start_configured( runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity, uint32_t max_clients, uint64_t lease_timeout_ms, @@ -1317,6 +1330,19 @@ static bool runtime_test_fixture_start_configured( if (application) { config.application = *application; } + runtime_test_coordination_t *coordination = runtime_test_fixture_coordination; + if (coordination) { + cbm_daemon_conflict_t conflict; + coordination->manager = cbm_version_cohort_manager_new(fixture->endpoint); + coordination->held = + coordination->manager && + cbm_version_cohort_acquire(coordination->manager, identity, cbm_now_ms() + 5000, + &coordination->lease, &conflict) == CBM_VERSION_COHORT_OK && + cbm_daemon_ipc_participant_guard_try_join(fixture->endpoint, &coordination->guard) == + 1 && + cbm_version_cohort_daemon_claim_acquire(coordination->manager, &coordination->claim) == + CBM_VERSION_COHORT_OK; + } fixture->service = cbm_daemon_runtime_service_start(&config); if (!fixture->service) { return runtime_test_fixture_start_failed(tag, "service-start", @@ -1370,6 +1396,110 @@ TEST(daemon_runtime_convenience_service_owns_participant_guard) { PASS(); } +#ifndef _WIN32 +/* The runtime's threads log concurrently while the loop stops the service, so + * the sink only sets flags. */ +static atomic_bool runtime_test_coordination_lost_logged; +static atomic_bool runtime_test_window_expired_logged; + +static void runtime_test_lifetime_log_sink(const char *line) { + /* Substrings, not key=value spelling, so text and JSON formats both match. */ + if (line && strstr(line, "daemon.lifetime_end") && strstr(line, "coordination_file_lost") && + strstr(line, "cohort_lease")) { + atomic_store(&runtime_test_coordination_lost_logged, true); + } + if (line && strstr(line, "initial_window_expired")) { + atomic_store(&runtime_test_window_expired_logged, true); + } +} +#endif + +/* #2178: the host lifetime loop must refresh every held coordination file on + * its tick and stop the generation once one is lost. The claim marker is aged + * and the cohort lifetime lock unlinked: the tick must refresh the marker + * through its held handle, name the lease (touched after the claim) as lost, + * and stop the runtime. */ +TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss) { +#ifdef _WIN32 + SKIP_PLATFORM("unlinking a held lock file is POSIX behavior"); +#else + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + identity.cache_fingerprint = RUNTIME_CACHE_A; /* cohort admission requires one */ + runtime_test_fixture_t fixture; + runtime_test_coordination_t coordination = {0}; + runtime_test_fixture_coordination = &coordination; + bool started = runtime_test_fixture_start(&fixture, "coordination-loss", &identity); + runtime_test_fixture_coordination = NULL; + bool held = coordination.held; + + char marker[RUNTIME_TEST_PATH_CAP]; + char lifetime[RUNTIME_TEST_PATH_CAP]; + int marker_written = snprintf(marker, sizeof(marker), "%s/cbm-version-cohort-daemon-v1.lock", + fixture.runtime_dir); + int lifetime_written = snprintf(lifetime, sizeof(lifetime), + "%s/cbm-version-cohort-lifetime-v1.lock", fixture.runtime_dir); + const time_t stale = 1000000000; + struct timespec stale_times[2] = {{.tv_sec = stale}, {.tv_sec = stale}}; + bool staged = held && marker_written > 0 && marker_written < (int)sizeof(marker) && + lifetime_written > 0 && lifetime_written < (int)sizeof(lifetime) && + utimensat(AT_FDCWD, marker, stale_times, 0) == 0 && unlink(lifetime) == 0; + + bool loop_ok = false; + if (staged) { + atomic_store(&runtime_test_coordination_lost_logged, false); + atomic_store(&runtime_test_window_expired_logged, false); + CBMLogLevel previous_log_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_INFO); + cbm_log_set_sink(runtime_test_lifetime_log_sink); + loop_ok = cbm_daemon_host_wait_for_lifetime_for_test( + fixture.service, fixture.endpoint, coordination.lease, coordination.claim, + coordination.guard); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_log_level); + } + cbm_daemon_runtime_service_state_t state = + started ? cbm_daemon_runtime_service_state(fixture.service) + : CBM_DAEMON_RUNTIME_SERVICE_RUNNING; + struct stat marker_status = {0}; + bool marker_refreshed = staged && stat(marker, &marker_status) == 0 && + marker_status.st_mtime > stale && marker_status.st_atime > stale; + + /* Host teardown order: service first, then guard, claim and lease. Every + * handle references the endpoint, which the fixture frees. */ + if (started && state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + (void)cbm_daemon_runtime_service_stop(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + if (fixture.service && cbm_daemon_runtime_service_free(fixture.service)) { + fixture.service = NULL; + } + (void)cbm_daemon_ipc_participant_guard_release(&coordination.guard); + while (coordination.claim && cbm_version_cohort_daemon_claim_release(&coordination.claim) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (coordination.lease && + cbm_version_cohort_lease_release(&coordination.lease) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (coordination.manager && + cbm_version_cohort_manager_free(&coordination.manager) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(held); + ASSERT_TRUE(staged); + ASSERT_TRUE(loop_ok); + ASSERT_EQ(state, CBM_DAEMON_RUNTIME_SERVICE_EXITED); + ASSERT_TRUE(marker_refreshed); + ASSERT_TRUE(atomic_load(&runtime_test_coordination_lost_logged)); + ASSERT_FALSE(atomic_load(&runtime_test_window_expired_logged)); + PASS(); +#endif +} + static bool runtime_test_fixture_start_application(runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity, runtime_application_context_t *context) { @@ -5106,6 +5236,7 @@ SUITE(daemon_runtime) { RUN_TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path); #endif RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); + RUN_TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); RUN_TEST(daemon_runtime_image_rejection_reaches_client_issue1383); From b15e7f91b95958555ac23a575b0bd1ebbfec7ddf Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Fri, 25 Sep 2026 14:09:27 -0500 Subject: [PATCH 19/76] style(tests): drop unrelated reflow hunks Restore main's formatting on pre-existing lines that a whole-file clang-format pass had reflowed, so the diff carries only the #2178 change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NbYKGdU7pqG6AEuMQuJxiF Signed-off-by: Shane McCarron --- tests/test_daemon_ipc.c | 7 ++++--- tests/test_version_cohort.c | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index 5fcf8c1fc..5a4e0e386 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -1004,8 +1004,8 @@ TEST(daemon_ipc_windows_sid_trust_accepts_local_admin_rejects_foreign_500) { (void)CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, NULL, &builtin_needed); if (builtin_needed > 0) { builtin_admins = malloc(builtin_needed); - if (builtin_admins && !CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, builtin_admins, - &builtin_needed)) { + if (builtin_admins && + !CreateWellKnownSid(WinBuiltinAdministratorsSid, NULL, builtin_admins, &builtin_needed)) { free(builtin_admins); builtin_admins = NULL; } @@ -5362,7 +5362,8 @@ TEST(daemon_ipc_posix_single_uid_userns_real_smoke_issue1830) { if (WEXITSTATUS(status) != 0 && WEXITSTATUS(status) != 1) { char unexpected[128]; (void)snprintf(unexpected, sizeof(unexpected), - "userns probe exited %d -- not a security verdict", WEXITSTATUS(status)); + "userns probe exited %d -- not a security verdict", + WEXITSTATUS(status)); FAIL(unexpected); } ASSERT_EQ(0, WEXITSTATUS(status)); diff --git a/tests/test_version_cohort.c b/tests/test_version_cohort.c index d5b6d4c5a..f48bd7225 100644 --- a/tests/test_version_cohort.c +++ b/tests/test_version_cohort.c @@ -332,9 +332,9 @@ TEST(version_cohort_conflict_is_retried_until_the_deadline) { /* An indefinite deadline never waits on a conflicting holder. */ retries_before = cbm_version_cohort_conflict_retries_for_testing(); - ASSERT_EQ( - cbm_version_cohort_acquire(second, &requested, UINT64_MAX, &requested_lease, &conflict), - CBM_VERSION_COHORT_CONFLICT); + ASSERT_EQ(cbm_version_cohort_acquire(second, &requested, UINT64_MAX, &requested_lease, + &conflict), + CBM_VERSION_COHORT_CONFLICT); ASSERT_NULL(requested_lease); ASSERT_EQ(cbm_version_cohort_conflict_retries_for_testing(), retries_before); From 8cd2ee5f421d0b7c06022091d59dbe71002de30d Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Fri, 25 Sep 2026 14:39:51 -0500 Subject: [PATCH 20/76] fix(daemon): address QA round 4 Reflow the host-loop seam call to clang-format and note that the fixture's convenience start joins its own participant guard, unlike the host's start_reserved. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NbYKGdU7pqG6AEuMQuJxiF Signed-off-by: Shane McCarron --- tests/test_daemon_runtime.c | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 5ca4c72dd..5187bb7a4 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -1272,7 +1272,8 @@ typedef struct { /* When set, the fixture takes the host's coordination handles in the host's * order before starting the service: cohort admission refuses once a - * generation is already serving. */ + * generation is already serving. Unlike the host's start_reserved, the + * fixture's convenience start also joins a service-owned participant guard. */ static runtime_test_coordination_t *runtime_test_fixture_coordination = NULL; static bool runtime_test_fixture_start_configured( @@ -1452,9 +1453,9 @@ TEST(daemon_runtime_host_loop_stops_on_coordination_file_loss) { CBMLogLevel previous_log_level = cbm_log_get_level(); cbm_log_set_level(CBM_LOG_INFO); cbm_log_set_sink(runtime_test_lifetime_log_sink); - loop_ok = cbm_daemon_host_wait_for_lifetime_for_test( - fixture.service, fixture.endpoint, coordination.lease, coordination.claim, - coordination.guard); + loop_ok = cbm_daemon_host_wait_for_lifetime_for_test(fixture.service, fixture.endpoint, + coordination.lease, coordination.claim, + coordination.guard); cbm_log_set_sink(NULL); cbm_log_set_level(previous_log_level); } From 9dca8d2fede860605943ee8dbd57ba6b927ae41b Mon Sep 17 00:00:00 2001 From: News Date: Sat, 26 Sep 2026 20:38:47 +0900 Subject: [PATCH 21/76] fix(windows): limit allocator fix to CRT aligned blocks Signed-off-by: News --- src/foundation/mem_override_win.c | 47 ------------------------------- 1 file changed, 47 deletions(-) diff --git a/src/foundation/mem_override_win.c b/src/foundation/mem_override_win.c index bb86a2e31..a33b697d2 100644 --- a/src/foundation/mem_override_win.c +++ b/src/foundation/mem_override_win.c @@ -161,53 +161,6 @@ void __wrap__aligned_free(void *block) { __real__aligned_free(block); } -#if defined(CBM_MEM_GLOBAL_OVERRIDE) && CBM_MEM_GLOBAL_OVERRIDE -/* LLVM-MinGW's static C++ runtime calls _aligned_malloc through the linker's - * wrapper but its aligned operator delete can call the CRT import pointer - * __imp__aligned_free directly. --wrap=_aligned_free does not rewrite that - * import-pointer reference. Provide the six C++ ABI delete entry points so - * every aligned new/delete pair reaches the same owner-aware deallocator. */ -void cbm_cxx_aligned_delete(void *block) __asm__("_ZdlPvSt11align_val_t"); -void cbm_cxx_aligned_delete(void *block) { - __wrap__aligned_free(block); -} - -void cbm_cxx_aligned_delete_nothrow(void *block, - const void *tag) __asm__("_ZdlPvSt11align_val_tRKSt9nothrow_t"); -void cbm_cxx_aligned_delete_nothrow(void *block, const void *tag) { - (void)tag; - __wrap__aligned_free(block); -} - -void cbm_cxx_aligned_delete_sized(void *block, size_t size, - size_t alignment) __asm__("_ZdlPvySt11align_val_t"); -void cbm_cxx_aligned_delete_sized(void *block, size_t size, size_t alignment) { - (void)size; - (void)alignment; - __wrap__aligned_free(block); -} - -void cbm_cxx_aligned_array_delete(void *block) __asm__("_ZdaPvSt11align_val_t"); -void cbm_cxx_aligned_array_delete(void *block) { - __wrap__aligned_free(block); -} - -void cbm_cxx_aligned_array_delete_nothrow(void *block, const void *tag) __asm__( - "_ZdaPvSt11align_val_tRKSt9nothrow_t"); -void cbm_cxx_aligned_array_delete_nothrow(void *block, const void *tag) { - (void)tag; - __wrap__aligned_free(block); -} - -void cbm_cxx_aligned_array_delete_sized(void *block, size_t size, - size_t alignment) __asm__("_ZdaPvySt11align_val_t"); -void cbm_cxx_aligned_array_delete_sized(void *block, size_t size, size_t alignment) { - (void)size; - (void)alignment; - __wrap__aligned_free(block); -} -#endif - void *__wrap_realloc(void *block, size_t size) { if (!block) { void *fresh = mi_malloc(size); From 6f17caa7a763d9987ebcd7ed33063d47d34bf635 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sun, 27 Sep 2026 01:34:35 +0800 Subject: [PATCH 22/76] fix(mcp): report request timing and transport failures Signed-off-by: lorenzozanee --- src/daemon/application.c | 21 ++- src/foundation/log.c | 22 ++- src/foundation/log.h | 3 +- src/mcp/mcp.c | 302 +++++++++++++++++++++++++++++---------- src/ui/http_server.c | 25 +++- tests/test_log.c | 5 - tests/test_mcp.c | 165 +++++++++++++++++++-- 7 files changed, 439 insertions(+), 104 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 748b8bb75..94d865f4a 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -2144,13 +2144,19 @@ static void application_update_notice_inject(cbm_daemon_application_session_t *s char **response_io) { cbm_daemon_application_t *application = session->application; cbm_mutex_lock(&application->mutex); - if (session->background_eligible && !session->update_notice_delivered && - !session->pending_update_notice && application->update_thread_done && - application->update_notice[0] && - cbm_mcp_jsonrpc_response_prepend_notice(response_io, application->update_notice)) { + bool should_inject = session->background_eligible && !session->update_notice_delivered && + !session->pending_update_notice && application->update_thread_done && + application->update_notice[0]; + bool injected = should_inject && + cbm_mcp_jsonrpc_response_prepend_notice(response_io, + application->update_notice); + if (injected) { session->pending_update_notice = true; } cbm_mutex_unlock(&application->mutex); + if (should_inject && !injected) { + cbm_log_warn("daemon.update_notice.inject_failed", "status", "omitted"); + } } static bool application_watch_job_subscription_exists_locked( @@ -2556,6 +2562,13 @@ static cbm_daemon_runtime_application_status_t application_mcp_request( parsed_ok && parsed.has_id && parsed.method && strcmp(parsed.method, "tools/call") == 0; char *response = cbm_mcp_server_handle(session->mcp, message); free(message); + if (!response && (!parsed_ok || parsed.has_id)) { + cbm_log_error("daemon.mcp.response_missing", "status", "error"); + if (parsed_ok) { + cbm_jsonrpc_request_free(&parsed); + } + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } if (initialize_request && application_jsonrpc_success(response)) { cbm_mutex_lock(&session->application->mutex); session->pending_background_initialize = true; diff --git a/src/foundation/log.c b/src/foundation/log.c index da8531aab..111c09c1b 100644 --- a/src/foundation/log.c +++ b/src/foundation/log.c @@ -363,17 +363,29 @@ static void copy_path_without_query(const char *path, char *out, size_t outsz) { } void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error, - int64_t duration_us) { + int64_t duration_us, int64_t handler_duration_us, + int64_t response_serialization_us, bool cancelled, bool timed_out) { char duration_ms[CBM_SZ_32]; + char handler_duration_ms[CBM_SZ_32]; + char response_serialization_ms[CBM_SZ_32]; snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000); + snprintf(handler_duration_ms, sizeof(handler_duration_ms), "%" PRId64, + handler_duration_us / 1000); + snprintf(response_serialization_ms, sizeof(response_serialization_ms), "%" PRId64, + response_serialization_us / 1000); + const char *status = timed_out ? "timeout" : (is_error ? "error" : "ok"); if (tool_name && tool_name[0] != '\0') { cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc", - "method", method ? method : "", "tool", tool_name, "status", - is_error ? "error" : "ok", "duration_ms", duration_ms, NULL); + "method", method ? method : "", "tool", tool_name, "status", status, + "duration_ms", duration_ms, "handler_duration_ms", handler_duration_ms, + "response_serialization_ms", response_serialization_ms, "cancelled", + cancelled ? "true" : "false", "timed_out", timed_out ? "true" : "false", NULL); } else { cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc", - "method", method ? method : "", "status", is_error ? "error" : "ok", "duration_ms", - duration_ms, NULL); + "method", method ? method : "", "status", status, "duration_ms", duration_ms, + "handler_duration_ms", handler_duration_ms, "response_serialization_ms", + response_serialization_ms, "cancelled", cancelled ? "true" : "false", "timed_out", + timed_out ? "true" : "false", NULL); } } diff --git a/src/foundation/log.h b/src/foundation/log.h index 88e0d25fc..ac4cfc3ca 100644 --- a/src/foundation/log.h +++ b/src/foundation/log.h @@ -95,7 +95,8 @@ void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t va /* Operational event helpers. They deliberately avoid request bodies, headers, * arguments, and query strings. */ void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error, - int64_t duration_us); + int64_t duration_us, int64_t handler_duration_us, + int64_t response_serialization_us, bool cancelled, bool timed_out); void cbm_log_http_request(const char *component, const char *method, const char *path, int status, int64_t duration_ms, size_t request_bytes, size_t response_bytes); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d6c7bd468..462bfba35 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -128,6 +128,7 @@ enum { /* JSON-RPC 2.0 standard error codes */ #define JSONRPC_PARSE_ERROR (-32700) +#define MCP_INPUT_CLOSED (-2) #define JSONRPC_METHOD_NOT_FOUND (-32601) #define JSONRPC_INVALID_PARAMS (-32602) #define JSONRPC_INTERNAL_ERROR (-32603) @@ -245,28 +246,45 @@ int cbm_jsonrpc_parse(const char *line, cbm_jsonrpc_request_t *out) { yyjson_val *v_id = yyjson_obj_get(root, "id"); yyjson_val *v_params = yyjson_obj_get(root, "params"); - if (!v_method || !yyjson_is_str(v_method)) { + if (!v_jsonrpc || !yyjson_is_str(v_jsonrpc) || + strcmp(yyjson_get_str(v_jsonrpc), "2.0") != 0 || !v_method || + !yyjson_is_str(v_method) || + (v_id && !yyjson_is_int(v_id) && !yyjson_is_str(v_id))) { yyjson_doc_free(doc); return CBM_NOT_FOUND; } - out->jsonrpc = - heap_strdup(v_jsonrpc && yyjson_is_str(v_jsonrpc) ? yyjson_get_str(v_jsonrpc) : "2.0"); + out->jsonrpc = heap_strdup(yyjson_get_str(v_jsonrpc)); out->method = heap_strdup(yyjson_get_str(v_method)); + if (!out->jsonrpc || !out->method) { + yyjson_doc_free(doc); + cbm_jsonrpc_request_free(out); + return CBM_NOT_FOUND; + } if (v_id) { out->has_id = true; if (yyjson_is_int(v_id)) { out->id = yyjson_get_int(v_id); - } else if (yyjson_is_str(v_id)) { + } else { /* JSON-RPC 2.0 §4 permits string ids (Claude Desktop uses them). * Preserve verbatim instead of coercing via strtol (issue #253). */ out->id_str = heap_strdup(yyjson_get_str(v_id)); + if (!out->id_str) { + yyjson_doc_free(doc); + cbm_jsonrpc_request_free(out); + return CBM_NOT_FOUND; + } } } if (v_params) { out->params_raw = yyjson_val_write(v_params, 0, NULL); + if (!out->params_raw) { + yyjson_doc_free(doc); + cbm_jsonrpc_request_free(out); + return CBM_NOT_FOUND; + } } yyjson_doc_free(doc); @@ -289,36 +307,62 @@ void cbm_jsonrpc_request_free(cbm_jsonrpc_request_t *r) { * ══════════════════════════════════════════════════════════════════ */ char *cbm_jsonrpc_format_response(const cbm_jsonrpc_response_t *resp) { + if (!resp) { + return NULL; + } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) { + return NULL; + } yyjson_mut_val *root = yyjson_mut_obj(doc); + if (!root) { + yyjson_mut_doc_free(doc); + return NULL; + } yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_str(doc, root, "jsonrpc", "2.0"); + bool added = yyjson_mut_obj_add_str(doc, root, "jsonrpc", "2.0"); if (resp->id_str) { - yyjson_mut_obj_add_str(doc, root, "id", resp->id_str); + added = added && yyjson_mut_obj_add_str(doc, root, "id", resp->id_str); } else { - yyjson_mut_obj_add_int(doc, root, "id", resp->id); + added = added && yyjson_mut_obj_add_int(doc, root, "id", resp->id); + } + if (!added) { + yyjson_mut_doc_free(doc); + return NULL; } if (resp->error_json) { /* Parse the error JSON and embed */ yyjson_doc *err_doc = yyjson_read(resp->error_json, strlen(resp->error_json), 0); - if (err_doc) { - yyjson_mut_val *err_val = yyjson_val_mut_copy(doc, yyjson_doc_get_root(err_doc)); - yyjson_mut_obj_add_val(doc, root, "error", err_val); - yyjson_doc_free(err_doc); + if (!err_doc) { + yyjson_mut_doc_free(doc); + return NULL; + } + yyjson_mut_val *err_val = yyjson_val_mut_copy(doc, yyjson_doc_get_root(err_doc)); + bool added = err_val && yyjson_mut_obj_add_val(doc, root, "error", err_val); + yyjson_doc_free(err_doc); + if (!added) { + yyjson_mut_doc_free(doc); + return NULL; } } else if (resp->result_json) { /* Parse the result JSON and embed */ yyjson_doc *res_doc = yyjson_read(resp->result_json, strlen(resp->result_json), 0); - if (res_doc) { - yyjson_mut_val *res_val = yyjson_val_mut_copy(doc, yyjson_doc_get_root(res_doc)); - yyjson_mut_obj_add_val(doc, root, "result", res_val); - yyjson_doc_free(res_doc); + if (!res_doc) { + yyjson_mut_doc_free(doc); + return NULL; + } + yyjson_mut_val *res_val = yyjson_val_mut_copy(doc, yyjson_doc_get_root(res_doc)); + bool added = res_val && yyjson_mut_obj_add_val(doc, root, "result", res_val); + yyjson_doc_free(res_doc); + if (!added) { + yyjson_mut_doc_free(doc); + return NULL; } } else { - /* JSON-RPC 2.0 spec: response MUST contain "result" or "error" */ - yyjson_mut_obj_add_null(doc, root, "result"); + yyjson_mut_doc_free(doc); + return NULL; } char *out = yy_final_doc_to_str(doc); @@ -326,13 +370,44 @@ char *cbm_jsonrpc_format_response(const cbm_jsonrpc_response_t *resp) { return out; } +static char *mcp_format_response_or_error(const cbm_jsonrpc_response_t *resp, + bool *serialization_failed) { + if (serialization_failed) { + *serialization_failed = false; + } + char *formatted = cbm_jsonrpc_format_response(resp); + if (formatted) { + return formatted; + } + if (serialization_failed) { + *serialization_failed = true; + } + cbm_jsonrpc_response_t error_resp = { + .id = resp->id, + .id_str = resp->id_str, + .error_json = "{\"code\":-32603,\"message\":\"Failed to serialize the server response\"}", + }; + return cbm_jsonrpc_format_response(&error_resp); +} + char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message) { yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + if (!doc) { + return NULL; + } yyjson_mut_val *root = yyjson_mut_obj(doc); + if (!root) { + yyjson_mut_doc_free(doc); + return NULL; + } yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_str(doc, root, "jsonrpc", "2.0"); - yyjson_mut_obj_add_int(doc, root, "id", id); + bool added = yyjson_mut_obj_add_str(doc, root, "jsonrpc", "2.0"); + added = added && yyjson_mut_obj_add_int(doc, root, "id", id); + if (!added) { + yyjson_mut_doc_free(doc); + return NULL; + } char *encoded_message = NULL; size_t encoded_message_len = 0; @@ -340,13 +415,21 @@ char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message) { if (!cbm_output_encode_text(raw_message, strlen(raw_message), &encoded_message, &encoded_message_len)) { yyjson_mut_doc_free(doc); + free(encoded_message); return NULL; } yyjson_mut_val *err = yyjson_mut_obj(doc); - yyjson_mut_obj_add_int(doc, err, "code", code); - yyjson_mut_obj_add_str(doc, err, "message", encoded_message ? encoded_message : raw_message); - yyjson_mut_obj_add_val(doc, root, "error", err); + added = err && yyjson_mut_obj_add_int(doc, err, "code", code); + added = added && + yyjson_mut_obj_add_str(doc, err, "message", encoded_message ? encoded_message + : raw_message); + added = added && yyjson_mut_obj_add_val(doc, root, "error", err); + if (!added) { + yyjson_mut_doc_free(doc); + free(encoded_message); + return NULL; + } char *out = yy_final_doc_to_str(doc); yyjson_mut_doc_free(doc); @@ -2933,7 +3016,9 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { free(records); return cbm_mcp_text_result("out of memory while listing projects", true); } - qsort(records, (size_t)record_count, sizeof(*records), project_record_compare); + if (record_count > 1) { + qsort(records, (size_t)record_count, sizeof(*records), project_record_compare); + } int limit = cbm_mcp_get_int_arg(args, "limit", 50); int offset = cbm_mcp_get_int_arg(args, "offset", 0); @@ -17796,6 +17881,11 @@ bool cbm_mcp_jsonrpc_response_prepend_notice(char **response_io, const char *not return true; } +static long long mcp_elapsed_us(struct timespec start, struct timespec end) { + return ((long long)(end.tv_sec - start.tv_sec) * MCP_S_TO_US) + + ((long long)(end.tv_nsec - start.tv_nsec) / MCP_MS_TO_US); +} + char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { cbm_jsonrpc_request_t req = {0}; if (cbm_jsonrpc_parse(line, &req) < 0) { @@ -17826,7 +17916,11 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { cbm_clock_gettime(CLOCK_MONOTONIC, &req_t0); char *result_json = NULL; char *request_error_json = NULL; - bool request_logged = false; + char *request_tool_name = NULL; + long long handler_duration_us = 0; + bool request_is_error = false; + bool request_cancelled = false; + bool request_timed_out = false; if (strcmp(req.method, "initialize") == 0) { result_json = cbm_mcp_initialize_response_for_profile(req.params_raw, srv->tool_profile); @@ -17853,6 +17947,7 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { char *tool_name = req.params_raw ? cbm_mcp_get_tool_name(req.params_raw) : NULL; char *tool_args = req.params_raw ? cbm_mcp_get_arguments(req.params_raw) : heap_strdup("{}"); + request_tool_name = tool_name; srv->active_request_id = req.id; free(srv->active_request_id_str); srv->active_request_id_str = req.id_str ? heap_strdup(req.id_str) : NULL; @@ -17865,16 +17960,15 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { srv->active_request_id_str = NULL; struct timespec t1; cbm_clock_gettime(CLOCK_MONOTONIC, &t1); - long long dur_us = ((long long)(t1.tv_sec - t0.tv_sec) * MCP_S_TO_US) + - ((long long)(t1.tv_nsec - t0.tv_nsec) / MCP_MS_TO_US); + long long dur_us = mcp_elapsed_us(t0, t1); bool is_err = (result_json != NULL) && (strstr(result_json, "\"isError\":true") != NULL); cbm_diag_record_query(dur_us, is_err); - long long request_dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) + - ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); - cbm_log_mcp_request(req.method, tool_name, is_err, request_dur_us); - request_logged = true; + handler_duration_us = mcp_elapsed_us(req_t0, t1); + request_is_error = is_err; + request_cancelled = mcp_request_cancelled(srv); + request_timed_out = result_json && + strstr(result_json, "\"code\":\"request_timeout\"") != NULL; - free(tool_name); free(tool_args); } else { /* Echo the original id (string or numeric, issue #253) on the error. */ @@ -17886,12 +17980,18 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { .id_str = req.id_str, .error_json = err_obj, }; - char *err = cbm_jsonrpc_format_response(&err_resp); struct timespec t1; cbm_clock_gettime(CLOCK_MONOTONIC, &t1); - long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) + - ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); - cbm_log_mcp_request(req.method, NULL, true, dur_us); + long long handler_us = mcp_elapsed_us(req_t0, t1); + struct timespec serialize_t0; + cbm_clock_gettime(CLOCK_MONOTONIC, &serialize_t0); + char *err = mcp_format_response_or_error(&err_resp, NULL); + struct timespec serialize_t1; + cbm_clock_gettime(CLOCK_MONOTONIC, &serialize_t1); + long long serialization_us = mcp_elapsed_us(serialize_t0, serialize_t1); + long long duration_us = mcp_elapsed_us(req_t0, serialize_t1); + cbm_log_mcp_request(req.method, NULL, true, duration_us, handler_us, serialization_us, + false, false); cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); return err; @@ -17903,32 +18003,43 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { .id_str = req.id_str, .error_json = request_error_json, }; - char *err = cbm_jsonrpc_format_response(&err_resp); - struct timespec t1; - cbm_clock_gettime(CLOCK_MONOTONIC, &t1); - long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) + - ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); - cbm_log_mcp_request(req.method, NULL, true, dur_us); + struct timespec serialize_t0; + cbm_clock_gettime(CLOCK_MONOTONIC, &serialize_t0); + char *err = mcp_format_response_or_error(&err_resp, NULL); + struct timespec serialize_t1; + cbm_clock_gettime(CLOCK_MONOTONIC, &serialize_t1); + long long handler_us = mcp_elapsed_us(req_t0, serialize_t0); + long long serialization_us = mcp_elapsed_us(serialize_t0, serialize_t1); + long long duration_us = mcp_elapsed_us(req_t0, serialize_t1); + cbm_log_mcp_request(req.method, NULL, true, duration_us, handler_us, serialization_us, + false, false); free(request_error_json); cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); return err; } - if (!request_logged) { - struct timespec t1; - cbm_clock_gettime(CLOCK_MONOTONIC, &t1); - long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) + - ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); - cbm_log_mcp_request(req.method, NULL, false, dur_us); - } - cbm_jsonrpc_response_t resp = { .id = req.id, .id_str = req.id_str, .result_json = result_json, }; - char *out = cbm_jsonrpc_format_response(&resp); + struct timespec serialize_t0; + cbm_clock_gettime(CLOCK_MONOTONIC, &serialize_t0); + bool serialization_failed = false; + char *out = mcp_format_response_or_error(&resp, &serialization_failed); + struct timespec serialize_t1; + cbm_clock_gettime(CLOCK_MONOTONIC, &serialize_t1); + long long serialization_us = mcp_elapsed_us(serialize_t0, serialize_t1); + long long duration_us = mcp_elapsed_us(req_t0, serialize_t1); + request_is_error = request_is_error || serialization_failed || out == NULL; + if (!request_tool_name) { + handler_duration_us = duration_us - serialization_us; + } + cbm_log_mcp_request(req.method, request_tool_name, request_is_error, duration_us, + handler_duration_us, serialization_us, request_cancelled, + request_timed_out); + free(request_tool_name); free(result_json); cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); @@ -18123,8 +18234,7 @@ int cbm_mcp_read_message(FILE *in, char **message, bool *content_length_framed) } #ifndef _WIN32 -/* Unix 3-phase poll: non-blocking fd check, FILE* buffer peek, blocking poll. - * Returns: 1 = data ready, 0 = timeout (evicted idle stores), -1 = error/EOF. */ +/* Unix 3-phase poll: non-blocking fd check, FILE* buffer peek, blocking poll. */ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { struct pollfd pfd = {.fd = fd, .events = POLLIN}; int pr = poll(&pfd, SKIP_ONE, 0); /* Phase 1: non-blocking */ @@ -18139,26 +18249,28 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { /* Phase 2: peek FILE* buffer */ int saved_flags = fcntl(fd, F_GETFL); if (saved_flags < 0) { - /* fcntl failed — fall through to a short blocking poll (see the Phase-3 - * note below on why the interval is bounded, not the full idle timeout) */ - pr = poll(&pfd, SKIP_ONE, MCP_TIMEOUT_MS); - if (pr < 0) { - return CBM_NOT_FOUND; - } - if (pr == 0) { - cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S); - return 0; - } - return SKIP_ONE; + return CBM_NOT_FOUND; } - (void)fcntl(fd, F_SETFL, saved_flags | O_NONBLOCK); + if (fcntl(fd, F_SETFL, saved_flags | O_NONBLOCK) < 0) { + return CBM_NOT_FOUND; + } + errno = 0; int c = fgetc(in); - (void)fcntl(fd, F_SETFL, saved_flags); + int read_errno = errno; + bool read_failed = ferror(in) != 0; + bool reached_eof = feof(in) != 0; + int restore_result = fcntl(fd, F_SETFL, saved_flags); + if (restore_result < 0) { + return CBM_NOT_FOUND; + } if (c == EOF) { - if (feof(in)) { - return CBM_NOT_FOUND; /* true EOF */ + if (reached_eof) { + return MCP_INPUT_CLOSED; + } + if (!read_failed || (read_errno != EAGAIN && read_errno != EWOULDBLOCK)) { + return CBM_NOT_FOUND; } clearerr(in); /* Phase 3: blocking poll, bounded to a SHORT interval (not the full idle @@ -18180,7 +18292,9 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { return SKIP_ONE; } - (void)ungetc(c, in); + if (ungetc(c, in) == EOF) { + return CBM_NOT_FOUND; + } return SKIP_ONE; } #endif @@ -18221,7 +18335,8 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { HANDLE hStdin = (HANDLE)_get_osfhandle(fd); DWORD wr = WaitForSingleObject(hStdin, STORE_IDLE_TIMEOUT_S * MCP_TIMEOUT_MS); if (wr == WAIT_FAILED) { - break; + cbm_log_error("mcp.input.poll", "status", "error"); + return 1; } if (wr == WAIT_TIMEOUT) { cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S); @@ -18229,9 +18344,13 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { } #else int pr = poll_for_input_unix(srv, fd, in); - if (pr < 0) { + if (pr == MCP_INPUT_CLOSED) { break; } + if (pr < 0) { + cbm_log_error("mcp.input.poll", "status", "error"); + return 1; + } if (pr == 0) { continue; /* timeout — idle stores evicted */ } @@ -18239,21 +18358,46 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { char *message = NULL; bool content_length_framed = false; - if (cbm_mcp_read_message(in, &message, &content_length_framed) <= 0) { + int read_status = cbm_mcp_read_message(in, &message, &content_length_framed); + if (read_status == 0) { break; } + if (read_status < 0) { + cbm_log_error("mcp.request.read", "status", "error"); + return 1; + } char *resp = cbm_mcp_server_handle(srv, message); + bool notification = false; + if (!resp) { + cbm_jsonrpc_request_t request = {0}; + notification = cbm_jsonrpc_parse(message, &request) == 0 && !request.has_id; + cbm_jsonrpc_request_free(&request); + } free(message); - if (resp) { - if (content_length_framed) { - size_t response_len = strlen(resp); - (void)fprintf(out, "Content-Length: %zu\r\n\r\n%s", response_len, resp); - } else { - (void)fprintf(out, "%s\n", resp); + if (!resp) { + if (!notification) { + cbm_log_error("mcp.response.missing", "protocol", "jsonrpc", "status", "error"); + return 1; } - (void)fflush(out); - free(resp); + continue; + } + + int write_result; + if (content_length_framed) { + size_t response_len = strlen(resp); + write_result = fprintf(out, "Content-Length: %zu\r\n\r\n%s", response_len, resp); + } else { + write_result = fprintf(out, "%s\n", resp); + } + free(resp); + if (write_result < 0) { + cbm_log_error("mcp.response.write", "protocol", "jsonrpc", "stage", "write"); + return 1; + } + if (fflush(out) != 0) { + cbm_log_error("mcp.response.write", "protocol", "jsonrpc", "stage", "flush"); + return 1; } } diff --git a/src/ui/http_server.c b/src/ui/http_server.c index f47ac59be..b5a496d56 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1803,7 +1803,30 @@ static void handle_rpc(cbm_http_conn_t *c, const cbm_http_req_t *req, cbm_mcp_se cbm_http_replyf(c, 200, g_cors_json, "%s", response); free(response); } else { - cbm_http_replyf(c, 204, g_cors, "%s", ""); + cbm_jsonrpc_request_t parsed = {0}; + bool parsed_ok = cbm_jsonrpc_parse(req->body, &parsed) == 0; + bool notification = parsed_ok && !parsed.has_id; + char *error_response = NULL; + if (parsed_ok && parsed.has_id) { + cbm_jsonrpc_response_t error = { + .id = parsed.id, + .id_str = parsed.id_str, + .error_json = "{\"code\":-32603,\"message\":\"Failed to produce a response\"}", + }; + error_response = cbm_jsonrpc_format_response(&error); + } + cbm_jsonrpc_request_free(&parsed); + if (notification) { + cbm_http_replyf(c, 204, g_cors, "%s", ""); + } else { + cbm_log_error("http.rpc.response_missing", "status", "error"); + if (error_response) { + cbm_http_replyf(c, 500, g_cors_json, "%s", error_response); + } else { + cbm_http_replyf(c, 500, g_cors, "%s", "Failed to produce a response"); + } + } + free(error_response); } } diff --git a/tests/test_log.c b/tests/test_log.c index 2406ee3b6..602672800 100644 --- a/tests/test_log.c +++ b/tests/test_log.c @@ -207,15 +207,10 @@ TEST(log_operational_helpers) { cbm_log_set_level(CBM_LOG_DEBUG); cbm_log_set_format(CBM_LOG_FORMAT_TEXT); capture_start(); - cbm_log_mcp_request("tools/call", "search_graph", false, 1250); cbm_log_http_request("graph_ui", "GET", "/api/layout", 200, 7, 0, 42); const char *output = capture_end(); cbm_log_set_level(CBM_LOG_INFO); - ASSERT(cbm_str_contains_raw(output, "msg=mcp.request")); - ASSERT(cbm_str_contains_raw(output, "protocol=jsonrpc")); - ASSERT(cbm_str_contains_raw(output, "method=tools/call")); - ASSERT(cbm_str_contains_raw(output, "tool=search_graph")); ASSERT(cbm_str_contains_raw(output, "msg=http.request")); ASSERT(cbm_str_contains_raw(output, "method=GET")); ASSERT(cbm_str_contains_raw(output, "path=/api/layout")); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3..55ae389aa 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1149,6 +1149,19 @@ TEST(jsonrpc_format_response) { PASS(); } +TEST(jsonrpc_format_response_rejects_missing_or_malformed_payload) { + cbm_jsonrpc_response_t resp = {.id = 2}; + ASSERT_NULL(cbm_jsonrpc_format_response(&resp)); + + resp.result_json = "{broken"; + ASSERT_NULL(cbm_jsonrpc_format_response(&resp)); + + resp.result_json = NULL; + resp.error_json = "{broken"; + ASSERT_NULL(cbm_jsonrpc_format_response(&resp)); + PASS(); +} + TEST(jsonrpc_format_error) { char *json = cbm_jsonrpc_format_error(5, -32600, "Invalid Request"); ASSERT_NOT_NULL(json); @@ -2225,6 +2238,10 @@ TEST(server_handle_logs_request_without_params) { ASSERT_NOT_NULL(strstr(mcp_log_buf, "protocol=jsonrpc")); ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=tools/list")); ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "handler_duration_ms=")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "response_serialization_ms=")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "cancelled=false")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "timed_out=false")); ASSERT_NULL(strstr(mcp_log_buf, "token")); ASSERT_NULL(strstr(mcp_log_buf, "secret")); PASS(); @@ -10665,14 +10682,27 @@ TEST(search_code_cancel_cleans_supervised_scan) { }; cbm_mcp_server_set_command_test_hook(srv, mcp_search_command_hook_probe, &probe); - char *response = - cbm_mcp_handle_tool(srv, "search_code", - "{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"," - "\"file_pattern\":\"*.go\"}"); + mcp_log_buf[0] = '\0'; + CBMLogLevel previous_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_INFO); + cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\",\"arguments\":{\"pattern\":" + "\"HandleRequest\",\"project\":\"prefilter-search\",\"file_pattern\":" + "\"*.go\"}}}"); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_level); ASSERT_NOT_NULL(response); ASSERT_TRUE(probe.cancel_accepted); ASSERT_NOT_NULL(strstr(response, "cancelled")); ASSERT_NOT_NULL(strstr(response, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "tool=search_code")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "cancelled=true")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "timed_out=false")); + ASSERT_NULL(strstr(mcp_log_buf, "HandleRequest")); + ASSERT_NULL(strstr(mcp_log_buf, "prefilter-search")); char logs[640]; snprintf(logs, sizeof(logs), "%s/logs", cache.path); @@ -10685,6 +10715,39 @@ TEST(search_code_cancel_cleans_supervised_scan) { PASS(); } +TEST(server_handle_logs_timeout_without_query) { + char tmp[512], src_path[768], vendor_path[768]; + cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path), + vendor_path, sizeof(vendor_path)); + ASSERT_NOT_NULL(srv); + cbm_mcp_server_set_search_scan_timeout_for_test(srv, 0, true); + + mcp_log_buf[0] = '\0'; + CBMLogLevel previous_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_INFO); + cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE); + char *response = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\",\"arguments\":{\"pattern\":" + "\"HandleRequest\",\"project\":\"prefilter-search\"}}}"); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_level); + + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=timeout")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "timed_out=true")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "cancelled=false")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "handler_duration_ms=")); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "response_serialization_ms=")); + ASSERT_NULL(strstr(mcp_log_buf, "HandleRequest")); + ASSERT_NULL(strstr(mcp_log_buf, "prefilter-search")); + + free(response); + cbm_mcp_server_free(srv); + cleanup_prefilter_dir(tmp, src_path, vendor_path); + PASS(); +} + TEST(search_code_output_limit_fails_closed_and_cleans_scan) { mcp_search_cache_t cache; ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-limit")); @@ -16631,14 +16694,24 @@ TEST(jsonrpc_parse_empty_string) { } TEST(jsonrpc_parse_missing_jsonrpc_field) { - /* jsonrpc field absent — parser defaults to "2.0" if method present */ + /* JSON-RPC requests must declare their protocol version. */ const char *line = "{\"id\":1,\"method\":\"initialize\",\"params\":{}}"; cbm_jsonrpc_request_t req = {0}; int rc = cbm_jsonrpc_parse(line, &req); - ASSERT_EQ(rc, 0); - ASSERT_STR_EQ(req.jsonrpc, "2.0"); - ASSERT_STR_EQ(req.method, "initialize"); - ASSERT_TRUE(req.has_id); + ASSERT_EQ(rc, -1); + cbm_jsonrpc_request_free(&req); + PASS(); +} + +TEST(jsonrpc_parse_rejects_invalid_version_and_id) { + cbm_jsonrpc_request_t req = {0}; + ASSERT_EQ(cbm_jsonrpc_parse( + "{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"ping\"}", &req), + -1); + cbm_jsonrpc_request_free(&req); + ASSERT_EQ(cbm_jsonrpc_parse( + "{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"ping\"}", &req), + -1); cbm_jsonrpc_request_free(&req); PASS(); } @@ -16967,6 +17040,75 @@ TEST(mcp_server_run_rapid_messages) { fclose(in_fp); PASS(); } + +#ifdef __linux__ +TEST(mcp_server_run_reports_output_failure) { + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + const char message[] = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}\n"; + ASSERT_EQ(write(fds[1], message, sizeof(message) - 1), (ssize_t)(sizeof(message) - 1)); + close(fds[1]); + + FILE *in_fp = fdopen(fds[0], "r"); + FILE *out_fp = fopen("/dev/full", "w"); + ASSERT_NOT_NULL(in_fp); + ASSERT_NOT_NULL(out_fp); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + mcp_log_buf[0] = '\0'; + CBMLogLevel previous_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_ERROR); + cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_level); + bool output_error_logged = strstr(mcp_log_buf, "stage=write") != NULL || + strstr(mcp_log_buf, "stage=flush") != NULL; + + cbm_mcp_server_free(srv); + fclose(in_fp); + fclose(out_fp); + + ASSERT_TRUE(rc != 0); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.response.write")); + ASSERT_TRUE(output_error_logged); + PASS(); +} + +TEST(mcp_server_run_reports_input_failure) { + FILE *in_fp = tmpfile(); + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(in_fp); + ASSERT_NOT_NULL(out_fp); + ASSERT_TRUE(fputs("Content-Length: nope\r\n\r\n", in_fp) >= 0); + rewind(in_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_log_buf[0] = '\0'; + CBMLogLevel previous_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_ERROR); + cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE); + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + cbm_log_set_sink(NULL); + cbm_log_set_level(previous_level); + + cbm_mcp_server_free(srv); + fclose(in_fp); + fclose(out_fp); + ASSERT_TRUE(rc != 0); + ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request.read")); + PASS(); +} +#else +TEST(mcp_server_run_reports_output_failure) { + SKIP_PLATFORM("/dev/full is Linux-specific"); +} +TEST(mcp_server_run_reports_input_failure) { + SKIP_PLATFORM("tmpfile poll behavior is Unix-specific"); +} +#endif #endif /* !_WIN32 */ /* Issue #235: passing an unrecognised project name to a tool crashed the @@ -20428,6 +20570,7 @@ SUITE(mcp) { /* JSON-RPC parsing — edge cases */ RUN_TEST(jsonrpc_parse_empty_string); RUN_TEST(jsonrpc_parse_missing_jsonrpc_field); + RUN_TEST(jsonrpc_parse_rejects_invalid_version_and_id); RUN_TEST(jsonrpc_parse_missing_method); RUN_TEST(jsonrpc_parse_string_id); RUN_TEST(jsonrpc_parse_no_params); @@ -20436,6 +20579,7 @@ SUITE(mcp) { /* JSON-RPC formatting */ RUN_TEST(jsonrpc_format_response); + RUN_TEST(jsonrpc_format_response_rejects_missing_or_malformed_payload); RUN_TEST(jsonrpc_format_error); /* MCP protocol helpers */ @@ -20639,6 +20783,7 @@ SUITE(mcp) { RUN_TEST(search_code_file_pattern_prefilter_boundaries); RUN_TEST(search_code_windows_scope_prefilter_removes_pipeline_filter); RUN_TEST(search_code_cancel_cleans_supervised_scan); + RUN_TEST(server_handle_logs_timeout_without_query); RUN_TEST(search_code_output_limit_fails_closed_and_cleans_scan); RUN_TEST(search_code_scan_deadline_fails_closed_and_resets); RUN_TEST(search_code_scan_deadline_override_is_per_server); @@ -20736,6 +20881,8 @@ SUITE(mcp) { /* Poll/getline FILE* buffering fix */ #ifndef _WIN32 RUN_TEST(mcp_server_run_rapid_messages); + RUN_TEST(mcp_server_run_reports_output_failure); + RUN_TEST(mcp_server_run_reports_input_failure); #endif /* Snippet resolution (port of snippet_test.go) */ From ffa0b320047f4db99dc53f09e228c94703436ae1 Mon Sep 17 00:00:00 2001 From: Nelson Spence Date: Mon, 28 Sep 2026 08:59:32 -0500 Subject: [PATCH 23/76] fix(py-lsp): walk each lambda body at most once per call chain py_emit_call_for re-walked a called lambda's body without tracking lambdas already being walked, so a lambda that reaches itself was re-walked once per nesting level up to the walk-depth cap, doubling with two calls per body. mpmath's calculus/extrapolation.py grows memory at about 1.9 GB/s until it is killed. Mark the registry entry as expanding during its re-walk and skip re-entry. The mpmath file now indexes in 1.1 s at about 80 MB peak RSS; the graph of the Python 3.14 stdlib + site-packages is byte-identical. Signed-off-by: Nelson Spence --- internal/cbm/lsp/py_lsp.c | 27 ++++++++++++++------ internal/cbm/lsp/py_lsp.h | 1 + tests/test_py_lsp.c | 52 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/internal/cbm/lsp/py_lsp.c b/internal/cbm/lsp/py_lsp.c index 8a0396115..039dec586 100644 --- a/internal/cbm/lsp/py_lsp.c +++ b/internal/cbm/lsp/py_lsp.c @@ -165,7 +165,7 @@ static void py_register_instance_field(PyLSPContext *ctx, const char *class_qn, static void py_bind_for_target(PyLSPContext *ctx, TSNode left, const CBMType *elem_type); static void py_register_lambda(PyLSPContext *ctx, const char *name, TSNode lambda_node); static void py_register_dict_literal(PyLSPContext *ctx, const char *name, TSNode dict_node); -static TSNode py_lookup_lambda(PyLSPContext *ctx, const char *name); +static int 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 ───────────────────────────────────────────── @@ -690,19 +690,21 @@ static void py_register_lambda(PyLSPContext *ctx, const char *name, TSNode lambd } ctx->lambdas[ctx->lambda_count].name = cbm_arena_strdup(ctx->arena, name); ctx->lambdas[ctx->lambda_count].lambda_node = lambda_node; + ctx->lambdas[ctx->lambda_count].expanding = false; ctx->lambda_count++; } -static TSNode py_lookup_lambda(PyLSPContext *ctx, const char *name) { - TSNode null_node = {0}; +/* Registry index of the lambda bound to `name`, or -1. An index, not a + * pointer: the registry array is replaced when it grows. */ +static int py_lookup_lambda(PyLSPContext *ctx, const char *name) { if (!ctx || !name) - return null_node; + return -1; for (int i = 0; i < ctx->lambda_count; i++) { if (ctx->lambdas[i].name && strcmp(ctx->lambdas[i].name, name) == 0) { - return ctx->lambdas[i].lambda_node; + return i; } } - return null_node; + return -1; } /* Helper for stripping quotes from a tree-sitter `string` node text. */ @@ -2519,11 +2521,18 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { // Walk the lambda body for any nested call sites with the params // bound to the call's arg types. Resolved calls get caller_qn // set to a synthetic child of the enclosing function. - TSNode lambda_node = py_lookup_lambda(ctx, fname); - if (!ts_node_is_null(lambda_node)) { + int lambda_idx = py_lookup_lambda(ctx, fname); + if (lambda_idx >= 0) { + TSNode lambda_node = ctx->lambdas[lambda_idx].lambda_node; TSNode params = ts_node_child_by_field_name(lambda_node, "parameters", 10); TSNode body = ts_node_child_by_field_name(lambda_node, "body", 4); TSNode args = ts_node_child_by_field_name(call_node, "arguments", 9); + // A lambda that reaches itself again (directly or through another + // lambda) would re-walk its body once per nesting level up to the + // walk-depth cap, and two such calls in one body double the work + // per level. Walk each body at most once per chain. + if (ctx->lambdas[lambda_idx].expanding) + return; CBMScope *saved = ctx->current_scope; ctx->current_scope = py_scope_push_checked(ctx); // Bind each lambda param to the call-site arg's type. @@ -2556,7 +2565,9 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) { ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.", prev_func ? prev_func : ctx->module_qn); if (!ts_node_is_null(body)) { + ctx->lambdas[lambda_idx].expanding = true; py_resolve_calls_in(ctx, body); + ctx->lambdas[lambda_idx].expanding = false; } ctx->enclosing_func_qn = prev_func; py_scope_restore(ctx, saved); diff --git a/internal/cbm/lsp/py_lsp.h b/internal/cbm/lsp/py_lsp.h index 939025100..6d1a3fb27 100644 --- a/internal/cbm/lsp/py_lsp.h +++ b/internal/cbm/lsp/py_lsp.h @@ -13,6 +13,7 @@ typedef struct { const char *name; // bound name, e.g. "fn" TSNode lambda_node; // the lambda AST node + bool expanding; // body is being re-walked for a call site right now } CBMLambdaEntry; // Function-as-dict-value entry. When `funcs = {"a": foo, "b": bar}` is diff --git a/tests/test_py_lsp.c b/tests/test_py_lsp.c index 3aaadee51..14ce8adb3 100644 --- a/tests/test_py_lsp.c +++ b/tests/test_py_lsp.c @@ -1931,6 +1931,56 @@ TEST(pylsp_lambda_rewalk_dedupes_dunder_occurrence) { PASS(); } +/* A lambda body is also re-walked for calls made inside another lambda's + * re-walk, so a lambda that reaches itself again (directly or through another + * lambda) was re-walked once per nesting level until the walk-depth cap. With + * two such calls in one body the work doubled per level, and a two-line + * `fib = lambda n: fib(n - 1) + fib(n - 2)` drove extraction to many GB. A + * lambda already being re-walked must not be re-entered, so each helper() + * site below resolves once. The recursion here is linear, so without the + * guard this stays small and fails the count instead of exhausting memory. */ +TEST(pylsp_recursive_lambda_rewalk_is_bounded) { + CBMFileResult *r = extract_py("def helper():\n" + " return 0\n" + "count = lambda n: helper() if n == 0 else count(n - 1)\n" + "even = lambda n: helper() if n == 0 else odd(n - 1)\n" + "odd = lambda n: even(n - 1)\n" + "count(3)\n" + "even(4)\n"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error || r->parse_incomplete); + + int helper_resolutions = 0; + for (int i = 0; i < r->resolved_calls.count; i++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[i]; + if (rc->callee_qn && strstr(rc->callee_qn, ".helper")) + helper_resolutions++; + } + ASSERT_EQ(helper_resolutions, 2); + + cbm_free_result(r); + PASS(); +} + +/* The re-walk guard is released after each walk, so a later call to the same + * lambda still binds its own argument type: both A.m and B.m resolve. */ +TEST(pylsp_lambda_rewalk_guard_released_between_calls) { + CBMFileResult *r = extract_py("class A:\n" + " def m(self):\n" + " return 1\n" + "class B:\n" + " def m(self):\n" + " return 2\n" + "call_m = lambda x: x.m()\n" + "call_m(A())\n" + "call_m(B())\n"); + ASSERT_NOT_NULL(r); + ASSERT_GTE(require_resolved(r, "", ".A.m"), 0); + ASSERT_GTE(require_resolved(r, "", ".B.m"), 0); + cbm_free_result(r); + PASS(); +} + /* Parser-backed Python calls have the same occurrence-identity requirement as * synthetic dunders. Distinct typed receivers can expose the same method leaf * in one caller; each CBMCall must join the semantic record for its own site. */ @@ -2243,6 +2293,8 @@ SUITE(py_lsp) { RUN_TEST(pylsp_round5_super_init); RUN_TEST(pylsp_dunder_same_leaf_occurrences_join_by_exact_site); RUN_TEST(pylsp_lambda_rewalk_dedupes_dunder_occurrence); + RUN_TEST(pylsp_recursive_lambda_rewalk_is_bounded); + RUN_TEST(pylsp_lambda_rewalk_guard_released_between_calls); RUN_TEST(pylsp_ordinary_same_leaf_calls_join_by_exact_site); /* Round 6 — generators, dataclasses, properties */ RUN_TEST(pylsp_round6_generator_yields_iterable); From b2a0a2cde52b8fc9237dbddfb347b8a058747032 Mon Sep 17 00:00:00 2001 From: Brian McNaboe Date: Wed, 30 Sep 2026 11:31:19 -0400 Subject: [PATCH 24/76] docs(cli): list daemon in --help The daemon subcommand was reachable but absent from the usage text. The description matches the hint daemon status already prints: a warm daemon lets CLI calls and hooks skip the per-command daemon start-up. It does not claim to remove a hook process's own image hash. Signed-off-by: Brian McNaboe --- src/main.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main.c b/src/main.c index fd3370c04..800898cd2 100644 --- a/src/main.c +++ b/src/main.c @@ -1110,6 +1110,9 @@ static void print_help(void) { printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n"); printf(" codebase-memory-mcp update [-y|-n]\n"); printf(" codebase-memory-mcp config \n"); + printf(" codebase-memory-mcp daemon \n"); + printf(" start keeps a daemon warm so CLI calls and\n"); + printf(" hooks skip the per-command daemon start-up\n"); printf(" codebase-memory-mcp --version Print version\n"); printf(" codebase-memory-mcp --help Print this help\n"); printf("\nCLI output options:\n"); From 48f25595ca147a65515403b03ed4e15e673e634b Mon Sep 17 00:00:00 2001 From: Kris <2040188027@qq.com> Date: Wed, 30 Sep 2026 23:34:36 +0800 Subject: [PATCH 25/76] fix(cli): read PATH at its real length and probe Windows shim dirs (#221) Two independent reasons `install` missed opencode on Windows 11: 1. find_in_path() copied PATH into a fixed 4 KB stack buffer through cbm_safe_getenv(), and that helper REFUSES a value it cannot fit instead of truncating it. On a machine whose PATH exceeds 4096 characters the whole PATH was therefore skipped before a single directory was probed. 2. The fallback list had no Windows-shaped entries, and it probed only the bare name, so npm's %APPDATA%\npm, mise's %LOCALAPPDATA%\mise\shims and scoop's %USERPROFILE%\scoop\shims could never match a .cmd shim. - Add cbm_env_dup() to platform.c: the allocating sibling of cbm_safe_getenv, memory-core owned, wide-read on Windows so a non-ASCII PATH is not mangled by the ANSI code page. cli.c no longer needs in the middle of the file, nor any raw allocation. - find_in_path() now exits once, so the PATH copy is freed on every path instead of leaking on each success. - Keep the existing pipx (~/.local/bin) and cargo (~/.cargo/bin) fallbacks, built from the home_dir argument rather than cbm_get_home_dir(), and add the npm/mise/scoop locations. PATHEXT is probed for every fallback directory, not only for PATH entries. - Bound the fallback loop by NUM_PATHS, not NUM_RETRIES. - Hoist the PATHEXT list into one file-scope constant and add braces to every one-line statement. Regression guards in tests/repro/repro_issue221.c: a PATH longer than 4096 characters whose LAST entry holds the fixture, and the Windows fallback shims driven through APPDATA / LOCALAPPDATA / the home_dir argument. Both are red against the previous code and green with this change. The PATH case is not Windows-specific: the same 4 KB cap hid entries on macOS and Linux. No raw allocator call site is added under src/, so the memory-core ratchet is untouched; clang-format and the no-skips policy both pass locally. Signed-off-by: Kris <2040188027@qq.com> --- src/cli/cli.c | 115 ++++++++++++++----- src/foundation/platform.c | 71 ++++++++++++ src/foundation/platform.h | 13 +++ tests/repro/repro_issue221.c | 208 +++++++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 28 deletions(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 6e33759e4..d8e91e7a0 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -22,6 +22,7 @@ #include "foundation/platform.h" #include "foundation/constants.h" #include "foundation/log.h" +#include "foundation/mem_core.h" #include "foundation/sha256.h" #include "cli/client_adapter.h" #include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help @@ -1076,36 +1077,50 @@ static bool is_executable(const char *path) { #endif } +/* Windows ships CLIs as extension-bearing shims -- .cmd from npm, .ps1 from + * mise, .exe from scoop -- so a bare-name probe never matches there. One + * definition serves both the PATH scan and the fallback-directory probe (#221). */ +#ifdef _WIN32 +static const char *const WIN_EXEC_EXTS[] = {".exe", ".cmd", ".bat", ".ps1", NULL}; +#endif + /* Search for an executable named `name` in the PATH environment variable. - * Returns the full path in `out` (max out_sz) if found, else empty string. */ + * Returns the full path in `out` (max out_sz) if found, else empty string. + * + * The PATH copy is sized to the REAL value, not to a fixed 4 KB stack buffer. + * That buffer was the first root cause of #221: a Windows dev machine's PATH + * routinely runs past 4096 characters, and cbm_safe_getenv() REFUSES a value it + * cannot fit rather than truncating it -- so find_in_path() returned false + * before looking at a single directory, and opencode (installed through + * mise/npm/scoop and present on PATH) read as "not installed". The same 4 KB + * cap hid entries on macOS/Linux too; the fix is not Windows-specific. */ static bool find_in_path(const char *name, char *out, size_t out_sz) { - char path_copy[CLI_BUF_4K]; - if (!cbm_safe_getenv("PATH", path_copy, sizeof(path_copy), NULL)) { + char *path_copy = cbm_env_dup("PATH"); + if (!path_copy) { return false; } - char *saveptr; + + /* One exit. PATH is a full live copy of the environment, the scan has + * several ways to stop, and the previous shape returned straight out of the + * loop -- which is exactly what leaked a PATH-sized allocation per call. */ + bool found = false; + char *saveptr = NULL; char *dir = strtok_r(path_copy, PATH_DELIM, &saveptr); - while (dir) { + while (dir != NULL && !found) { snprintf(out, out_sz, "%s/%s", dir, name); - if (is_executable(out)) { - return true; - } + found = is_executable(out); #ifdef _WIN32 - /* On Windows executables carry an extension (PATHEXT). A CLI like - * opencode is often installed as a .cmd / .ps1 / .exe shim (e.g. via - * mise or npm), so the bare-name probe above misses it (#221). Try the - * common executable extensions before moving to the next PATH entry. */ - static const char *const win_exts[] = {".exe", ".cmd", ".bat", ".ps1", NULL}; - for (int i = 0; win_exts[i]; i++) { - snprintf(out, out_sz, "%s/%s%s", dir, name, win_exts[i]); - if (is_executable(out)) { - return true; - } + /* PATHEXT variants for THIS entry, before moving to the next one. */ + for (int i = 0; !found && WIN_EXEC_EXTS[i] != NULL; i++) { + snprintf(out, out_sz, "%s/%s%s", dir, name, WIN_EXEC_EXTS[i]); + found = is_executable(out); } #endif dir = strtok_r(NULL, PATH_DELIM, &saveptr); } - return false; + + cbm_free(CBM_MEM_CLASS_OTHER, path_copy); + return found; } const char *cbm_find_cli(const char *name, const char *home_dir) { @@ -1119,22 +1134,66 @@ const char *cbm_find_cli(const char *name, const char *home_dir) { if (!home_dir || !home_dir[0]) { return ""; } + /* PATH-independent fallbacks, tried in order. Every home-derived entry is + * built from the `home_dir` ARGUMENT and not from cbm_get_home_dir(): a + * dry-run scan or a test passes a synthetic home and must not be shown the + * host's agents. `/` separators are fine on Windows -- the wide-safe file + * layer accepts them, and the rest of this file already composes paths so. */ enum { NUM_PATHS = 5 }; char paths[NUM_PATHS][CLI_BUF_512]; - snprintf(paths[0], sizeof(paths[0]), "/usr/local/bin/%s", name); - snprintf(paths[1], sizeof(paths[1]), "%s/.npm/bin/%s", home_dir, name); - snprintf(paths[2], sizeof(paths[2]), "%s/.local/bin/%s", home_dir, name); - snprintf(paths[3], sizeof(paths[3]), "%s/.cargo/bin/%s", home_dir, name); -#ifdef __APPLE__ - snprintf(paths[4], sizeof(paths[4]), "/opt/homebrew/bin/%s", name); + memset(paths, 0, sizeof(paths)); + int n = 0; +#ifdef _WIN32 + /* Locations that were already probed on Windows before #221 and are real + * there: pipx installs into ~/.local/bin and cargo into ~/.cargo/bin, so + * replacing the list rather than extending it would make pipx- and + * cargo-installed agents disappear. */ + snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name); + /* Windows-shaped locations (issue #221, second root cause): npm puts its + * global bin under %APPDATA%\npm, and mise and scoop expose shims under + * %LOCALAPPDATA%\mise\shims and %USERPROFILE%\scoop\shims. Each is read + * through its own environment seam, so the list stays testable. */ + const char *appdata = cbm_app_config_dir(); + if (appdata) { + snprintf(paths[n++], sizeof(paths[0]), "%s/npm/%s", appdata, name); + } + const char *localapp = cbm_app_local_dir(); + if (localapp) { + snprintf(paths[n++], sizeof(paths[0]), "%s/mise/shims/%s", localapp, name); + } + snprintf(paths[n++], sizeof(paths[0]), "%s/scoop/shims/%s", home_dir, name); #else - paths[4][0] = '\0'; + snprintf(paths[n++], sizeof(paths[0]), "/usr/local/bin/%s", name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.npm/bin/%s", home_dir, name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name); + snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name); +#ifdef __APPLE__ + snprintf(paths[n++], sizeof(paths[0]), "/opt/homebrew/bin/%s", name); #endif - for (int i = 0; i < NUM_RETRIES; i++) { - if (paths[i][0] && is_executable(paths[i])) { +#endif + /* NUM_PATHS, not NUM_RETRIES: both are 5 today, so the old bound only + * looked right by coincidence -- what the loop means is the list length. */ + for (int i = 0; i < NUM_PATHS; i++) { + if (!paths[i][0]) { + continue; + } + if (is_executable(paths[i])) { snprintf(buf, sizeof(buf), "%s", paths[i]); return buf; } +#ifdef _WIN32 + /* The npm/mise/scoop shims carry an extension, so every fallback + * directory needs the same PATHEXT probe the PATH scan got. */ + for (int e = 0; WIN_EXEC_EXTS[e] != NULL; e++) { + char extpath[CLI_BUF_512]; + snprintf(extpath, sizeof(extpath), "%s%s", paths[i], WIN_EXEC_EXTS[e]); + if (is_executable(extpath)) { + snprintf(buf, sizeof(buf), "%s", extpath); + return buf; + } + } +#endif } return ""; } diff --git a/src/foundation/platform.c b/src/foundation/platform.c index cf9a0f469..410536030 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -7,6 +7,7 @@ #include "foundation/compat.h" #include "foundation/constants.h" +#include "foundation/mem_core.h" #include "foundation/platform_internal.h" #include #include @@ -437,6 +438,76 @@ const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const ch return NULL; } +/* See platform.h. The long-value sibling of cbm_safe_getenv: there is no caller + * buffer to overrun, so a PATH past 4 KB is read in full instead of being + * refused (#221). Both the wide staging buffer and the UTF-8 result are + * allocated through the memory core, which is also the only allocator the + * caller may release them with -- keeping the memory-core ratchet honest means + * this file adds no raw allocation site to pay for the new read. */ +char *cbm_env_dup(const char *name) { + if (!name || !name[0]) { + return NULL; + } +#ifdef _WIN32 + /* Same wide route as cbm_safe_getenv: _environ holds ANSI-code-page bytes, + * so a non-ASCII PATH would arrive mojibake'd or with '?' substituted -- + * invalid in a Windows path and fatal to every file API downstream. */ + wchar_t wname[CBM_SZ_256]; + int wn = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wname, CBM_SZ_256); + if (wn <= 0) { + return NULL; + } + SetLastError(ERROR_SUCCESS); + DWORD needed = GetEnvironmentVariableW(wname, NULL, 0U); + DWORD lookup_error = GetLastError(); + if (needed == 0U) { + if (lookup_error != ERROR_ENVVAR_NOT_FOUND) { + /* Present but empty. Distinct from unset, and callers are entitled + * to tell the two apart, so return "" and not NULL. */ + return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, ""); + } + return NULL; + } + wchar_t *wvalue = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)needed * sizeof(*wvalue)); + if (!wvalue) { + return NULL; + } + SetLastError(ERROR_SUCCESS); + DWORD got = GetEnvironmentVariableW(wname, wvalue, needed); + DWORD read_error = GetLastError(); + if (got >= needed || (got == 0U && read_error != ERROR_SUCCESS)) { + cbm_free(CBM_MEM_CLASS_OTHER, wvalue); + return NULL; + } + int utf8_size = + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, NULL, 0, NULL, NULL); + char *utf8 = utf8_size > 0 ? cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)utf8_size) : NULL; + int converted = utf8 ? WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, utf8, + utf8_size, NULL, NULL) + : 0; + if (!utf8 || converted != utf8_size) { + cbm_free(CBM_MEM_CLASS_OTHER, utf8); + cbm_free(CBM_MEM_CLASS_OTHER, wvalue); + return NULL; + } + cbm_free(CBM_MEM_CLASS_OTHER, wvalue); + return utf8; +#else + /* Same environ walk as cbm_safe_getenv: getenv() returns a pointer another + * thread can invalidate with setenv(), so the value is copied out here. */ + char **env = CBM_ENVIRON; + if (env) { + size_t name_len = strlen(name); + for (; *env; env++) { + if (strncmp(*env, name, name_len) == 0 && (*env)[name_len] == '=') { + return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, *env + name_len + SKIP_ONE); + } + } + } + return NULL; +#endif +} + /* See platform.h. The shape here is the one src/main.c:1104 already uses for * --port=: an end pointer says where the read stopped, errno catches a number * too large, and *end == '\0' catches anything left over. */ diff --git a/src/foundation/platform.h b/src/foundation/platform.h index 43738f621..52e01dc46 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -138,6 +138,19 @@ int cbm_default_worker_count(bool initial); * Returns NULL when the variable is unset and fallback is NULL. */ const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback); +/* Allocating sibling of cbm_safe_getenv, for a value that must not be capped at + * a caller-chosen size. A Windows PATH routinely runs past the 4096-byte buffer + * cbm_safe_getenv's callers pass, and the copy helper REPORTS that as failure + * rather than truncating -- so the whole PATH was skipped and every directory + * past the cut became invisible (#221). + * + * Returns a UTF-8 copy owned by the memory core: release it with + * cbm_free(CBM_MEM_CLASS_OTHER, ptr), never free(). NULL when the variable is + * unset or cannot be read; a variable that exists but is empty yields "". + * On Windows the value is read with the wide API and converted, the same route + * cbm_safe_getenv takes, so a non-ASCII value survives intact. */ +char *cbm_env_dup(const char *name); + /* Read an environment variable as a whole number. * * Answers true only when the variable is set, is not empty, and reads cleanly diff --git a/tests/repro/repro_issue221.c b/tests/repro/repro_issue221.c index cb4d27fd4..2baf2705d 100644 --- a/tests/repro/repro_issue221.c +++ b/tests/repro/repro_issue221.c @@ -152,7 +152,215 @@ TEST(repro_issue221_opencode_pathext_lookup) { PASS(); } +/* ── #221 regression: PATH longer than the old 4 KB copy ───────────────── */ + +/* Snapshot/restore one environment variable. The suite swaps PATH and the + * Windows profile roots, and a leaked swap would poison every later suite. */ +static char *repro221_save_env(const char *name) { + const char *value = getenv(name); + return value ? strdup(value) : NULL; +} + +static void repro221_restore_env(const char *name, char *saved) { + if (saved) { + cbm_setenv(name, saved, 1); + free(saved); + } else { + cbm_unsetenv(name); + } +} + +/* + * repro_issue221_long_path_beyond_four_kb + * + * find_in_path() used to copy PATH into a fixed 4096-byte stack buffer through + * cbm_safe_getenv(). That helper does not truncate -- it REFUSES a value it + * cannot fit -- so on a machine whose PATH exceeds 4096 characters the whole + * PATH was skipped and every agent on it read as "not installed". That is the + * layout the #221 reporter had: mise, npm and scoop each append entries. + * + * The fixture directory is the LAST PATH entry, so any truncation of the copy + * hides exactly the directory under test and nothing else. This case is not + * Windows-specific -- the same 4 KB cap hid entries on macOS and Linux. + * + * RED before the fix (empty result), GREEN after it. + */ +TEST(repro_issue221_long_path_beyond_four_kb) { + const char *created = th_mktempdir("repro221long"); + if (!created) { + FAIL("th_mktempdir failed"); + } + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s", created); + +#ifdef _WIN32 + const char *fixture_name = "cbm-repro221-long.cmd"; + const char *fixture_body = "@echo off\r\nrem fake long-PATH agent\r\n"; + const char path_delim = ';'; +#else + const char *fixture_name = "cbm-repro221-long"; + const char *fixture_body = "#!/bin/sh\n# fake long-PATH agent\n"; + const char path_delim = ':'; +#endif + + char fixture_path[1024]; + snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name); + if (th_write_file(fixture_path, fixture_body) != 0) { + th_cleanup(tmpdir); + FAIL("could not write the long-PATH fixture"); + } + th_make_executable(fixture_path); + + /* + * Filler entries name directories that do not exist: the scan has to walk + * past every one of them to reach the fixture, which is appended after the + * filler so that it always sits beyond the 4096-byte mark. + */ + enum { FILLER_TARGET = 4600, PATH_CAP = FILLER_TARGET + 1024 }; + char long_path[PATH_CAP]; + size_t used = 0U; + while (used < (size_t)FILLER_TARGET) { + int written = snprintf(long_path + used, sizeof(long_path) - used, + "/nonexistent-repro221-filler%05zu%c", used, path_delim); + if (written <= 0 || (size_t)written >= sizeof(long_path) - used) { + th_cleanup(tmpdir); + FAIL("could not build the long PATH filler"); + } + used += (size_t)written; + } + ASSERT_GT(used, 4096); + int tail = snprintf(long_path + used, sizeof(long_path) - used, "%s", tmpdir); + if (tail <= 0 || (size_t)tail >= sizeof(long_path) - used) { + th_cleanup(tmpdir); + FAIL("could not append the fixture directory to the long PATH"); + } + + char *saved_path = repro221_save_env("PATH"); + cbm_setenv("PATH", long_path, 1); + + /* A home that does not exist keeps the PATH-independent fallbacks out of + * the way: the fixture's directory is the only place that can satisfy it. */ + const char *result = cbm_find_cli("cbm-repro221-long", "/nonexistent-repro221-home"); + + repro221_restore_env("PATH", saved_path); + + ASSERT_NOT_NULL(result); + ASSERT(result[0] != '\0'); + ASSERT(strncmp(result, tmpdir, strlen(tmpdir)) == 0); + ASSERT(strstr(result, "cbm-repro221-long") != NULL); + + th_cleanup(tmpdir); + PASS(); +} + +/* ── #221 regression: the Windows fallback shim locations ───────────────── */ + +/* + * repro_issue221_windows_fallback_shims + * + * An agent the PATH scan cannot see must still be found where Windows actually + * puts CLIs: %APPDATA%\npm (npm's global bin), %LOCALAPPDATA%\mise\shims, + * %USERPROFILE%\scoop\shims, plus the pipx and cargo homes (~\.local\bin, + * ~\.cargo\bin) that were already probed before #221 passed and must not be + * dropped by the fix. + * + * Each location is reached through its own seam -- APPDATA / LOCALAPPDATA, or + * the home_dir argument -- so this never reads the developer's real profile. + * Every shim carries a .cmd extension, which is why the fallback probe needs + * the same PATHEXT treatment the PATH scan got. + * + * Windows-only: the directory layout it asserts is the one Windows installers + * produce. */ +TEST(repro_issue221_windows_fallback_shims) { +#ifndef _WIN32 + SKIP_PLATFORM("Windows-only: npm/mise/scoop shim fallback layout"); +#else + const char *created = th_mktempdir("repro221fb"); + if (!created) { + FAIL("th_mktempdir failed"); + } + char root[512]; + snprintf(root, sizeof(root), "%s", created); + + char *saved_appdata = repro221_save_env("APPDATA"); + char *saved_localappdata = repro221_save_env("LOCALAPPDATA"); + char *saved_userprofile = repro221_save_env("USERPROFILE"); + char *saved_path = repro221_save_env("PATH"); + + /* APPDATA/LOCALAPPDATA/USERPROFILE all point at the fixture root. The + * scoop, pipx and cargo entries are built from the home_dir ARGUMENT, so + * USERPROFILE here mirrors what a real profile would supply. */ + cbm_setenv("APPDATA", root, 1); + cbm_setenv("LOCALAPPDATA", root, 1); + cbm_setenv("USERPROFILE", root, 1); + cbm_setenv("PATH", "/nonexistent-repro221-path", 1); + + /* One shim at a time, so the returned path proves which entry matched + * rather than merely that something did. Each case removes its shim before + * the next one runs, so the earlier entries cannot shadow the later ones. */ + const struct { + const char *subdir; + const char *label; + } cases[] = { + {".local/bin", "pipx home"}, {".cargo/bin", "cargo home"}, {"npm", "APPDATA npm"}, + {"mise/shims", "LOCALAPPDATA mise"}, {"scoop/shims", "scoop home"}, + }; + const char *shim_name = "cbm-repro221-fb.cmd"; + const size_t case_count = sizeof(cases) / sizeof(cases[0]); + + for (size_t i = 0; i < case_count; i++) { + char dir[512]; + snprintf(dir, sizeof(dir), "%s/%s", root, cases[i].subdir); + if (th_mkdir_p(dir) != 0) { + repro221_restore_env("PATH", saved_path); + repro221_restore_env("USERPROFILE", saved_userprofile); + repro221_restore_env("LOCALAPPDATA", saved_localappdata); + repro221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("could not create the fallback fixture directory"); + } + char shim[1024]; + snprintf(shim, sizeof(shim), "%s/%s", dir, shim_name); + if (th_write_file(shim, "@echo off\r\nrem fake fallback shim\r\n") != 0) { + repro221_restore_env("PATH", saved_path); + repro221_restore_env("USERPROFILE", saved_userprofile); + repro221_restore_env("LOCALAPPDATA", saved_localappdata); + repro221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("could not write the fallback shim"); + } + + const char *result = cbm_find_cli("cbm-repro221-fb", root); + char expected_prefix[512]; + snprintf(expected_prefix, sizeof(expected_prefix), "%s/%s/", root, cases[i].subdir); + bool matched = result != NULL && result[0] != '\0' && + strncmp(result, expected_prefix, strlen(expected_prefix)) == 0; + (void)remove(shim); + + if (!matched) { + printf(" fallback case %zu (%s) did not resolve in %s\n", i, cases[i].label, dir); + repro221_restore_env("PATH", saved_path); + repro221_restore_env("USERPROFILE", saved_userprofile); + repro221_restore_env("LOCALAPPDATA", saved_localappdata); + repro221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("a Windows fallback shim location was not probed"); + } + } + + repro221_restore_env("PATH", saved_path); + repro221_restore_env("USERPROFILE", saved_userprofile); + repro221_restore_env("LOCALAPPDATA", saved_localappdata); + repro221_restore_env("APPDATA", saved_appdata); + + th_cleanup(root); + PASS(); +#endif +} + /* ── Suite ──────────────────────────────────────────────────────────────── */ SUITE(repro_issue221) { RUN_TEST(repro_issue221_opencode_pathext_lookup); + RUN_TEST(repro_issue221_long_path_beyond_four_kb); + RUN_TEST(repro_issue221_windows_fallback_shims); } From 72dc59fed58444ca7d5e7a4749fb767c28e48b94 Mon Sep 17 00:00:00 2001 From: Edson Date: Thu, 1 Oct 2026 10:54:24 -0400 Subject: [PATCH 26/76] fix(haskell): name infix-form definitions after the operator (#2440) A Haskell definition written in infix form (`a <+> b = ...`, ``x `plus` y = ...``) has no `name` field. resolve_haskell_func_name took the first child of its `infix` head, which is the left operand, so the def was indexed as `a` / `x` and the operator itself got no node. Resolve the head's `operator` field instead: a backticked `infix_id` yields its inner variable (`plus`), and a symbolic `operator` is parenthesised in cbm_func_name_node_text (`(<+>)`), matching the name the prefix form `(<+>) a b = ...` already gets. The def extractor and the call-scope QN both go through these two functions, so they stay in agreement. Fixes #2440 Signed-off-by: Edson --- internal/cbm/extract_defs.c | 30 ++++++++++++++++++++++++++++++ internal/cbm/helpers.c | 8 ++++++++ tests/test_extraction.c | 28 ++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b655d06c1..ac8d449f0 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -405,6 +405,33 @@ static TSNode resolve_lean_func_name(TSNode node, TSNode name) { return cbm_find_child_by_kind(node, "ident"); } +// Haskell infix-form definition head (`a <+> b`, ``x `plus` y``): the defined +// name is the operator, not the left operand that is the head's first child. A +// backticked `infix_id` yields its inner variable (`plus`); a symbolic `operator` +// is returned as-is and parenthesised by cbm_func_name_node_text, matching the +// prefix form `(<+>) a b = ...` (#2440). +static TSNode resolve_haskell_infix_head_name(TSNode head) { + enum { INFIX_MIN_CHILDREN = 3, INFIX_OP_IDX = 1 }; + TSNode op = ts_node_child_by_field_name(head, TS_FIELD("operator")); + if (ts_node_is_null(op) && ts_node_child_count(head) >= INFIX_MIN_CHILDREN) { + op = ts_node_child(head, INFIX_OP_IDX); + } + if (!ts_node_is_null(op)) { + const char *ok = ts_node_type(op); + if (strcmp(ok, "operator") == 0) { + return op; + } + if (strcmp(ok, "infix_id") == 0) { + TSNode v = cbm_find_child_by_kind(op, "variable"); + if (!ts_node_is_null(v)) { + return v; + } + } + } + TSNode null_node = {0}; + return null_node; +} + // Haskell: resolve function name from first named child (variable/name). static TSNode resolve_haskell_func_name(TSNode node) { if (ts_node_named_child_count(node) > 0) { @@ -413,6 +440,9 @@ static TSNode resolve_haskell_func_name(TSNode node) { if (strcmp(hk, "variable") == 0 || strcmp(hk, "name") == 0) { return head; } + if (strcmp(hk, "infix") == 0) { + return resolve_haskell_infix_head_name(head); + } if (ts_node_named_child_count(head) > 0) { TSNode v = ts_node_named_child(head, 0); const char *vk = ts_node_type(v); diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 4c4d340f0..630e8f038 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1042,6 +1042,14 @@ char *cbm_func_name_node_text(CBMArena *a, TSNode name_node, const char *source, if (text && lang == CBM_LANG_NIX) { cbm_nix_strip_attr_quotes(text); } + /* Haskell infix-form operator definition: `a <+> b = …` resolves to the bare + * `operator` node `<+>`, while the prefix form `(<+>) a b = …` is named by its + * `prefix_id` text `(<+>)`. Parenthesise here, not in the resolver, so both + * forms mint the same name and the def name and the call-scope QN, which both + * route through this function, cannot disagree (#2440). */ + if (text && lang == CBM_LANG_HASKELL && strcmp(ts_node_type(name_node), "operator") == 0) { + return cbm_arena_sprintf(a, "(%s)", text); + } return text; } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5a21fe3e2..a6aa82cf7 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1377,6 +1377,33 @@ TEST(haskell_function) { PASS(); } +/* #2440: an infix-form definition (`a <+> b = ...`, ``x `plus` y = ...``) has + * no `name` field; its first named child is an `infix` node whose first child + * is the LEFT OPERAND, so the def was named `a` / `x`. It must be named after + * the operator, consistent with the prefix form `(<+>) a b = ...`. */ +TEST(haskell_infix_definition_name) { + CBMFileResult *r = extract("module M where\n" + "\n" + "(<+>) :: Int -> Int -> Int\n" + "a <+> b = a + b\n" + "\n" + "plus :: Int -> Int -> Int\n" + "x `plus` y = x + y\n" + "\n" + "f :: Int\n" + "f = 1 <+> 2\n", + CBM_LANG_HASKELL, "t", "M.hs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "f")); + ASSERT(has_def(r, "Function", "plus")); + ASSERT(has_def(r, "Function", "(<+>)")); + ASSERT_FALSE(has_def(r, "Function", "a")); + ASSERT_FALSE(has_def(r, "Function", "x")); + cbm_free_result(r); + PASS(); +} + /* --- OCaml --- */ TEST(ocaml_function) { CBMFileResult *r = @@ -8954,6 +8981,7 @@ SUITE(extraction) { RUN_TEST(elixir_function); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); + RUN_TEST(haskell_infix_definition_name); RUN_TEST(ocaml_function); RUN_TEST(erlang_function); From 937ad47e55706e086777a8690008bb202199c883 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sat, 3 Oct 2026 13:50:43 +0800 Subject: [PATCH 27/76] fix(cypher): defer WHERE until referenced bindings exist Signed-off-by: lorenzozanee --- src/cypher/cypher.c | 52 +++++++++++++++++++++++++- tests/test_cypher.c | 89 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 3cc31f9d7..2a18db7e8 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -4145,7 +4145,54 @@ static void execute_with_simple(cbm_return_clause_t *wc, binding_t *bindings, in } } -/* Apply post-WITH WHERE filter */ +static bool condition_bindings_available(const cbm_condition_t *condition, binding_t *binding) { + if (condition->variable && !binding_get(binding, condition->variable) && + !binding_get_edge(binding, condition->variable)) { + return false; + } + for (int i = 0; i < condition->arg_count; i++) { + const char *var = condition->args[i].variable; + if (var && !binding_get(binding, var) && !binding_get_edge(binding, var)) { + return false; + } + } + return true; +} + +static bool expr_bindings_available(const cbm_expr_t *expr, binding_t *binding) { + if (!expr) { + return true; + } + switch (expr->type) { + case EXPR_CONDITION: + return condition_bindings_available(&expr->cond, binding); + case EXPR_AND: + case EXPR_OR: + case EXPR_XOR: + return expr_bindings_available(expr->left, binding) && + expr_bindings_available(expr->right, binding); + case EXPR_NOT: + return expr_bindings_available(expr->left, binding); + } + return false; +} + +static bool where_bindings_available(const cbm_where_clause_t *where, binding_t *binding) { + if (!where) { + return true; + } + if (where->root) { + return expr_bindings_available(where->root, binding); + } + for (int i = 0; i < where->count; i++) { + if (!condition_bindings_available(&where->conditions[i], binding)) { + return false; + } + } + return true; +} + +/* Apply a WHERE filter after its referenced variables have been bound. */ static void filter_bindings_where(const cbm_where_clause_t *where, binding_t *vbindings, int *vcount) { int kept = 0; @@ -5149,7 +5196,8 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec binding_t b = {0}; b.store = store; binding_set(&b, var_name, &scanned[i]); - bool pass = !q->where || eval_where(q->where, &b); + bool pass = !q->where || !where_bindings_available(q->where, &b) || + eval_where(q->where, &b); if (pass) { bindings[bind_count++] = b; } else { diff --git a/tests/test_cypher.c b/tests/test_cypher.c index abd334683..b8e4ddb1f 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -1757,6 +1757,32 @@ TEST(cypher_exists_has_outgoing_calls) { ASSERT_EQ(rc, 0); ASSERT_EQ(r.row_count, 2); cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE b.name <> \"SubmitOrder\" " + "RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE b.name = \"SubmitOrder\" OR " + "NOT b.name = \"SubmitOrder\" RETURN count(*)", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "3"); + cbm_cypher_result_free(&r); + + rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WITH a, b WHERE NOT b.name = " + "\"SubmitOrder\" RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + cbm_store_close(s); PASS(); } @@ -3150,6 +3176,65 @@ TEST(cypher_exec_where_not) { PASS(); } +TEST(cypher_exec_where_not_target_node) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = \"SubmitOrder\" " + "RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 2); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_target_relationship) { + cbm_store_t *s = setup_cypher_multi_edge_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[r:HTTP_CALLS]->(b) WHERE NOT r.url_path = " + "\"/health\" RETURN r.url_path", + "testproj", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "/api/orders"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_source_node) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT a.name = " + "\"HandleOrder\" RETURN a.name, b.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + +TEST(cypher_exec_where_not_target_node_count) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE NOT b.name = " + "\"SubmitOrder\" RETURN count(*)", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "2"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_in) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -5005,6 +5090,10 @@ SUITE(cypher) { RUN_TEST(cypher_exec_where_neq_bang); RUN_TEST(cypher_exec_where_ends_with); RUN_TEST(cypher_exec_where_not); + RUN_TEST(cypher_exec_where_not_target_node); + RUN_TEST(cypher_exec_where_not_target_relationship); + RUN_TEST(cypher_exec_where_not_source_node); + RUN_TEST(cypher_exec_where_not_target_node_count); RUN_TEST(cypher_exec_where_in); RUN_TEST(cypher_exec_where_not_in); RUN_TEST(cypher_exec_where_is_null); From 2a83fe50e4f67df01be1770c3e103ed91b52f539 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Sat, 3 Oct 2026 23:20:36 +0800 Subject: [PATCH 28/76] test(mcp): cover project discovery after store switches Signed-off-by: lorenzozanee --- tests/test_mcp.c | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 22af972af..af108e7f3 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5452,7 +5452,7 @@ TEST(tool_query_graph_prefix_directory_recovers_rows_beyond_raw_estimate) { PASS(); } -TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct) { +TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-list-lean-XXXXXX", cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(cache)); @@ -5509,6 +5509,38 @@ TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct) { bool tree_not_duplicated = tree_response && !strstr(tree_response, "structuredContent"); bool page_truthful = page && strstr(page, "projects: 5") && strstr(page, "returned: 5") && strstr(page, "has_more: true") && strstr(page, "next_offset: 5"); + bool projects_resolve_after_switches = true; + for (int i = PROJECTS - 1; i >= 0; i--) { + char project[32]; + char args[96]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + snprintf(args, sizeof(args), "{\"project\":\"%s\"}", project); + + char *status_response = cbm_mcp_handle_tool(srv, "index_status", args); + char *status = extract_text_content(status_response); + if (!status || !strstr(status, "ready")) { + projects_resolve_after_switches = false; + } + free(status); + free(status_response); + + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\"entry\"}", + project); + char *search_response = cbm_mcp_handle_tool(srv, "search_graph", args); + char *search = extract_text_content(search_response); + if (!search || !strstr(search, "entry")) { + projects_resolve_after_switches = false; + } + free(search); + free(search_response); + } + char *post_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); + char *post_switch = extract_text_content(post_switch_response); + bool list_complete_after_switches = + post_switch && strstr(post_switch, "total: 12") && strstr(post_switch, "lean-project-00") && + strstr(post_switch, "lean-project-11"); + free(post_switch); + free(post_switch_response); bool json_direct = false; bool json_structured = json_response && strstr(json_response, "structuredContent"); yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL; @@ -5545,6 +5577,8 @@ TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct) { ASSERT_TRUE(tree_is_leaner); ASSERT_TRUE(tree_not_duplicated); ASSERT_TRUE(page_truthful); + ASSERT_TRUE(projects_resolve_after_switches); + ASSERT_TRUE(list_complete_after_switches); ASSERT_TRUE(json_direct); ASSERT_TRUE(json_structured); PASS(); @@ -21142,7 +21176,7 @@ SUITE(mcp) { RUN_TEST(tool_query_graph_budget_bounds_first_row_and_json_escaping); RUN_TEST(tool_query_graph_prefix_directory_is_lossless_and_json_stays_direct); RUN_TEST(tool_query_graph_prefix_directory_recovers_rows_beyond_raw_estimate); - RUN_TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct); + RUN_TEST(tool_list_projects_stays_complete_across_store_switches_issue1115); RUN_TEST(tool_list_projects_preserves_root_beyond_one_kib); RUN_TEST(tool_index_status_no_project); RUN_TEST(tool_check_index_coverage_finds_path_beyond_status_cap); From 21c224fba56fe1b6b55cb13d003f5ab7f9199264 Mon Sep 17 00:00:00 2001 From: lorenzozanee Date: Tue, 6 Oct 2026 07:59:14 +0800 Subject: [PATCH 29/76] fix(pipeline): resolve constants in template URLs Signed-off-by: lorenzozanee --- internal/cbm/extract_calls.c | 15 ++++++++------- internal/cbm/extract_unified.c | 4 ++-- internal/cbm/helpers.c | 34 ++++++++++++++++++++++++++------- internal/cbm/helpers.h | 9 ++++----- tests/test_pipeline.c | 35 ++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 21 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index f14efb766..1ec05de2a 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2192,9 +2192,9 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { if (is_string_like(ak) && ca->expr) { ca->value = strip_quotes(ctx->arena, ca->expr); } else if (strcmp(ak, "template_string") == 0) { - /* Flattened {} form so downstream url-arg detection joins the - * canonical server route shape (issue #1006/#1009). */ - ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source); + /* Resolve known constants; dynamic substitutions keep the route placeholder. */ + ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source, + &ctx->string_constants); } else if (strcmp(ak, "identifier") == 0 && ca->expr) { ca->value = lookup_string_constant(ctx, ca->expr); } else if (strcmp(ak, "object") == 0) { @@ -2259,7 +2259,8 @@ static bool is_queue_topic_field(const char *key) { static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node) { const char *vk = ts_node_type(val_node); if (strcmp(vk, "template_string") == 0) { - return cbm_template_string_text(ctx->arena, val_node, ctx->source); + return cbm_template_string_text(ctx->arena, val_node, ctx->source, + &ctx->string_constants); } if (is_string_like(vk)) { char *text = cbm_node_text(ctx->arena, val_node, ctx->source); @@ -2434,10 +2435,10 @@ static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) { } static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) { - /* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the - * client URL joins the server route's canonical placeholder (issue #1006). */ + /* Resolve constants while keeping dynamic route parts in canonical form. */ if (strcmp(ak, "template_string") == 0) { - const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source); + const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source, + &ctx->string_constants); if (flat) { return strip_and_validate_string_arg(ctx->arena, (char *)flat); } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 086f501a0..cce2100b7 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1212,7 +1212,7 @@ static void handle_string_constants(CBMExtractCtx *ctx, TSNode node, const WalkS const char *value_kind = ts_node_type(value_node); const char *flat_value = NULL; if (strcmp(value_kind, "template_string") == 0) { - flat_value = cbm_template_string_text(ctx->arena, value_node, ctx->source); + flat_value = cbm_template_string_text(ctx->arena, value_node, ctx->source, NULL); if (!flat_value) { return; } @@ -1263,7 +1263,7 @@ static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState * template strings become string_refs with the canonical placeholder shape * shared with server route paths (issue #1006). */ if (strcmp(kind, "template_string") == 0) { - const char *flat = cbm_template_string_text(ctx->arena, node, ctx->source); + const char *flat = cbm_template_string_text(ctx->arena, node, ctx->source, NULL); if (!flat) { return; } diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 4c4d340f0..86c54c4e9 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1803,13 +1803,10 @@ int cbm_classify_string(const char *str, int len) { return NOT_FOUND; } -/* Flatten a JS/TS `template_string` node into plain text (issue #1006). - * String fragments are kept verbatim; each ${...} substitution becomes the - * "{}" placeholder so client URLs built from template literals share the - * canonical parameter shape of server-side route paths - * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields - * no text or exceeds the route-sized buffer. */ -const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { +/* Flatten a JS/TS template string, resolving known string constants and + * replacing other substitutions with the route placeholder. */ +const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source, + const CBMStringConstantMap *string_constants) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; size_t pos = 0; @@ -1829,6 +1826,29 @@ const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *sourc memcpy(buf + pos, frag, fl); pos += fl; } else if (strcmp(k, "template_substitution") == 0) { + const char *resolved = NULL; + TSNode expr = ts_node_named_child(c, 0); + if (string_constants && !ts_node_is_null(expr) && + strcmp(ts_node_type(expr), "identifier") == 0) { + char *name = cbm_node_text(a, expr, source); + for (int j = 0; name && j < string_constants->count; j++) { + if (!string_constants->is_url_builder[j] && string_constants->names[j] && + string_constants->values[j] && + strcmp(string_constants->names[j], name) == 0) { + resolved = string_constants->values[j]; + break; + } + } + } + if (resolved) { + size_t rl = strlen(resolved); + if (pos + rl >= TPL_BUF) { + return NULL; + } + memcpy(buf + pos, resolved, rl); + pos += rl; + continue; + } if (pos + PAIR_LEN >= TPL_BUF) { return NULL; } diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 00e3d30dc..16c132d10 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -253,10 +253,9 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char * // Folder QN: project.dir_parts char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir); -/* Flatten a JS/TS `template_string` node into plain text: string fragments are - * kept verbatim and each ${...} substitution becomes the "{}" placeholder, so - * client-side URLs built from template literals share the canonical parameter - * shape that server-side route paths already use. NULL when empty/oversized. */ -const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source); +/* Flatten JS/TS template strings for URL extraction. Known string constants + * resolve; other substitutions become "{}". NULL when empty/oversized. */ +const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source, + const CBMStringConstantMap *string_constants); #endif // CBM_HELPERS_H diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ed9b0dd3c..7c8d61cbe 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8158,6 +8158,40 @@ TEST(pipeline_native_fetch_classified_as_http_calls) { PASS(); } +TEST(pipeline_native_fetch_template_base_url_makes_route) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_template_base_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/api.ts", + "const BASE_URL = \"http://payments:8080\";\n" + "export async function createPayment(body: unknown) {\n" + " return fetch(`${BASE_URL}/v1/payments`, { method: 'POST', body });\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/fetch_template_base.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + int http_call_count = cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"); + int matching_route_count = + http_calls_to_route(s, project, "createPayment", "/v1/payments"); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + ASSERT_EQ(http_call_count, 1); + ASSERT_EQ(matching_route_count, 1); + PASS(); +} + /* #1892: Swift produced no Route node and no HTTP_CALLS edge, because the * Swift grammar has no "arguments" field and the generic lookup therefore read * no call arguments at all. Alamofire/URLSession were already in the service @@ -17671,6 +17705,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_ts_config_object_url_http_calls_issue2235); RUN_TEST(pipeline_arg_url_rejects_document_file_paths); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); + RUN_TEST(pipeline_native_fetch_template_base_url_makes_route); RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892); RUN_TEST(pipeline_swift_http_call_makes_route_issue1892); RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); From 104dc21b045d5236a341c0967bef866a146fb0e0 Mon Sep 17 00:00:00 2001 From: lixsp11 <30531760+Lixsp11@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:28:45 +0900 Subject: [PATCH 30/76] fix(store): use 64-bit file offsets in the SQLite page writer sqlite_writer.c computed page offsets as (long)(page - 1) * 65536 and passed them to fseek()/ftell(). long is 32 bits on Windows, so once a database grows past 2 GiB the offset goes negative and fseek() fails; the result is ignored, so pages land wherever the stream happens to be. Past 4 GiB the offset wraps back to small positive values and the writer overwrites the start of the file. The staging database stops at exactly 4 GiB, is corrupt, and the run ends in persist_failed. Route every seek/tell in the writer through 64-bit wrappers (_fseeki64/_ftelli64 on Windows, fseeko/ftello elsewhere) and compute offsets with a new cbm_writer_page_offset() helper that widens before multiplying. Fixes #2399 Signed-off-by: lixsp11 <30531760+Lixsp11@users.noreply.github.com> --- internal/cbm/sqlite_writer.c | 37 ++++++++++++++++++++++-------------- internal/cbm/sqlite_writer.h | 4 ++++ tests/test_sqlite_writer.c | 13 +++++++++++++ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/internal/cbm/sqlite_writer.c b/internal/cbm/sqlite_writer.c index f965da1ce..d684ff3a0 100644 --- a/internal/cbm/sqlite_writer.c +++ b/internal/cbm/sqlite_writer.c @@ -35,13 +35,22 @@ #include #include #define cbm_writer_getpid _getpid +/* long is 32 bits on Windows, so fseek/ftell cannot address past 2 GiB there. */ +#define cbm_writer_seek(fp, off, whence) _fseeki64((fp), (long long)(off), (whence)) +#define cbm_writer_tell(fp) ((int64_t)_ftelli64(fp)) #else #include #define cbm_writer_getpid getpid +#define cbm_writer_seek(fp, off, whence) fseeko((fp), (off_t)(off), (whence)) +#define cbm_writer_tell(fp) ((int64_t)ftello(fp)) #endif #define CBM_PAGE_SIZE 65536 +int64_t cbm_writer_page_offset(uint32_t page_num) { + return (int64_t)(page_num - SKIP_ONE) * CBM_PAGE_SIZE; +} + /* SQLite reserves the page containing the 1 GiB file offset (the "pending byte" * used for file locking on Windows). This page MUST be skipped during allocation * otherwise integrity_check reports "2nd reference to page N" because it marks @@ -601,8 +610,8 @@ static void pb_flush_leaf(PageBuilder *pb) { // Write page to file. Skip the pending byte page (SQLite reserved). pb->next_page = cbm_skip_pending_byte(pb->next_page); uint32_t page_num = pb->next_page; - long offset = (long)(page_num - SKIP_ONE) * CBM_PAGE_SIZE; - (void)fseek(pb->fp, offset, SEEK_SET); + int64_t offset = cbm_writer_page_offset(page_num); + (void)cbm_writer_seek(pb->fp, offset, SEEK_SET); (void)fwrite(pb->page, SKIP_ONE, CBM_PAGE_SIZE, pb->fp); // Record this leaf for interior page building @@ -707,7 +716,7 @@ static int write_interior_page(PageBuilder *pb, uint8_t *page, int cell_count, i page[HDR_FRAGBYTES_OFF] = 0; put_u32(page + HDR_RIGHTCHILD_OFF, right_child_page); - (void)fseek(pb->fp, (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE, SEEK_SET); + (void)cbm_writer_seek(pb->fp, cbm_writer_page_offset(pnum), SEEK_SET); (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, pb->fp); if (parent_count >= *parent_cap) { @@ -970,7 +979,7 @@ static uint32_t write_overflow_pages(FILE *fp, uint32_t *next_page, const uint8_ int data_len) { int per_page = CBM_PAGE_SIZE - BTREE_PTR_SIZE; uint32_t first_page = 0; - long prev_next_ptr_offset = -SKIP_ONE; + int64_t prev_next_ptr_offset = -SKIP_ONE; int offset = 0; while (offset < data_len) { @@ -983,7 +992,7 @@ static uint32_t write_overflow_pages(FILE *fp, uint32_t *next_page, const uint8_ if (prev_next_ptr_offset >= 0) { uint8_t ptr[BTREE_PTR_SIZE]; put_u32(ptr, pnum); - (void)fseek(fp, prev_next_ptr_offset, SEEK_SET); + (void)cbm_writer_seek(fp, prev_next_ptr_offset, SEEK_SET); (void)fwrite(ptr, SKIP_ONE, BTREE_PTR_SIZE, fp); } @@ -997,9 +1006,9 @@ static uint32_t write_overflow_pages(FILE *fp, uint32_t *next_page, const uint8_ put_u32(page, 0); // next-page pointer — 0 for now, backpatched on next iteration memcpy(page + BTREE_PTR_SIZE, data + offset, chunk); - long page_offset = (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE; + int64_t page_offset = cbm_writer_page_offset(pnum); prev_next_ptr_offset = page_offset; - (void)fseek(fp, page_offset, SEEK_SET); + (void)cbm_writer_seek(fp, page_offset, SEEK_SET); (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, fp); offset += chunk; @@ -1276,7 +1285,7 @@ static uint32_t write_table_btree(FILE *fp, uint32_t *next_page, const uint8_t * put_u16(page + hdr + HDR_CELLCOUNT_OFF, 0); // 0 cells put_u16(page + hdr + HDR_CONTENT_OFF, (uint16_t)CBM_PAGE_SIZE); // content at end of page page[hdr + HDR_FRAGBYTES_OFF] = 0; // 0 fragmented bytes - (void)fseek(fp, (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE, SEEK_SET); + (void)cbm_writer_seek(fp, cbm_writer_page_offset(pnum), SEEK_SET); (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, fp); return pnum; } @@ -1326,7 +1335,7 @@ static uint32_t write_empty_index_leaf(FILE *fp, uint32_t *next_page) { put_u16(page + HDR_CELLCOUNT_OFF, 0); put_u16(page + HDR_CONTENT_OFF, (uint16_t)CBM_PAGE_SIZE); page[HDR_FRAGBYTES_OFF] = 0; - (void)fseek(fp, (long)(pnum - SKIP_ONE) * CBM_PAGE_SIZE, SEEK_SET); + (void)cbm_writer_seek(fp, cbm_writer_page_offset(pnum), SEEK_SET); (void)fwrite(page, SKIP_ONE, CBM_PAGE_SIZE, fp); return pnum; } @@ -2020,7 +2029,7 @@ static int write_master_page1(FILE *fp, MasterEntry *master, int master_count, u write_sqlite_file_header(page1, next_page - SKIP_ONE); - (void)fseek(fp, 0, SEEK_SET); + (void)cbm_writer_seek(fp, 0, SEEK_SET); (void)fwrite(page1, SKIP_ONE, CBM_PAGE_SIZE, fp); for (int i = 0; i < master_count; i++) { @@ -2034,12 +2043,12 @@ static int write_master_page1(FILE *fp, MasterEntry *master, int master_count, u /* Pad file to exact page boundary. */ static void pad_file_to_page_boundary(FILE *fp, uint32_t next_page) { - (void)fseek(fp, 0, SEEK_END); - long file_size = ftell(fp); - long expected_size = (long)(next_page - SKIP_ONE) * CBM_PAGE_SIZE; + (void)cbm_writer_seek(fp, 0, SEEK_END); + int64_t file_size = cbm_writer_tell(fp); + int64_t expected_size = cbm_writer_page_offset(next_page); if (file_size < expected_size) { uint8_t zero = 0; - (void)fseek(fp, expected_size - SKIP_ONE, SEEK_SET); + (void)cbm_writer_seek(fp, expected_size - SKIP_ONE, SEEK_SET); (void)fwrite(&zero, SKIP_ONE, SKIP_ONE, fp); } } diff --git a/internal/cbm/sqlite_writer.h b/internal/cbm/sqlite_writer.h index 1142e7d57..b92a13719 100644 --- a/internal/cbm/sqlite_writer.h +++ b/internal/cbm/sqlite_writer.h @@ -90,4 +90,8 @@ int cbm_writer_finalize(cbm_db_writer_t *w, const char *project, const char *roo CBMDumpEdge *edges, int edge_count, CBMDumpVector *vectors, int vector_count, CBMDumpTokenVec *token_vecs, int token_vec_count); +// Byte offset of 1-based page `page_num` in the output file. 64-bit on every +// platform, so databases larger than 2 GiB are addressed correctly on Windows. +int64_t cbm_writer_page_offset(uint32_t page_num); + #endif // CBM_SQLITE_WRITER_H diff --git a/tests/test_sqlite_writer.c b/tests/test_sqlite_writer.c index bacf06a95..cbeb2dfc3 100644 --- a/tests/test_sqlite_writer.c +++ b/tests/test_sqlite_writer.c @@ -996,6 +996,18 @@ TEST(sw_publish_preserves_live_reader) { /* ── Suite ─────────────────────────────────────────────────────── */ +TEST(sw_page_offsets_do_not_wrap_past_4gib) { + /* A 32-bit long offset wraps negative at 2 GiB and back to 0 at 4 GiB: on + * Windows a staging DB stopped at exactly 4 GiB with its first pages + * overwritten, and publish failed with persist_failed (#2399). */ + ASSERT_EQ(cbm_writer_page_offset(1), 0); + ASSERT_EQ(cbm_writer_page_offset(2), 65536); + ASSERT_EQ(cbm_writer_page_offset(32769), INT64_C(2147483648)); + ASSERT_EQ(cbm_writer_page_offset(65537), INT64_C(4294967296)); + ASSERT_EQ(cbm_writer_page_offset(UINT32_MAX), (int64_t)(UINT32_MAX - 1) * 65536); + PASS(); +} + TEST(sw_open_truncated_path_names_its_reason) { /* final_path is a fixed 4K buffer and make_writer_temp_path only fails by * truncation. Neither sets an errno of its own, so without naming one the @@ -1065,6 +1077,7 @@ SUITE(sqlite_writer) { RUN_TEST(sw_publish_failure_preserves_destination_sidecars); RUN_TEST(sw_publish_supports_non_ascii_path); RUN_TEST(sw_publish_preserves_live_reader); + RUN_TEST(sw_page_offsets_do_not_wrap_past_4gib); RUN_TEST(sw_open_truncated_path_names_its_reason); RUN_TEST(sw_publish_failure_reports_the_rename_not_the_cleanup); } From f1e36b583d3a21664b373f854466c676897a5560 Mon Sep 17 00:00:00 2001 From: H4M4CHi-ttr <42773515+H4M4CHi-ttr@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:49:39 +0900 Subject: [PATCH 31/76] fix(watcher): inspect repository and cache paths as UTF-8 Use existing UTF-8 filesystem inspection for watcher roots, .git markers, and cached graph databases. Preserve symlink following and distinguish confirmed absence from unavailable inspection before pruning projects. Cover an existing Unicode root with zero prune grace and capture the blocked-git test environment through the existing UTF-8 runtime helper. Split the independently applicable bug fix from the original certified EmbeddingGemma 2 contribution without changing its file contents. Signed-off-by: H4M4CHi-ttr <42773515+H4M4CHi-ttr@users.noreply.github.com> --- src/daemon/application.c | 4 ++-- src/watcher/watcher.c | 36 +++++++++++++++++++++++++--------- tests/test_watcher.c | 42 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index ee121b5c6..581d435a5 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -448,8 +448,8 @@ static bool application_regular_db_exists(const char *project) { if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat status; - return stat(path, &status) == 0 && S_ISREG(status.st_mode); + cbm_path_info_t status; + return cbm_path_info_utf8(path, &status) == CBM_PATH_INFO_OK && status.is_regular; } static bool application_canonical_directory_exists(const char *path) { diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index f8d04c4c6..ac7c47b7f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -522,14 +522,28 @@ static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *s * a `.git` file (the gitlink form used by linked worktrees and initialized * submodules). Deliberately a filesystem check, not a git invocation — the whole * point is to learn something `rev-parse` cannot tell us, because it walks up. */ +/* Preserve stat's link-following behavior while inspecting UTF-8 paths. */ +static int watcher_path_info(const char *path, cbm_path_info_t *info) { + int status = cbm_path_info_utf8(path, info); + if (status == CBM_PATH_INFO_OK && info->is_symlink) { + char canonical[CBM_SZ_4K]; + if (!cbm_canonical_path(path, canonical, sizeof(canonical))) { + return CBM_PATH_INFO_UNAVAILABLE; + } + status = cbm_path_info_utf8(canonical, info); + } + return status; +} + static bool git_has_own_dot_git(const char *root_path) { char path[CBM_SZ_4K]; int written = snprintf(path, sizeof(path), "%s/.git", root_path); if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat st; - return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode)); + cbm_path_info_t info; + return watcher_path_info(path, &info) == CBM_PATH_INFO_OK && + (info.is_directory || info.is_regular); } /* Does the ancestor repository actually track anything inside this directory? @@ -990,13 +1004,17 @@ static root_status_t root_status(const char *root_path, int *out_errno) { if (!root_path) { return ROOT_UNCERTAIN; } - struct stat st; - if (stat(root_path, &st) == 0) { - /* Exists but is no longer a directory → the root directory is gone. */ - return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING; + cbm_path_info_t info; + int status = watcher_path_info(root_path, &info); + if (status == CBM_PATH_INFO_OK) { + return info.is_directory ? ROOT_PRESENT : ROOT_MISSING; } +#ifdef _WIN32 + *out_errno = status == CBM_PATH_INFO_ABSENT ? ENOENT : EIO; +#else *out_errno = errno; - return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN; +#endif + return status == CBM_PATH_INFO_ABSENT ? ROOT_MISSING : ROOT_UNCERTAIN; } /* Sustained-absence window (seconds) before a missing root may be pruned. @@ -1338,8 +1356,8 @@ static void publish_status(project_state_t *s, bool scanned) { /* Init baseline for a project: check if git, get HEAD, count files */ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { - struct stat st; - if (stat(s->root_path, &st) != 0) { + cbm_path_info_t info; + if (watcher_path_info(s->root_path, &info) != CBM_PATH_INFO_OK || !info.is_directory) { cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path); s->baseline_done = true; s->is_git = false; diff --git a/tests/test_watcher.c b/tests/test_watcher.c index e0b78cffc..8be483ca8 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1103,8 +1103,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { watcher_windows_image_identity_t expected_git; ASSERT_TRUE(watcher_windows_capture_image_identity(fake_git, &expected_git)); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? cbm_strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? cbm_strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -1238,8 +1238,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { ASSERT_TRUE(watcher_test_self_image(self)); ASSERT_EQ(symlink(self, fake_git), 0); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -3995,7 +3995,41 @@ TEST(watcher_null_watch_count) { * SUITE * ══════════════════════════════════════════════════════════════════ */ +TEST(watcher_unicode_root_is_not_pruned_as_missing) { + char *parent = th_mktempdir("cbm_watch_utf8"); + ASSERT_NOT_NULL(parent); + char root[CBM_SZ_4K]; + snprintf(root, sizeof(root), "%s/\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E", parent); + ASSERT_EQ(th_mkdir_p(root), 0); + const char *saved = cbm_runtime_getenv("CBM_WATCHER_PRUNE_GRACE_S"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1); + cbm_store_t *store = cbm_store_open_memory(); + cbm_store_upsert_project(store, "unicode-root", root); + cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); + cbm_watcher_watch(watcher, "unicode-root", root); + for (int i = 0; i < 8; i++) { + cbm_watcher_poll_once(watcher); + } + cbm_project_t info = {0}; + bool kept = cbm_watcher_watch_count(watcher) == 1 && + cbm_store_get_project(store, "unicode-root", &info) == CBM_STORE_OK; + cbm_project_free_fields(&info); + cbm_watcher_free(watcher); + cbm_store_close(store); + if (saved_copy) { + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_copy, 1); + } else { + cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); + } + free(saved_copy); + ASSERT_EQ(th_rmtree(parent), 0); + ASSERT_TRUE(kept); + PASS(); +} + SUITE(watcher) { + RUN_TEST(watcher_unicode_root_is_not_pruned_as_missing); /* Adaptive interval */ RUN_TEST(poll_interval_base); RUN_TEST(poll_interval_scaling); From 6530142fe18af967c6cd87ba91a9447cf029a5ea Mon Sep 17 00:00:00 2001 From: Scott Beck <296871+bline@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:11:27 -0600 Subject: [PATCH 32/76] fix(doc-links): report references into a moved directory as missing (#2588) A Markdown reference whose directory no longer exists counted as no reference to the repository: no MENTIONS edge and no unresolved row. After `git mv src/util src/helpers`, `src/util/rounding.py::clamp` simply disappeared, so a moved or renamed directory left no trace of the stale reference. The doc-rot rule required the path's own parent directory to be a Folder. It now also accepts any directory above it that the reference itself wrote: `src` for `src/util/rounding.py` read from the root, or for `../src/util/rounding.py` read from docs/. The directory a try is read from (the document's directory, or what its leading `./` and `../` reach) does not count, so a document's own directory never turns a foreign path into doc rot. A path none of whose written directories exists (a file a tutorial asks the reader to create in a new directory, a system path) stays no reference, as before. Test (tests/test_doc_links_md.c) indexes a document that references src/util/rounding.py both from the root and relatively, moves src/util to src/helpers, and re-indexes incrementally, requiring the same state as a full index. Without the fix it fails on the defect: the reference has no row ("" != "missing"). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Scott Beck <296871+bline@users.noreply.github.com> --- src/pipeline/doc_links_md.c | 51 ++++++++++++++++++++++++++++++++----- tests/test_doc_links_md.c | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/src/pipeline/doc_links_md.c b/src/pipeline/doc_links_md.c index 9e76d41a4..b23a53c9b 100644 --- a/src/pipeline/doc_links_md.c +++ b/src/pipeline/doc_links_md.c @@ -16,16 +16,18 @@ * document wrote the path. * * Unresolved: - * missing the path is not in the graph, but its directory is: the - * document names something that is not there (doc rot) + * missing the path is not in the graph, but its directory is, or a + * directory above it that the reference wrote is: the document + * names something that is not there (doc rot; a moved or + * renamed directory) * ambiguous a file name alone that is not next to the document but exists * elsewhere in the repository: the document does not say which * Not a reference to this repository at all (LOCAL: no edge, no row): a link * back into the same document; a path that is neither indexed nor of a type * the indexer reads (an image, an archive, an extensionless page); a path - * whose directory is not in the repository either (a file a tutorial asks - * the reader to create, a system path, a path above the root); and, in a - * code span, a rooted single segment without a file extension, which is a + * none of whose directories is in the repository either (a file a tutorial + * asks the reader to create, a system path, a path above the root); and, in + * a code span, a rooted single segment without a file extension, which is a * URL route (`/docs`, `/health`), not the directory of that name. */ #include "pipeline/doc_links.h" @@ -408,6 +410,25 @@ static const cbm_gbuf_node_t *mdr_folder(const mdr_index_t *x, const cbm_gbuf_t return path[0] ? (const cbm_gbuf_node_t *)cbm_ht_get(x->folders, path) : NULL; } +/* Whether a directory above `path` that the reference itself wrote, one + * longer than the `base_len` bytes it was read from, is a Folder. */ +static bool mdr_written_ancestor(const mdr_index_t *x, const cbm_gbuf_t *graph, const char *path, + size_t base_len) { + char dir[MDR_PATH_CAP]; + size_t n = strlen(path); + if (n >= sizeof(dir)) { + return false; + } + memcpy(dir, path, n + SKIP_ONE); + for (char *s = strrchr(dir, '/'); s && (size_t)(s - dir) > base_len; s = strrchr(dir, '/')) { + *s = '\0'; + if (mdr_folder(x, graph, dir)) { + return true; + } + } + return false; +} + static bool mdr_code_label(const char *label) { for (size_t i = 0; label && MDR_CODE_LABELS[i]; i++) { if (strcmp(label, MDR_CODE_LABELS[i]) == 0) { @@ -1126,8 +1147,19 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons char tries[MDR_TRIES][MDR_PATH_CAP]; int nt = 0; int attempted = 0; + size_t base_len[MDR_TRIES]; /* the directory each try reads from, before what p wrote */ if (!rooted) { attempted++; + char lead[MDR_PATH_CAP]; /* p's leading `./` and `../` segments */ + char base[MDR_PATH_CAP]; + size_t ll = 0; + while (ll < sizeof(lead) - PAIR_LEN - 1 && (strncmp(p + ll, "./", PAIR_LEN) == 0 || + strncmp(p + ll, "../", PAIR_LEN + 1) == 0)) { + ll += p[ll + 1] == '/' ? PAIR_LEN : PAIR_LEN + 1; + } + memcpy(lead, p, ll); + lead[ll] = '\0'; + base_len[nt] = mdr_join(doc_dir, lead, base, sizeof(base)) ? strlen(base) : 0; nt += mdr_join(doc_dir, p, tries[nt], MDR_PATH_CAP); } if (!relative && !r->name_only) { @@ -1137,7 +1169,7 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons q++; } if (mdr_join("", q, tries[nt], MDR_PATH_CAP) && (nt == 0 || strcmp(tries[0], tries[nt]))) { - nt++; + base_len[nt++] = 0; } } for (int i = 0; i < nt; i++) { @@ -1174,7 +1206,8 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons return; } /* doc rot when the reference points into the repository: its directory is - * there (a bare name in a link: the document's own directory) */ + * there (a bare name in a link: the document's own directory), or a + * directory above it that the reference wrote is (one moved or renamed) */ for (int i = 0; i < nt; i++) { char parent[MDR_PATH_CAP]; const char *ps = strrchr(tries[i], '/'); @@ -1185,6 +1218,10 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons out->reason = CBM_DOCLINK_REASON_MISSING; return; } + if (pl > 0 && strchr(p, '/') && mdr_written_ancestor(x, graph, tries[i], base_len[i])) { + out->reason = CBM_DOCLINK_REASON_MISSING; + return; + } if (pl == 0 && r->link && !rooted) { out->reason = CBM_DOCLINK_REASON_MISSING; return; diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index 21e74f37e..54d1d3a0d 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -444,6 +444,55 @@ TEST(doc_links_md_incremental) { PASS(); } +/* #2588: a reference into a directory that was moved or renamed named a + * path whose directory is gone, so it counted as no reference to this + * repository: no edge and no row. A directory above it that the reference + * wrote is still there, so it is doc rot. A path none of whose directories + * the repository has stays no reference. */ +TEST(doc_links_md_moved_directory_issue2588) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_mv_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + th_write_file(TH_PATH(repo, "docs/notes.md"), "# Notes\n" + "\n" + "See `src/util/rounding.py::clamp` and " + "[rounding](../src/util/rounding.py).\n" + "Create `scratch/new/thing.py` first.\n"); + th_write_file(TH_PATH(repo, "src/ledger.py"), "def balance(ledger):\n" + " return sum(ledger)\n"); + th_write_file(TH_PATH(repo, "src/util/rounding.py"), "def clamp(x, lo, hi):\n" + " return max(lo, min(hi, x))\n"); + char db[512]; + char full_db[512]; + snprintf(db, sizeof(db), "%s/inc.db", tmp); + snprintf(full_db, sizeof(full_db), "%s/full.db", tmp); + ASSERT_EQ(dm_index(repo, db, NULL), 0); + char props[512]; + char reason[64]; + ASSERT_EQ(md_edge(db, "Notes", "rounding.clamp", props, sizeof(props)), 1); + + /* src/util moves to src/helpers: both references are missing, on the + * incremental route as in a full index */ + th_write_file(TH_PATH(repo, "src/helpers/rounding.py"), "def clamp(x, lo, hi):\n" + " return max(lo, min(hi, x))\n"); + ASSERT_EQ(th_rmtree(TH_PATH(repo, "src/util")), 0); + ASSERT_EQ(dm_step(repo, db, full_db, "directory moved", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0); + ASSERT_EQ(md_edge(db, "Notes", "rounding.clamp", props, sizeof(props)), 0); + dm_row(db, "docs/notes.md", "src/util/rounding.py::clamp", reason, sizeof(reason), NULL, 0); + ASSERT_STR_EQ(reason, "missing"); + dm_row(db, "docs/notes.md", "../src/util/rounding.py", reason, sizeof(reason), NULL, 0); + ASSERT_STR_EQ(reason, "missing"); + + /* no directory of it in the repository: still no reference */ + dm_row(db, "docs/notes.md", "scratch/new/thing.py", reason, sizeof(reason), NULL, 0); + ASSERT_STR_EQ(reason, ""); + + th_cleanup(tmp); + PASS(); +} + /* ── ADRs ────────────────────────────────────────────────────────── */ static void md_write_adrs(const char *tmp, const char *first_status) { @@ -866,6 +915,7 @@ SUITE(doc_links_md) { RUN_TEST(doc_links_md_classify_span); RUN_TEST(doc_links_md_resolve); RUN_TEST(doc_links_md_incremental); + RUN_TEST(doc_links_md_moved_directory_issue2588); RUN_TEST(doc_links_md_adr); RUN_TEST(doc_links_md_adr_label_choice); RUN_TEST(doc_links_md_adr_reported); From a76600db096721e37931ffdc6e3254e574263832 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 06:21:53 +0200 Subject: [PATCH 33/76] revert(extract): drop template-URL constant resolution now done by the URL fold Since 77bc4927 (merged with #2513), the extractor folds a template URL over a module string constant before the template flattener runs, so fetch(`${BASE_URL}/v1/payments`) with a literal BASE_URL already yields an HTTP_CALLS edge to the /v1/payments route (#2540). Resolving the constant a second time inside cbm_template_string_text is no longer needed, so the production part of this change is dropped and the helper keeps its original signature. The pipeline regression test from this PR stays: it fails on main before #2513 (no HTTP_CALLS edge) and passes on current main. Signed-off-by: Martin Vogel --- internal/cbm/extract_calls.c | 15 +++++++-------- internal/cbm/extract_unified.c | 4 ++-- internal/cbm/helpers.c | 34 +++++++--------------------------- internal/cbm/helpers.h | 9 +++++---- 4 files changed, 21 insertions(+), 41 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 1ec05de2a..f14efb766 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2192,9 +2192,9 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { if (is_string_like(ak) && ca->expr) { ca->value = strip_quotes(ctx->arena, ca->expr); } else if (strcmp(ak, "template_string") == 0) { - /* Resolve known constants; dynamic substitutions keep the route placeholder. */ - ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source, - &ctx->string_constants); + /* Flattened {} form so downstream url-arg detection joins the + * canonical server route shape (issue #1006/#1009). */ + ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source); } else if (strcmp(ak, "identifier") == 0 && ca->expr) { ca->value = lookup_string_constant(ctx, ca->expr); } else if (strcmp(ak, "object") == 0) { @@ -2259,8 +2259,7 @@ static bool is_queue_topic_field(const char *key) { static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node) { const char *vk = ts_node_type(val_node); if (strcmp(vk, "template_string") == 0) { - return cbm_template_string_text(ctx->arena, val_node, ctx->source, - &ctx->string_constants); + return cbm_template_string_text(ctx->arena, val_node, ctx->source); } if (is_string_like(vk)) { char *text = cbm_node_text(ctx->arena, val_node, ctx->source); @@ -2435,10 +2434,10 @@ static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) { } static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) { - /* Resolve constants while keeping dynamic route parts in canonical form. */ + /* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the + * client URL joins the server route's canonical placeholder (issue #1006). */ if (strcmp(ak, "template_string") == 0) { - const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source, - &ctx->string_constants); + const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source); if (flat) { return strip_and_validate_string_arg(ctx->arena, (char *)flat); } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index cce2100b7..086f501a0 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1212,7 +1212,7 @@ static void handle_string_constants(CBMExtractCtx *ctx, TSNode node, const WalkS const char *value_kind = ts_node_type(value_node); const char *flat_value = NULL; if (strcmp(value_kind, "template_string") == 0) { - flat_value = cbm_template_string_text(ctx->arena, value_node, ctx->source, NULL); + flat_value = cbm_template_string_text(ctx->arena, value_node, ctx->source); if (!flat_value) { return; } @@ -1263,7 +1263,7 @@ static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState * template strings become string_refs with the canonical placeholder shape * shared with server route paths (issue #1006). */ if (strcmp(kind, "template_string") == 0) { - const char *flat = cbm_template_string_text(ctx->arena, node, ctx->source, NULL); + const char *flat = cbm_template_string_text(ctx->arena, node, ctx->source); if (!flat) { return; } diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 86c54c4e9..4c4d340f0 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1803,10 +1803,13 @@ int cbm_classify_string(const char *str, int len) { return NOT_FOUND; } -/* Flatten a JS/TS template string, resolving known string constants and - * replacing other substitutions with the route placeholder. */ -const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source, - const CBMStringConstantMap *string_constants) { +/* Flatten a JS/TS `template_string` node into plain text (issue #1006). + * String fragments are kept verbatim; each ${...} substitution becomes the + * "{}" placeholder so client URLs built from template literals share the + * canonical parameter shape of server-side route paths + * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields + * no text or exceeds the route-sized buffer. */ +const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; size_t pos = 0; @@ -1826,29 +1829,6 @@ const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *sourc memcpy(buf + pos, frag, fl); pos += fl; } else if (strcmp(k, "template_substitution") == 0) { - const char *resolved = NULL; - TSNode expr = ts_node_named_child(c, 0); - if (string_constants && !ts_node_is_null(expr) && - strcmp(ts_node_type(expr), "identifier") == 0) { - char *name = cbm_node_text(a, expr, source); - for (int j = 0; name && j < string_constants->count; j++) { - if (!string_constants->is_url_builder[j] && string_constants->names[j] && - string_constants->values[j] && - strcmp(string_constants->names[j], name) == 0) { - resolved = string_constants->values[j]; - break; - } - } - } - if (resolved) { - size_t rl = strlen(resolved); - if (pos + rl >= TPL_BUF) { - return NULL; - } - memcpy(buf + pos, resolved, rl); - pos += rl; - continue; - } if (pos + PAIR_LEN >= TPL_BUF) { return NULL; } diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 16c132d10..00e3d30dc 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -253,9 +253,10 @@ char *cbm_fqn_compute_source_lang(CBMArena *a, const char *project, const char * // Folder QN: project.dir_parts char *cbm_fqn_folder(CBMArena *a, const char *project, const char *rel_dir); -/* Flatten JS/TS template strings for URL extraction. Known string constants - * resolve; other substitutions become "{}". NULL when empty/oversized. */ -const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source, - const CBMStringConstantMap *string_constants); +/* Flatten a JS/TS `template_string` node into plain text: string fragments are + * kept verbatim and each ${...} substitution becomes the "{}" placeholder, so + * client-side URLs built from template literals share the canonical parameter + * shape that server-side route paths already use. NULL when empty/oversized. */ +const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source); #endif // CBM_HELPERS_H From 2e7244fe4ccf56dbee2c8ebeb45837ce25e02bdd Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 07:46:54 +0200 Subject: [PATCH 34/76] test(mcp): give the #1115 store-switch coverage its own test The store-switch regression for #1115 was added inside tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct and that test was renamed after it, so the name no longer said what its seven list_projects shape assertions check. Restore that test exactly as it is on main and move the switch loop unchanged into tool_project_discovery_agrees_across_store_switches_issue1115 with its own twelve-project fixture. The new test also checks list_projects before the switches, so it covers both the first-touch index_status/query case and the before/after list agreement the issue asked for. Signed-off-by: Martin Vogel --- tests/test_mcp.c | 137 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 101 insertions(+), 36 deletions(-) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4b858250c..ce22116b8 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -5914,7 +5914,7 @@ TEST(tool_query_graph_prefix_directory_recovers_rows_beyond_raw_estimate) { PASS(); } -TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { +TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct) { char cache[256]; snprintf(cache, sizeof(cache), "%s/cbm-list-lean-XXXXXX", cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(cache)); @@ -5971,38 +5971,6 @@ TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { bool tree_not_duplicated = tree_response && !strstr(tree_response, "structuredContent"); bool page_truthful = page && strstr(page, "projects: 5") && strstr(page, "returned: 5") && strstr(page, "has_more: true") && strstr(page, "next_offset: 5"); - bool projects_resolve_after_switches = true; - for (int i = PROJECTS - 1; i >= 0; i--) { - char project[32]; - char args[96]; - snprintf(project, sizeof(project), "lean-project-%02d", i); - snprintf(args, sizeof(args), "{\"project\":\"%s\"}", project); - - char *status_response = cbm_mcp_handle_tool(srv, "index_status", args); - char *status = extract_text_content(status_response); - if (!status || !strstr(status, "ready")) { - projects_resolve_after_switches = false; - } - free(status); - free(status_response); - - snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\"entry\"}", - project); - char *search_response = cbm_mcp_handle_tool(srv, "search_graph", args); - char *search = extract_text_content(search_response); - if (!search || !strstr(search, "entry")) { - projects_resolve_after_switches = false; - } - free(search); - free(search_response); - } - char *post_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); - char *post_switch = extract_text_content(post_switch_response); - bool list_complete_after_switches = - post_switch && strstr(post_switch, "total: 12") && strstr(post_switch, "lean-project-00") && - strstr(post_switch, "lean-project-11"); - free(post_switch); - free(post_switch_response); bool json_direct = false; bool json_structured = json_response && strstr(json_response, "structuredContent"); yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL; @@ -6039,13 +6007,109 @@ TEST(tool_list_projects_stays_complete_across_store_switches_issue1115) { ASSERT_TRUE(tree_is_leaner); ASSERT_TRUE(tree_not_duplicated); ASSERT_TRUE(page_truthful); - ASSERT_TRUE(projects_resolve_after_switches); - ASSERT_TRUE(list_complete_after_switches); ASSERT_TRUE(json_direct); ASSERT_TRUE(json_structured); PASS(); } +/* #1115: with more indexed projects than were resident at once, the + * least-recently-used one answered "project not found or not indexed" from + * index_status, and list_projects and index_status disagreed in the same + * session. The server keeps one project store open and switches it whenever a + * call names another project, so twelve projects visited one after another + * force a switch on every step and each visit is the first touch of a project + * that is not resident. Every project must resolve through index_status and a + * query, and list_projects must report all twelve before and after the + * switches. */ +TEST(tool_project_discovery_agrees_across_store_switches_issue1115) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-switch-1115-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + enum { PROJECTS = 12 }; + for (int i = 0; i < PROJECTS; i++) { + char project[32]; + char db_path[512]; + char root_path[512]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(root_path, sizeof(root_path), + "/workspaces/organization/shared/services/lean-project-%02d", i); + cbm_store_t *store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, root_path), CBM_STORE_OK); + cbm_node_t node = {.project = project, + .label = "Function", + .name = "entry", + .qualified_name = "shared.module.entry", + .file_path = "src/main.c", + .start_line = 1, + .end_line = 1}; + ASSERT_GT(cbm_store_upsert_node(store, &node), 0); + cbm_store_close(store); + } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char *pre_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); + char *pre_switch = extract_text_content(pre_switch_response); + bool list_complete_before_switches = pre_switch && strstr(pre_switch, "total: 12") && + strstr(pre_switch, "lean-project-00") && + strstr(pre_switch, "lean-project-11"); + free(pre_switch); + free(pre_switch_response); + + bool projects_resolve_after_switches = true; + for (int i = PROJECTS - 1; i >= 0; i--) { + char project[32]; + char args[96]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + snprintf(args, sizeof(args), "{\"project\":\"%s\"}", project); + + char *status_response = cbm_mcp_handle_tool(srv, "index_status", args); + char *status = extract_text_content(status_response); + if (!status || !strstr(status, "ready")) { + projects_resolve_after_switches = false; + } + free(status); + free(status_response); + + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\"entry\"}", project); + char *search_response = cbm_mcp_handle_tool(srv, "search_graph", args); + char *search = extract_text_content(search_response); + if (!search || !strstr(search, "entry")) { + projects_resolve_after_switches = false; + } + free(search); + free(search_response); + } + char *post_switch_response = cbm_mcp_handle_tool(srv, "list_projects", "{\"limit\":50}"); + char *post_switch = extract_text_content(post_switch_response); + bool list_complete_after_switches = post_switch && strstr(post_switch, "total: 12") && + strstr(post_switch, "lean-project-00") && + strstr(post_switch, "lean-project-11"); + free(post_switch); + free(post_switch_response); + + cbm_mcp_server_free(srv); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + for (int i = 0; i < PROJECTS; i++) { + char project[32]; + snprintf(project, sizeof(project), "lean-project-%02d", i); + cleanup_project_db(cache, project); + } + cbm_rmdir(cache); + + ASSERT_TRUE(list_complete_before_switches); + ASSERT_TRUE(projects_resolve_after_switches); + ASSERT_TRUE(list_complete_after_switches); + PASS(); +} + /* The stored project root is dynamic identity, not a display-sized field. * Keep a syntactically valid segmented path beyond 1 KiB intact in both wire * encodings; the default detail also routes that same value through branch @@ -25105,7 +25169,8 @@ SUITE(mcp) { RUN_TEST(tool_query_graph_budget_bounds_first_row_and_json_escaping); RUN_TEST(tool_query_graph_prefix_directory_is_lossless_and_json_stays_direct); RUN_TEST(tool_query_graph_prefix_directory_recovers_rows_beyond_raw_estimate); - RUN_TEST(tool_list_projects_stays_complete_across_store_switches_issue1115); + RUN_TEST(tool_list_projects_tree_uses_one_stable_header_and_keeps_json_direct); + RUN_TEST(tool_project_discovery_agrees_across_store_switches_issue1115); RUN_TEST(tool_list_projects_preserves_root_beyond_one_kib); RUN_TEST(tool_index_status_no_project); RUN_TEST(tool_ingest_traces_stores_observed_calls); From 788dbe075ea62e83b817582267fb734c09a6419b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 18:33:16 +0200 Subject: [PATCH 35/76] fix(platform): cbm_env_dup answers through the settings resolver (#221) Since this branch was written, main routes cbm_safe_getenv through a startup settings resolver before it reads the process environment. cbm_env_dup, its allocating sibling, read only the process environment, so after the merge the two helpers could give different answers for the same name. Consult the resolver first, exactly as cbm_safe_getenv does: a handled value is copied through the memory core, and a handled NULL masks the variable. The resolver serves CBM_* settings today, so PATH lookups behave as before; this keeps both helpers one view of the environment by construction. platform_env_dup_honours_the_settings_resolver pins the three cases: resolved, masked, and falling through to the process environment. Signed-off-by: Martin Vogel --- src/foundation/platform.c | 6 +++++ src/foundation/platform.h | 4 +++- tests/test_platform.c | 48 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/foundation/platform.c b/src/foundation/platform.c index ee407e258..d11df686e 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -478,6 +478,12 @@ char *cbm_env_dup(const char *name) { if (!name || !name[0]) { return NULL; } + /* The same view cbm_safe_getenv gives: the startup settings resolver + * answers first, and a handled NULL masks the variable. */ + const char *resolved = NULL; + if (environment_resolver && environment_resolver(name, &resolved)) { + return resolved ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, resolved) : NULL; + } #ifdef _WIN32 /* Same wide route as cbm_safe_getenv: _environ holds ANSI-code-page bytes, * so a non-ASCII PATH would arrive mojibake'd or with '?' substituted -- diff --git a/src/foundation/platform.h b/src/foundation/platform.h index aa3536b96..40b020222 100644 --- a/src/foundation/platform.h +++ b/src/foundation/platform.h @@ -157,7 +157,9 @@ const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const * cbm_free(CBM_MEM_CLASS_OTHER, ptr), never free(). NULL when the variable is * unset or cannot be read; a variable that exists but is empty yields "". * On Windows the value is read with the wide API and converted, the same route - * cbm_safe_getenv takes, so a non-ASCII value survives intact. */ + * cbm_safe_getenv takes, so a non-ASCII value survives intact. Like + * cbm_safe_getenv, an installed settings resolver answers before the process + * environment does. */ char *cbm_env_dup(const char *name); /* Read an environment variable as a whole number. diff --git a/tests/test_platform.c b/tests/test_platform.c index d1434c2ee..8b18a4884 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -7,6 +7,7 @@ #include "../src/foundation/compat_regex.h" #include "../src/foundation/constants.h" #include "../src/foundation/compat_thread.h" +#include "../src/foundation/mem_core.h" #include "../src/foundation/platform.h" #include "../src/foundation/platform_internal.h" #include "../src/foundation/system_info_internal.h" @@ -895,6 +896,52 @@ TEST(platform_env_long_refuses_what_it_cannot_read) { PASS(); } +/* cbm_env_dup (#221) gives the same environment view as cbm_safe_getenv: an + * installed settings resolver answers first, a handled NULL masks the + * variable, and an unhandled name falls through to the process environment. */ +static bool platform_env_dup_test_resolver(const char *name, const char **value) { + if (strcmp(name, "CBM_TEST_ENV_DUP_RESOLVED") == 0) { + *value = "from-settings"; + return true; + } + if (strcmp(name, "CBM_TEST_ENV_DUP_MASKED") == 0) { + *value = NULL; + return true; + } + return false; +} + +TEST(platform_env_dup_honours_the_settings_resolver) { + ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_RESOLVED", "from-environment", 1), 0); + ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_MASKED", "from-environment", 1), 0); + ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_NATIVE", "from-environment", 1), 0); + + cbm_set_environment_resolver(platform_env_dup_test_resolver); + char *resolved = cbm_env_dup("CBM_TEST_ENV_DUP_RESOLVED"); + char *masked = cbm_env_dup("CBM_TEST_ENV_DUP_MASKED"); + char *native = cbm_env_dup("CBM_TEST_ENV_DUP_NATIVE"); + char safe[64]; + const char *safe_value = cbm_safe_getenv("CBM_TEST_ENV_DUP_RESOLVED", safe, sizeof(safe), NULL); + cbm_set_environment_resolver(NULL); + + bool safe_ok = safe_value && strcmp(safe_value, "from-settings") == 0; + bool resolved_ok = resolved && strcmp(resolved, "from-settings") == 0; + bool masked_ok = masked == NULL; + bool native_ok = native && strcmp(native, "from-environment") == 0; + cbm_free(CBM_MEM_CLASS_OTHER, resolved); + cbm_free(CBM_MEM_CLASS_OTHER, masked); + cbm_free(CBM_MEM_CLASS_OTHER, native); + (void)cbm_unsetenv("CBM_TEST_ENV_DUP_RESOLVED"); + (void)cbm_unsetenv("CBM_TEST_ENV_DUP_MASKED"); + (void)cbm_unsetenv("CBM_TEST_ENV_DUP_NATIVE"); + + ASSERT_TRUE(safe_ok); + ASSERT_TRUE(resolved_ok); + ASSERT_TRUE(masked_ok); + ASSERT_TRUE(native_ok); + PASS(); +} + #ifdef _WIN32 /* cbm_safe_getenv reads Windows' wide environment as UTF-8. Its matching * setter must update that same wide environment; _putenv_s alone interprets @@ -1420,6 +1467,7 @@ SUITE(platform) { RUN_TEST(platform_default_workers_env_unset); RUN_TEST(platform_env_long_reads_a_clean_number); RUN_TEST(platform_env_long_refuses_what_it_cannot_read); + RUN_TEST(platform_env_dup_honours_the_settings_resolver); RUN_TEST(platform_system_info); RUN_TEST(regex_compile_refuses_oversized_expansion); RUN_TEST(regex_compile_accepts_expansion_under_budget); From 079fb2067a8432310dac88240f926000b94f0582 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 18:33:20 +0200 Subject: [PATCH 36/76] test(cli): run the #221 long-PATH and Windows shim regressions in the cli suite The two regressions sat in tests/repro/repro_issue221.c, which builds only into the non-gating bug-repro runner, so CI could not stop the fix from regressing. The Windows-only case also skipped on macOS and Linux, and scripts/repro.sh reports any skipped case as an incomplete board. Move both into tests/test_cli.c next to the other cbm_find_cli tests, unchanged apart from their names: the cli suite runs on every CI leg, and SKIP_PLATFORM is how that suite already marks Windows-only cases. tests/repro/repro_issue221.c is back to its content on main. Signed-off-by: Martin Vogel --- tests/repro/repro_issue221.c | 208 ----------------------------------- tests/test_cli.c | 208 +++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 208 deletions(-) diff --git a/tests/repro/repro_issue221.c b/tests/repro/repro_issue221.c index 2baf2705d..cb4d27fd4 100644 --- a/tests/repro/repro_issue221.c +++ b/tests/repro/repro_issue221.c @@ -152,215 +152,7 @@ TEST(repro_issue221_opencode_pathext_lookup) { PASS(); } -/* ── #221 regression: PATH longer than the old 4 KB copy ───────────────── */ - -/* Snapshot/restore one environment variable. The suite swaps PATH and the - * Windows profile roots, and a leaked swap would poison every later suite. */ -static char *repro221_save_env(const char *name) { - const char *value = getenv(name); - return value ? strdup(value) : NULL; -} - -static void repro221_restore_env(const char *name, char *saved) { - if (saved) { - cbm_setenv(name, saved, 1); - free(saved); - } else { - cbm_unsetenv(name); - } -} - -/* - * repro_issue221_long_path_beyond_four_kb - * - * find_in_path() used to copy PATH into a fixed 4096-byte stack buffer through - * cbm_safe_getenv(). That helper does not truncate -- it REFUSES a value it - * cannot fit -- so on a machine whose PATH exceeds 4096 characters the whole - * PATH was skipped and every agent on it read as "not installed". That is the - * layout the #221 reporter had: mise, npm and scoop each append entries. - * - * The fixture directory is the LAST PATH entry, so any truncation of the copy - * hides exactly the directory under test and nothing else. This case is not - * Windows-specific -- the same 4 KB cap hid entries on macOS and Linux. - * - * RED before the fix (empty result), GREEN after it. - */ -TEST(repro_issue221_long_path_beyond_four_kb) { - const char *created = th_mktempdir("repro221long"); - if (!created) { - FAIL("th_mktempdir failed"); - } - char tmpdir[512]; - snprintf(tmpdir, sizeof(tmpdir), "%s", created); - -#ifdef _WIN32 - const char *fixture_name = "cbm-repro221-long.cmd"; - const char *fixture_body = "@echo off\r\nrem fake long-PATH agent\r\n"; - const char path_delim = ';'; -#else - const char *fixture_name = "cbm-repro221-long"; - const char *fixture_body = "#!/bin/sh\n# fake long-PATH agent\n"; - const char path_delim = ':'; -#endif - - char fixture_path[1024]; - snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name); - if (th_write_file(fixture_path, fixture_body) != 0) { - th_cleanup(tmpdir); - FAIL("could not write the long-PATH fixture"); - } - th_make_executable(fixture_path); - - /* - * Filler entries name directories that do not exist: the scan has to walk - * past every one of them to reach the fixture, which is appended after the - * filler so that it always sits beyond the 4096-byte mark. - */ - enum { FILLER_TARGET = 4600, PATH_CAP = FILLER_TARGET + 1024 }; - char long_path[PATH_CAP]; - size_t used = 0U; - while (used < (size_t)FILLER_TARGET) { - int written = snprintf(long_path + used, sizeof(long_path) - used, - "/nonexistent-repro221-filler%05zu%c", used, path_delim); - if (written <= 0 || (size_t)written >= sizeof(long_path) - used) { - th_cleanup(tmpdir); - FAIL("could not build the long PATH filler"); - } - used += (size_t)written; - } - ASSERT_GT(used, 4096); - int tail = snprintf(long_path + used, sizeof(long_path) - used, "%s", tmpdir); - if (tail <= 0 || (size_t)tail >= sizeof(long_path) - used) { - th_cleanup(tmpdir); - FAIL("could not append the fixture directory to the long PATH"); - } - - char *saved_path = repro221_save_env("PATH"); - cbm_setenv("PATH", long_path, 1); - - /* A home that does not exist keeps the PATH-independent fallbacks out of - * the way: the fixture's directory is the only place that can satisfy it. */ - const char *result = cbm_find_cli("cbm-repro221-long", "/nonexistent-repro221-home"); - - repro221_restore_env("PATH", saved_path); - - ASSERT_NOT_NULL(result); - ASSERT(result[0] != '\0'); - ASSERT(strncmp(result, tmpdir, strlen(tmpdir)) == 0); - ASSERT(strstr(result, "cbm-repro221-long") != NULL); - - th_cleanup(tmpdir); - PASS(); -} - -/* ── #221 regression: the Windows fallback shim locations ───────────────── */ - -/* - * repro_issue221_windows_fallback_shims - * - * An agent the PATH scan cannot see must still be found where Windows actually - * puts CLIs: %APPDATA%\npm (npm's global bin), %LOCALAPPDATA%\mise\shims, - * %USERPROFILE%\scoop\shims, plus the pipx and cargo homes (~\.local\bin, - * ~\.cargo\bin) that were already probed before #221 passed and must not be - * dropped by the fix. - * - * Each location is reached through its own seam -- APPDATA / LOCALAPPDATA, or - * the home_dir argument -- so this never reads the developer's real profile. - * Every shim carries a .cmd extension, which is why the fallback probe needs - * the same PATHEXT treatment the PATH scan got. - * - * Windows-only: the directory layout it asserts is the one Windows installers - * produce. */ -TEST(repro_issue221_windows_fallback_shims) { -#ifndef _WIN32 - SKIP_PLATFORM("Windows-only: npm/mise/scoop shim fallback layout"); -#else - const char *created = th_mktempdir("repro221fb"); - if (!created) { - FAIL("th_mktempdir failed"); - } - char root[512]; - snprintf(root, sizeof(root), "%s", created); - - char *saved_appdata = repro221_save_env("APPDATA"); - char *saved_localappdata = repro221_save_env("LOCALAPPDATA"); - char *saved_userprofile = repro221_save_env("USERPROFILE"); - char *saved_path = repro221_save_env("PATH"); - - /* APPDATA/LOCALAPPDATA/USERPROFILE all point at the fixture root. The - * scoop, pipx and cargo entries are built from the home_dir ARGUMENT, so - * USERPROFILE here mirrors what a real profile would supply. */ - cbm_setenv("APPDATA", root, 1); - cbm_setenv("LOCALAPPDATA", root, 1); - cbm_setenv("USERPROFILE", root, 1); - cbm_setenv("PATH", "/nonexistent-repro221-path", 1); - - /* One shim at a time, so the returned path proves which entry matched - * rather than merely that something did. Each case removes its shim before - * the next one runs, so the earlier entries cannot shadow the later ones. */ - const struct { - const char *subdir; - const char *label; - } cases[] = { - {".local/bin", "pipx home"}, {".cargo/bin", "cargo home"}, {"npm", "APPDATA npm"}, - {"mise/shims", "LOCALAPPDATA mise"}, {"scoop/shims", "scoop home"}, - }; - const char *shim_name = "cbm-repro221-fb.cmd"; - const size_t case_count = sizeof(cases) / sizeof(cases[0]); - - for (size_t i = 0; i < case_count; i++) { - char dir[512]; - snprintf(dir, sizeof(dir), "%s/%s", root, cases[i].subdir); - if (th_mkdir_p(dir) != 0) { - repro221_restore_env("PATH", saved_path); - repro221_restore_env("USERPROFILE", saved_userprofile); - repro221_restore_env("LOCALAPPDATA", saved_localappdata); - repro221_restore_env("APPDATA", saved_appdata); - th_cleanup(root); - FAIL("could not create the fallback fixture directory"); - } - char shim[1024]; - snprintf(shim, sizeof(shim), "%s/%s", dir, shim_name); - if (th_write_file(shim, "@echo off\r\nrem fake fallback shim\r\n") != 0) { - repro221_restore_env("PATH", saved_path); - repro221_restore_env("USERPROFILE", saved_userprofile); - repro221_restore_env("LOCALAPPDATA", saved_localappdata); - repro221_restore_env("APPDATA", saved_appdata); - th_cleanup(root); - FAIL("could not write the fallback shim"); - } - - const char *result = cbm_find_cli("cbm-repro221-fb", root); - char expected_prefix[512]; - snprintf(expected_prefix, sizeof(expected_prefix), "%s/%s/", root, cases[i].subdir); - bool matched = result != NULL && result[0] != '\0' && - strncmp(result, expected_prefix, strlen(expected_prefix)) == 0; - (void)remove(shim); - - if (!matched) { - printf(" fallback case %zu (%s) did not resolve in %s\n", i, cases[i].label, dir); - repro221_restore_env("PATH", saved_path); - repro221_restore_env("USERPROFILE", saved_userprofile); - repro221_restore_env("LOCALAPPDATA", saved_localappdata); - repro221_restore_env("APPDATA", saved_appdata); - th_cleanup(root); - FAIL("a Windows fallback shim location was not probed"); - } - } - - repro221_restore_env("PATH", saved_path); - repro221_restore_env("USERPROFILE", saved_userprofile); - repro221_restore_env("LOCALAPPDATA", saved_localappdata); - repro221_restore_env("APPDATA", saved_appdata); - - th_cleanup(root); - PASS(); -#endif -} - /* ── Suite ──────────────────────────────────────────────────────────────── */ SUITE(repro_issue221) { RUN_TEST(repro_issue221_opencode_pathext_lookup); - RUN_TEST(repro_issue221_long_path_beyond_four_kb); - RUN_TEST(repro_issue221_windows_fallback_shims); } diff --git a/tests/test_cli.c b/tests/test_cli.c index 539b0df18..c3d02c45c 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -4245,6 +4245,212 @@ TEST(cli_find_cli_fallback_paths) { PASS(); } +/* ── #221 regression: PATH longer than the old 4 KB copy ───────────────── */ + +/* Snapshot/restore one environment variable. The suite swaps PATH and the + * Windows profile roots, and a leaked swap would poison every later suite. */ +static char *cli_issue221_save_env(const char *name) { + const char *value = getenv(name); + return value ? strdup(value) : NULL; +} + +static void cli_issue221_restore_env(const char *name, char *saved) { + if (saved) { + cbm_setenv(name, saved, 1); + free(saved); + } else { + cbm_unsetenv(name); + } +} + +/* + * cli_find_cli_long_path_beyond_four_kb_issue221 + * + * find_in_path() used to copy PATH into a fixed 4096-byte stack buffer through + * cbm_safe_getenv(). That helper does not truncate -- it REFUSES a value it + * cannot fit -- so on a machine whose PATH exceeds 4096 characters the whole + * PATH was skipped and every agent on it read as "not installed". That is the + * layout the #221 reporter had: mise, npm and scoop each append entries. + * + * The fixture directory is the LAST PATH entry, so any truncation of the copy + * hides exactly the directory under test and nothing else. This case is not + * Windows-specific -- the same 4 KB cap hid entries on macOS and Linux. + * + * RED before the fix (empty result), GREEN after it. + */ +TEST(cli_find_cli_long_path_beyond_four_kb_issue221) { + const char *created = th_mktempdir("repro221long"); + if (!created) { + FAIL("th_mktempdir failed"); + } + char tmpdir[512]; + snprintf(tmpdir, sizeof(tmpdir), "%s", created); + +#ifdef _WIN32 + const char *fixture_name = "cbm-repro221-long.cmd"; + const char *fixture_body = "@echo off\r\nrem fake long-PATH agent\r\n"; + const char path_delim = ';'; +#else + const char *fixture_name = "cbm-repro221-long"; + const char *fixture_body = "#!/bin/sh\n# fake long-PATH agent\n"; + const char path_delim = ':'; +#endif + + char fixture_path[1024]; + snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name); + if (th_write_file(fixture_path, fixture_body) != 0) { + th_cleanup(tmpdir); + FAIL("could not write the long-PATH fixture"); + } + th_make_executable(fixture_path); + + /* + * Filler entries name directories that do not exist: the scan has to walk + * past every one of them to reach the fixture, which is appended after the + * filler so that it always sits beyond the 4096-byte mark. + */ + enum { FILLER_TARGET = 4600, PATH_CAP = FILLER_TARGET + 1024 }; + char long_path[PATH_CAP]; + size_t used = 0U; + while (used < (size_t)FILLER_TARGET) { + int written = snprintf(long_path + used, sizeof(long_path) - used, + "/nonexistent-repro221-filler%05zu%c", used, path_delim); + if (written <= 0 || (size_t)written >= sizeof(long_path) - used) { + th_cleanup(tmpdir); + FAIL("could not build the long PATH filler"); + } + used += (size_t)written; + } + ASSERT_GT(used, 4096); + int tail = snprintf(long_path + used, sizeof(long_path) - used, "%s", tmpdir); + if (tail <= 0 || (size_t)tail >= sizeof(long_path) - used) { + th_cleanup(tmpdir); + FAIL("could not append the fixture directory to the long PATH"); + } + + char *saved_path = cli_issue221_save_env("PATH"); + cbm_setenv("PATH", long_path, 1); + + /* A home that does not exist keeps the PATH-independent fallbacks out of + * the way: the fixture's directory is the only place that can satisfy it. */ + const char *result = cbm_find_cli("cbm-repro221-long", "/nonexistent-repro221-home"); + + cli_issue221_restore_env("PATH", saved_path); + + ASSERT_NOT_NULL(result); + ASSERT(result[0] != '\0'); + ASSERT(strncmp(result, tmpdir, strlen(tmpdir)) == 0); + ASSERT(strstr(result, "cbm-repro221-long") != NULL); + + th_cleanup(tmpdir); + PASS(); +} + +/* ── #221 regression: the Windows fallback shim locations ───────────────── */ + +/* + * cli_find_cli_windows_fallback_shims_issue221 + * + * An agent the PATH scan cannot see must still be found where Windows actually + * puts CLIs: %APPDATA%\npm (npm's global bin), %LOCALAPPDATA%\mise\shims, + * %USERPROFILE%\scoop\shims, plus the pipx and cargo homes (~\.local\bin, + * ~\.cargo\bin) that were already probed before #221 passed and must not be + * dropped by the fix. + * + * Each location is reached through its own seam -- APPDATA / LOCALAPPDATA, or + * the home_dir argument -- so this never reads the developer's real profile. + * Every shim carries a .cmd extension, which is why the fallback probe needs + * the same PATHEXT treatment the PATH scan got. + * + * Windows-only: the directory layout it asserts is the one Windows installers + * produce. */ +TEST(cli_find_cli_windows_fallback_shims_issue221) { +#ifndef _WIN32 + SKIP_PLATFORM("Windows-only: npm/mise/scoop shim fallback layout"); +#else + const char *created = th_mktempdir("repro221fb"); + if (!created) { + FAIL("th_mktempdir failed"); + } + char root[512]; + snprintf(root, sizeof(root), "%s", created); + + char *saved_appdata = cli_issue221_save_env("APPDATA"); + char *saved_localappdata = cli_issue221_save_env("LOCALAPPDATA"); + char *saved_userprofile = cli_issue221_save_env("USERPROFILE"); + char *saved_path = cli_issue221_save_env("PATH"); + + /* APPDATA/LOCALAPPDATA/USERPROFILE all point at the fixture root. The + * scoop, pipx and cargo entries are built from the home_dir ARGUMENT, so + * USERPROFILE here mirrors what a real profile would supply. */ + cbm_setenv("APPDATA", root, 1); + cbm_setenv("LOCALAPPDATA", root, 1); + cbm_setenv("USERPROFILE", root, 1); + cbm_setenv("PATH", "/nonexistent-repro221-path", 1); + + /* One shim at a time, so the returned path proves which entry matched + * rather than merely that something did. Each case removes its shim before + * the next one runs, so the earlier entries cannot shadow the later ones. */ + const struct { + const char *subdir; + const char *label; + } cases[] = { + {".local/bin", "pipx home"}, {".cargo/bin", "cargo home"}, {"npm", "APPDATA npm"}, + {"mise/shims", "LOCALAPPDATA mise"}, {"scoop/shims", "scoop home"}, + }; + const char *shim_name = "cbm-repro221-fb.cmd"; + const size_t case_count = sizeof(cases) / sizeof(cases[0]); + + for (size_t i = 0; i < case_count; i++) { + char dir[512]; + snprintf(dir, sizeof(dir), "%s/%s", root, cases[i].subdir); + if (th_mkdir_p(dir) != 0) { + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("could not create the fallback fixture directory"); + } + char shim[1024]; + snprintf(shim, sizeof(shim), "%s/%s", dir, shim_name); + if (th_write_file(shim, "@echo off\r\nrem fake fallback shim\r\n") != 0) { + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("could not write the fallback shim"); + } + + const char *result = cbm_find_cli("cbm-repro221-fb", root); + char expected_prefix[512]; + snprintf(expected_prefix, sizeof(expected_prefix), "%s/%s/", root, cases[i].subdir); + bool matched = result != NULL && result[0] != '\0' && + strncmp(result, expected_prefix, strlen(expected_prefix)) == 0; + (void)remove(shim); + + if (!matched) { + printf(" fallback case %zu (%s) did not resolve in %s\n", i, cases[i].label, dir); + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + th_cleanup(root); + FAIL("a Windows fallback shim location was not probed"); + } + } + + cli_issue221_restore_env("PATH", saved_path); + cli_issue221_restore_env("USERPROFILE", saved_userprofile); + cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata); + cli_issue221_restore_env("APPDATA", saved_appdata); + + th_cleanup(root); + PASS(); +#endif +} + /* ═══════════════════════════════════════════════════════════════════ * Dry-run flag parsing (port of TestDryRun) * ═══════════════════════════════════════════════════════════════════ */ @@ -18307,6 +18513,8 @@ SUITE(cli) { RUN_TEST(cli_find_cli_result_is_thread_local); RUN_TEST(cli_find_cli_on_path); RUN_TEST(cli_find_cli_fallback_paths); + RUN_TEST(cli_find_cli_long_path_beyond_four_kb_issue221); + RUN_TEST(cli_find_cli_windows_fallback_shims_issue221); /* Dry-run flag parsing (1 test — install_test.go) */ RUN_TEST(cli_dry_run_flags); From 43a7e592027d3f4282cdb0314d92c142fca0165a Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:23:03 +0200 Subject: [PATCH 37/76] revert(cypher): drop the early-WHERE deferral that #2253 superseded The early WHERE pass in execute_single runs on seed rows where only the first alias is bound, so a NOT on the target node or the relationship pruned every row (#2250). This branch fixed that by skipping the early pass until every alias the WHERE reads was bound. While it waited for review, #2253 (8ab555ee) landed the same fix on main as a three-valued early pass: an unbound alias is UNKNOWN, NOT keeps it UNKNOWN, and only a definite FALSE prunes. This branch's regression tests pass on main's production code unchanged, so restore src/cypher/cypher.c to the branch's base and keep the tests; the merge of main that follows takes main's implementation. Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 52 ++------------------------------------------- 1 file changed, 2 insertions(+), 50 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 2a18db7e8..3cc31f9d7 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -4145,54 +4145,7 @@ static void execute_with_simple(cbm_return_clause_t *wc, binding_t *bindings, in } } -static bool condition_bindings_available(const cbm_condition_t *condition, binding_t *binding) { - if (condition->variable && !binding_get(binding, condition->variable) && - !binding_get_edge(binding, condition->variable)) { - return false; - } - for (int i = 0; i < condition->arg_count; i++) { - const char *var = condition->args[i].variable; - if (var && !binding_get(binding, var) && !binding_get_edge(binding, var)) { - return false; - } - } - return true; -} - -static bool expr_bindings_available(const cbm_expr_t *expr, binding_t *binding) { - if (!expr) { - return true; - } - switch (expr->type) { - case EXPR_CONDITION: - return condition_bindings_available(&expr->cond, binding); - case EXPR_AND: - case EXPR_OR: - case EXPR_XOR: - return expr_bindings_available(expr->left, binding) && - expr_bindings_available(expr->right, binding); - case EXPR_NOT: - return expr_bindings_available(expr->left, binding); - } - return false; -} - -static bool where_bindings_available(const cbm_where_clause_t *where, binding_t *binding) { - if (!where) { - return true; - } - if (where->root) { - return expr_bindings_available(where->root, binding); - } - for (int i = 0; i < where->count; i++) { - if (!condition_bindings_available(&where->conditions[i], binding)) { - return false; - } - } - return true; -} - -/* Apply a WHERE filter after its referenced variables have been bound. */ +/* Apply post-WITH WHERE filter */ static void filter_bindings_where(const cbm_where_clause_t *where, binding_t *vbindings, int *vcount) { int kept = 0; @@ -5196,8 +5149,7 @@ static int execute_single(cbm_store_t *store, cbm_query_t *q, const char *projec binding_t b = {0}; b.store = store; binding_set(&b, var_name, &scanned[i]); - bool pass = !q->where || !where_bindings_available(q->where, &b) || - eval_where(q->where, &b); + bool pass = !q->where || eval_where(q->where, &b); if (pass) { bindings[bind_count++] = b; } else { From 6d2d30c3ea2191adfc000f57d31a95d14b7b290b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:23:17 +0200 Subject: [PATCH 38/76] fix(cypher): the early WHERE pass waits for every alias a condition reads eval_condition_partial (from #2253) answers UNKNOWN for a condition whose alias is not bound yet, but it only looked at c->variable. A function condition is filed under its first argument's alias, so in MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = 'x' the seed row saw a bound a, read coalesce(NULL, ) as a definite mismatch and pruned every row before CALLS expansion: zero rows, while the same predicate after WITH returned the right ones. Check every function argument's alias as well, the way this branch's original fix did (condition_bindings_available), inside main's three-valued leaf. RED before: cypher_exec_where_func_arg_on_unbound_target, row_count 0, expected 1. GREEN after; cypher suite 237 passed. Co-authored-by: lorenzozanee Signed-off-by: Martin Vogel --- src/cypher/cypher.c | 17 ++++++++++++++--- tests/test_cypher.c | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 8eaae7a26..c86be43bf 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -2899,12 +2899,23 @@ static cyp_partial_t partial_or(cyp_partial_t l, cyp_partial_t r) { : CYP_PARTIAL_UNKNOWN; } -/* A condition whose alias is not bound yet is UNKNOWN; a literal-only LHS - * (func condition with no variable arg) has nothing to wait for. */ +static bool partial_alias_unbound(binding_t *b, const char *alias) { + return alias && !binding_get(b, alias) && !binding_get_edge(b, alias); +} + +/* A condition is UNKNOWN while any alias it reads is not bound yet: its own + * variable, and every function argument's -- a func condition is filed under + * its first argument's alias, so the b in coalesce(a.x, b.name) is only seen + * here. A literal-only LHS has nothing to wait for. */ static cyp_partial_t eval_condition_partial(const cbm_condition_t *c, binding_t *b) { - if (c->variable && !binding_get(b, c->variable) && !binding_get_edge(b, c->variable)) { + if (partial_alias_unbound(b, c->variable)) { return CYP_PARTIAL_UNKNOWN; } + for (int i = 0; i < c->arg_count; i++) { + if (partial_alias_unbound(b, c->args[i].variable)) { + return CYP_PARTIAL_UNKNOWN; + } + } return eval_condition(c, b) ? CYP_PARTIAL_TRUE : CYP_PARTIAL_FALSE; } diff --git a/tests/test_cypher.c b/tests/test_cypher.c index 06c3131da..ec3afbfc2 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -3463,6 +3463,27 @@ TEST(cypher_exec_where_not_target_node_count) { PASS(); } +/* The early WHERE pass must wait for every alias a condition reads. A function + * condition is filed under its first argument's alias, so in + * coalesce(a.no_such_prop, b.name) the target b hid behind the bound a: the + * seed row read coalesce(NULL, ) as a definite mismatch and pruned + * every row before CALLS expansion, while the same predicate after WITH was + * right. */ +TEST(cypher_exec_where_func_arg_on_unbound_target) { + cbm_store_t *s = setup_cypher_store(); + cbm_cypher_result_t r = {0}; + int rc = cbm_cypher_execute(s, + "MATCH (a)-[:CALLS]->(b) WHERE coalesce(a.no_such_prop, b.name) = " + "\"SubmitOrder\" RETURN a.name", + "test", 0, &r); + ASSERT_EQ(rc, 0); + ASSERT_EQ(r.row_count, 1); + ASSERT_STR_EQ(r.rows[0][0], "ValidateOrder"); + cbm_cypher_result_free(&r); + cbm_store_close(s); + PASS(); +} + TEST(cypher_exec_where_in) { cbm_store_t *s = setup_cypher_store(); cbm_cypher_result_t r = {0}; @@ -6332,6 +6353,7 @@ SUITE(cypher) { RUN_TEST(cypher_exec_where_not_target_relationship); RUN_TEST(cypher_exec_where_not_source_node); RUN_TEST(cypher_exec_where_not_target_node_count); + RUN_TEST(cypher_exec_where_func_arg_on_unbound_target); RUN_TEST(cypher_exec_where_in); RUN_TEST(cypher_exec_where_not_in); RUN_TEST(cypher_exec_where_is_null); From 2b1aba710c81cf763234967f56b6df014f784b31 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:25:58 +0200 Subject: [PATCH 39/76] docs(cli): say what a warm daemon gives hooks in --help Hooks never start a daemon. Without one they skip graph augmentation and print a throttled notice that points at `daemon start` (main_hook_report_absent_daemon), so a hook has no per-command daemon start-up to skip. What a warm daemon gives hooks is the graph context itself. Reword the second half of the daemon help entry to say that. The CLI half stays: it matches the hint a one-shot CLI command prints after it had to start a temporary daemon. Signed-off-by: Martin Vogel --- src/main.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index 800898cd2..bee661793 100644 --- a/src/main.c +++ b/src/main.c @@ -1111,8 +1111,9 @@ static void print_help(void) { printf(" codebase-memory-mcp update [-y|-n]\n"); printf(" codebase-memory-mcp config \n"); printf(" codebase-memory-mcp daemon \n"); - printf(" start keeps a daemon warm so CLI calls and\n"); - printf(" hooks skip the per-command daemon start-up\n"); + printf(" start keeps a daemon warm: CLI calls skip\n"); + printf(" the per-command start-up, and hooks add\n"); + printf(" graph context only while a daemon runs\n"); printf(" codebase-memory-mcp --version Print version\n"); printf(" codebase-memory-mcp --help Print this help\n"); printf("\nCLI output options:\n"); From e8ccc330576ce3fdf826d2dbb0a0a515f0d11473 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:27:42 +0200 Subject: [PATCH 40/76] fix(lsp): grow the ts_lsp walk stacks with cbm_walk_stack_reserve Follow-up to the review of #2091, on top of the contributor's change. The three ts_lsp.c walks (infer_return_type_from_body, rebuild_signatures_from_ast, convert_signature_type_params) now keep their first 256 slots on the C stack, as the old fixed arrays did, and grow on the heap through cbm_walk_stack_reserve / cbm_walk_stack_release (#2375) instead of cutting TSNodeStack storage from ctx->arena: - Scratch lifetime: ctx->arena in the per-file pass is the result arena, kept until the result cache is freed, and the return walk runs once per untyped top-level function. The heap chunk is now released when each walk ends. - No silent drop: when the stack cannot grow, cbm_walk_stack_reserve logs extract.walk_stack_alloc_failed and the walk stops. The return walk then answers "unknown"; the signature walks leave the declarations not yet reached as extract_defs registered them. - Traversal order: children are filled in cursor order (still one O(N) pass) and popped last-child first, exactly as main's walks did, so of several returns the later one is still tried first. tslsp_return_walk_order_prefers_later_return pins it; it fails under a first-child-first walk. The TSNodeStack arena initializer in extract_node_stack.h is no longer needed and is dropped, so that header is unchanged from main. The two stack-cap tests now fail instead of passing when their buffer allocation fails. Co-authored-by: Joshua Richter Signed-off-by: Martin Vogel --- internal/cbm/extract_node_stack.h | 18 ++--- internal/cbm/lsp/ts_lsp.c | 130 +++++++++++++++++++++--------- tests/test_ts_lsp.c | 22 ++++- 3 files changed, 115 insertions(+), 55 deletions(-) diff --git a/internal/cbm/extract_node_stack.h b/internal/cbm/extract_node_stack.h index 3c40838cd..30a27968b 100644 --- a/internal/cbm/extract_node_stack.h +++ b/internal/cbm/extract_node_stack.h @@ -34,18 +34,6 @@ typedef struct { CBMArena *scratch; } TSNodeStack; -/* Initialize a stack against an arena directly. The LSP passes hold a - * TSLSPContext, not a CBMExtractCtx, and every allocation in those files - * already comes from that one arena — see collect_children in ts_lsp.c. An - * extraction caller should still use ts_nstack_init below, whose context - * argument is what makes handing over the wrong arena a type error. */ -static inline void ts_nstack_init_arena(TSNodeStack *s, CBMArena *arena, int initial_cap) { - s->scratch = arena; - s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode)); - s->count = 0; - s->cap = s->items ? initial_cap : 0; -} - /* Initialize a stack with the given initial capacity, allocated from the * context's traversal scratch. Taking the context rather than an arena is * deliberate: it makes handing over ctx->arena, or a local alias of it, a type @@ -53,7 +41,11 @@ static inline void ts_nstack_init_arena(TSNodeStack *s, CBMArena *arena, int ini * scratch falls back to ctx->arena, which is the behaviour that shipped before * #1997, so no caller ever gets a NULL arena and silently loses nodes. */ static inline void ts_nstack_init(TSNodeStack *s, const CBMExtractCtx *ctx, int initial_cap) { - ts_nstack_init_arena(s, ctx->scratch ? ctx->scratch : ctx->arena, initial_cap); + CBMArena *arena = ctx->scratch ? ctx->scratch : ctx->arena; + s->scratch = arena; + s->items = (TSNode *)cbm_arena_alloc(arena, (size_t)initial_cap * sizeof(TSNode)); + s->count = 0; + s->cap = s->items ? initial_cap : 0; } /* Push a node onto the stack, growing 2x if needed. */ diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 228b32736..cf827e7c8 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -28,7 +28,7 @@ #include "ts_lsp.h" #include "../../../src/foundation/platform.h" -#include "extract_node_stack.h" +#include "../helpers.h" #include #include #include @@ -4685,21 +4685,61 @@ static void register_file_defs(CBMArena *arena, CBMTypeRegistry *reg, CBMFileRes } } -// Find the first `return EXPR` inside a body and evaluate EXPR's type. Returns -// UNKNOWN if the body has no return or every return is unresolvable. +// AST walk stacks for the passes below. The first chunk lives on the C stack, the +// same 256 slots the old fixed arrays used; a wider pending set grows on the heap +// through cbm_walk_stack_reserve and is released when the walk ends, so nothing +// is cut from the per-file result arena. A fixed cap used to stop pushing without +// a word, which skipped every top-level declaration past the 256th and left the +// return walk short of returns it had never reached (#2388). +enum { TS_WALK_STACK_INLINE = 256 }; + +// Push every child of n in cursor order (one O(N) pass). The walk pops the stack, +// so children are visited last-child first: the order these walks have always +// used. False means the stack could not grow; cbm_walk_stack_reserve has logged it. +static bool ts_walk_push_children(TSNode **stack, int *cap, int *top, TSNode n, + const TSNode *inline_buf, const char *walker) { + uint32_t count = ts_node_child_count(n); + if (count == 0) + return true; + if (!cbm_walk_stack_reserve((void **)stack, cap, *top + (int)count, sizeof(TSNode), inline_buf, + walker)) + return false; + TSTreeCursor cursor = ts_tree_cursor_new(n); + if (ts_tree_cursor_goto_first_child(&cursor)) { + uint32_t pushed = 0; + do { + (*stack)[(*top)++] = ts_tree_cursor_current_node(&cursor); + pushed++; + } while (pushed < count && ts_tree_cursor_goto_next_sibling(&cursor)); + } + ts_tree_cursor_delete(&cursor); + return true; +} + +static bool ts_walk_push(TSNode **stack, int *cap, int *top, TSNode n, const TSNode *inline_buf, + const char *walker) { + if (!cbm_walk_stack_reserve((void **)stack, cap, *top + 1, sizeof(TSNode), inline_buf, walker)) + return false; + (*stack)[(*top)++] = n; + return true; +} + +// Find a `return EXPR` inside a body and evaluate EXPR's type. Children are visited +// last-child first, so of several returns the one latest in the body is tried +// first; the first one that resolves wins. Returns UNKNOWN if the body has no +// return, every return is unresolvable, or the walk stack could not grow. static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body) { if (ts_node_is_null(body)) return cbm_type_unknown(); - // Iterative DFS to avoid C-stack blowup on big bodies. The stack grows, so a - // body wider than any fixed cap is still walked in full: a fixed 256 slots - // used to stop pushing without a word, and this function then answered - // "unknown return type" for a body whose return it had never reached. - TSNodeStack stack; - ts_nstack_init_arena(&stack, ctx->arena, 256); - ts_nstack_push(&stack, body); - while (stack.count > 0) { - TSNode n = ts_nstack_pop(&stack); + TSNode inline_stack[TS_WALK_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = TS_WALK_STACK_INLINE; + int top = 0; + const CBMType *found = cbm_type_unknown(); + stack[top++] = body; + while (top > 0) { + TSNode n = stack[--top]; if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); @@ -4712,8 +4752,10 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body if (ts_node_is_null(expr)) continue; const CBMType *t = ts_eval_expr_type(ctx, expr); - if (t && !cbm_type_is_unknown(t)) - return t; + if (t && !cbm_type_is_unknown(t)) { + found = t; + break; + } continue; } // Don't recurse into nested function bodies — their returns belong to them. @@ -4721,9 +4763,11 @@ static const CBMType *infer_return_type_from_body(TSLSPContext *ctx, TSNode body strcmp(k, "arrow_function") == 0 || strcmp(k, "method_definition") == 0) continue; - ts_nstack_push_children(&stack, n); + if (!ts_walk_push_children(&stack, &cap, &top, n, inline_stack, "ts_lsp_return_walk")) + break; // "unknown" is the safe answer for a walk that could not finish } - return cbm_type_unknown(); + cbm_walk_stack_release(stack, inline_stack); + return found; } // Rebuild a function's CBMRegisteredFunc signature from the AST directly. extract_defs @@ -4737,29 +4781,33 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR if (ts_node_is_null(root) || !reg || !ctx->module_qn) return; - /* The stack grows. A fixed 256 slots used to be filled by the seeding loop - * alone on a file with that many top-level children, and every declaration - * past it was then skipped without a word — it kept whatever signature - * extract_defs had guessed. */ - TSNodeStack stack; - ts_nstack_init_arena(&stack, ctx->arena, 256); - ts_nstack_push_children(&stack, root); + // Seeded with every top-level child, so a file with more than 256 of them + // needs the growing stack (see TS_WALK_STACK_INLINE). If it cannot grow, the + // walk stops and the declarations not yet reached keep extract_defs' signature. + TSNode inline_stack[TS_WALK_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = TS_WALK_STACK_INLINE; + int top = 0; + bool walking = + ts_walk_push_children(&stack, &cap, &top, root, inline_stack, "ts_lsp_rebuild_signatures"); - while (stack.count > 0) { - TSNode n = ts_nstack_pop(&stack); + while (walking && top > 0) { + TSNode n = stack[--top]; if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); // Recurse into export_statement and class bodies. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - ts_nstack_push_children(&stack, n); + walking = ts_walk_push_children(&stack, &cap, &top, n, inline_stack, + "ts_lsp_rebuild_signatures"); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); if (!ts_node_is_null(body)) - ts_nstack_push(&stack, body); + walking = ts_walk_push(&stack, &cap, &top, body, inline_stack, + "ts_lsp_rebuild_signatures"); continue; } @@ -4889,6 +4937,7 @@ static void rebuild_signatures_from_ast(TSLSPContext *ctx, TSNode root, CBMTypeR break; } } + cbm_walk_stack_release(stack, inline_stack); } // Convert NAMED references to TYPE_PARAM for any function whose declaration has a @@ -4899,29 +4948,33 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp return; // Walk: function_declaration, class_declaration { method_definition }, plus exported. - /* The stack grows. A fixed 256 slots used to be filled by the seeding loop - * alone on a file with that many top-level children, and every declaration - * past it was then skipped without a word — it kept whatever signature - * extract_defs had guessed. */ - TSNodeStack stack; - ts_nstack_init_arena(&stack, ctx->arena, 256); - ts_nstack_push_children(&stack, root); + // Seeded with every top-level child, so a file with more than 256 of them needs + // the growing stack (see TS_WALK_STACK_INLINE). If it cannot grow, the walk stops + // and the generics not yet reached keep their NAMED parameters. + TSNode inline_stack[TS_WALK_STACK_INLINE]; + TSNode *stack = inline_stack; + int cap = TS_WALK_STACK_INLINE; + int top = 0; + bool walking = + ts_walk_push_children(&stack, &cap, &top, root, inline_stack, "ts_lsp_type_params"); - while (stack.count > 0) { - TSNode n = ts_nstack_pop(&stack); + while (walking && top > 0) { + TSNode n = stack[--top]; if (ts_node_is_null(n)) continue; const char *k = ts_node_type(n); // Recurse into export_statement and class_body. if (strcmp(k, "export_statement") == 0 || strcmp(k, "class_body") == 0) { - ts_nstack_push_children(&stack, n); + walking = + ts_walk_push_children(&stack, &cap, &top, n, inline_stack, "ts_lsp_type_params"); continue; } if (strcmp(k, "class_declaration") == 0) { TSNode body = ts_node_child_by_field_name(n, "body", TS_LSP_FIELD_LEN("body")); if (!ts_node_is_null(body)) - ts_nstack_push(&stack, body); + walking = + ts_walk_push(&stack, &cap, &top, body, inline_stack, "ts_lsp_type_params"); continue; } @@ -5020,6 +5073,7 @@ static void convert_signature_type_params(TSLSPContext *ctx, TSNode root, CBMTyp break; } } + cbm_walk_stack_release(stack, inline_stack); } // Parse a JSDoc comment block for `@param {T} name` and `@returns {T}` tags. For each diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index a6261e967..bcbb89d21 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -310,6 +310,21 @@ TEST(tslsp_return_inferred_local) { PASS(); } +/* infer_return_type_from_body visits children last-child first, so of two returns + * the one later in the body is tried first. Pinned so that a change to the walk + * order (#2091) is a visible decision rather than a side effect. */ +TEST(tslsp_return_walk_order_prefers_later_return) { + CBMFileResult *r = + extract_ts("class A { a(): void {} }\n" + "class B { b(): void {} }\n" + "function pick(f) { if (f) { return new A(); } return new B(); }\n" + "function go() { const x = pick(true); x.b(); }\n"); + ASSERT_NOT_NULL(r); + ASSERT_GTE(require_resolved(r, ".go", "b"), 0); + cbm_free_result(r); + PASS(); +} + TEST(tslsp_return_async_promise) { CBMFileResult *r = extract_ts("class User { hello(): void {} }\n" "async function load(): Promise { return new User(); }\n" @@ -3321,8 +3336,7 @@ TEST(tslsp_stress_declarations_past_stack_cap) { * the first extraction pass had guessed. */ enum { BUF_CAP = 64 * 1024 }; char *buf = (char *)malloc(BUF_CAP); - if (!buf) - PASS(); + ASSERT_NOT_NULL(buf); char *p = buf; char *end = buf + BUF_CAP; for (int i = 0; i < 300; i++) { @@ -3350,8 +3364,7 @@ TEST(tslsp_stress_return_past_stack_cap) { * and answer "unknown return type". */ enum { BUF_CAP = 64 * 1024 }; char *buf = (char *)malloc(BUF_CAP); - if (!buf) - PASS(); + ASSERT_NOT_NULL(buf); char *p = buf; char *end = buf + BUF_CAP; p += snprintf(p, (size_t)(end - p), "class Conn { ping(): void {} }\n"); @@ -4799,6 +4812,7 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_return_simple); RUN_TEST(tslsp_return_chain); RUN_TEST(tslsp_return_inferred_local); + RUN_TEST(tslsp_return_walk_order_prefers_later_return); RUN_TEST(tslsp_return_async_promise); RUN_TEST(tslsp_return_method_no_rttype); From 15950c361c5eee83e6fef4593727b0b1b62b14b4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:30:16 +0200 Subject: [PATCH 41/76] test(grammar): pin C# 14 extension block members in the regression net Carry the csharp_extension_declaration case from #2218 into the per-language grammar regression table: the #2071 snippet must yield NumberExtensions and SumPositive, so a future refresh of tree-sitter-c-sharp cannot silently drop extension block members again. The label golden table needs a row for every regression case, so the matching golden row from #2218 comes with it. The expression-bodied member is wrapped across two lines. Co-authored-by: lorenzozanee Signed-off-by: Martin Vogel --- tests/test_grammar_labels.c | 1 + tests/test_grammar_regression.c | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/test_grammar_labels.c b/tests/test_grammar_labels.c index f2e58074d..be28a0687 100644 --- a/tests/test_grammar_labels.c +++ b/tests/test_grammar_labels.c @@ -93,6 +93,7 @@ static const LabelGolden LABEL_GOLDENS[] = { {"ruby", "Class:1,Function:1,Module:1"}, {"php", "Class:1,Function:1,Module:1"}, {"c_sharp", "Class:1,Method:1,Module:1"}, + {"csharp_extension_declaration", "Class:1,Method:1,Module:1"}, {"bash", "Function:2,Module:1"}, {"zsh", "Function:2,Module:1"}, {"lua", "Function:2,Module:1"}, diff --git a/tests/test_grammar_regression.c b/tests/test_grammar_regression.c index 219c68edb..88007db0d 100644 --- a/tests/test_grammar_regression.c +++ b/tests/test_grammar_regression.c @@ -81,6 +81,21 @@ const GrammarCase CBM_GRAMMAR_CASES[] = { "class A {\n void Foo() {}\n}\n", 2, {"A", "Foo", NULL}}, + {"csharp_extension_declaration", + CBM_LANG_CSHARP, + "Extensions.cs", + "using System.Collections.Generic;\n" + "using System.Linq;\n" + "public static class NumberExtensions\n" + "{\n" + " extension(IEnumerable numbers)\n" + " {\n" + " public int SumPositive() =>\n" + " numbers.Where(n => n > 0).Aggregate(0, static (total, n) => total + n);\n" + " }\n" + "}\n", + 2, + {"NumberExtensions", "SumPositive", NULL}}, {"bash", CBM_LANG_BASH, "a.sh", From 143ccfa35e2150aa4b2320039ceb09f11988c1a9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:30:29 +0200 Subject: [PATCH 42/76] fix(ui): allocate the framework-entry flags through the memory core The framework_entry flag array in cbm_layout_from_nodes was allocated with calloc and released with free on both exit paths. The memory-core linter counts raw allocator sites per file and fails when a file grows: src/ui/layout3d.c went from its baseline of 61 to 64. The array is owned entirely by this function, so allocation and both releases move together to the memory core: cbm_calloc and cbm_free with CBM_MEM_CLASS_OTHER, the same pairing this function already uses for out_calls. The route lookup's cbm_store_find_node_by_id result stays released by cbm_node_free_fields, the store API's own pair. src/ui/layout3d.c is back at 61 raw sites. The edge loop now sits one level deeper inside the new framework_entries_valid block, so it is re-indented to the house clang-format style. src/ui/ is outside the CI clang-format check, so this was done locally. Ignoring whitespace, the only changes are the three allocator lines and three re-wrapped lines. Signed-off-by: Martin Vogel --- src/ui/layout3d.c | 114 +++++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 56 deletions(-) diff --git a/src/ui/layout3d.c b/src/ui/layout3d.c index fcf73029a..144dff0f5 100644 --- a/src/ui/layout3d.c +++ b/src/ui/layout3d.c @@ -575,7 +575,7 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje /* 3. Query edges — filter during fetch via binary search (O(e log n)) */ int *deg = calloc((size_t)n, sizeof(int)); - bool *framework_entry = calloc((size_t)n, sizeof(bool)); + bool *framework_entry = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)n * sizeof(bool)); int mapped = 0; int edge_cap = CBM_SZ_256; cbm_edge_t *all_edges = malloc((size_t)edge_cap * sizeof(cbm_edge_t)); @@ -586,62 +586,64 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje bool framework_entries_valid = deg && framework_entry && all_edges && es && ed; if (framework_entries_valid) { if (cbm_store_get_schema(store, project, &schema) == CBM_STORE_OK) { - for (int t = 0; t < schema.edge_type_count; t++) { - cbm_edge_t *te = NULL; - int tc = 0; - if (cbm_store_find_edges_by_type(store, project, schema.edge_types[t].type, &te, &tc) == - CBM_STORE_OK) { - for (int e = 0; e < tc; e++) { - int si = find_node_index(id_map, n, te[e].source_id); - int di = find_node_index(id_map, n, te[e].target_id); - if (si >= 0 && te[e].type && strcmp(te[e].type, "HANDLES") == 0) { - cbm_node_t route = {0}; - if (cbm_store_find_node_by_id(store, te[e].target_id, &route) == CBM_STORE_OK) { - if (route.label && strcmp(route.label, "Route") == 0) - framework_entry[si] = true; - cbm_node_free_fields(&route); - } else - framework_entries_valid = false; - } - if (si >= 0 && di >= 0) { - if (mapped >= edge_cap) { - int nc = edge_cap * PAIR_LEN; - cbm_edge_t *te2 = realloc(all_edges, (size_t)nc * sizeof(cbm_edge_t)); - int *ts = realloc(es, (size_t)nc * sizeof(int)); - int *td = realloc(ed, (size_t)nc * sizeof(int)); - if (!te2 || !ts || !td) { + for (int t = 0; t < schema.edge_type_count; t++) { + cbm_edge_t *te = NULL; + int tc = 0; + if (cbm_store_find_edges_by_type(store, project, schema.edge_types[t].type, &te, + &tc) == CBM_STORE_OK) { + for (int e = 0; e < tc; e++) { + int si = find_node_index(id_map, n, te[e].source_id); + int di = find_node_index(id_map, n, te[e].target_id); + if (si >= 0 && te[e].type && strcmp(te[e].type, "HANDLES") == 0) { + cbm_node_t route = {0}; + if (cbm_store_find_node_by_id(store, te[e].target_id, &route) == + CBM_STORE_OK) { + if (route.label && strcmp(route.label, "Route") == 0) + framework_entry[si] = true; + cbm_node_free_fields(&route); + } else framework_entries_valid = false; - if (te2) - all_edges = te2; - if (ts) - es = ts; - if (td) - ed = td; - free_edge_array(te + e, tc - e); - goto edges_done; - } - all_edges = te2; - es = ts; - ed = td; - edge_cap = nc; } - all_edges[mapped] = te[e]; - memset(&te[e], 0, sizeof(cbm_edge_t)); - es[mapped] = si; - ed[mapped] = di; - deg[si]++; - deg[di]++; - mapped++; - } else { - free((void *)te[e].project); - free((void *)te[e].type); - free((void *)te[e].properties_json); + if (si >= 0 && di >= 0) { + if (mapped >= edge_cap) { + int nc = edge_cap * PAIR_LEN; + cbm_edge_t *te2 = + realloc(all_edges, (size_t)nc * sizeof(cbm_edge_t)); + int *ts = realloc(es, (size_t)nc * sizeof(int)); + int *td = realloc(ed, (size_t)nc * sizeof(int)); + if (!te2 || !ts || !td) { + framework_entries_valid = false; + if (te2) + all_edges = te2; + if (ts) + es = ts; + if (td) + ed = td; + free_edge_array(te + e, tc - e); + goto edges_done; + } + all_edges = te2; + es = ts; + ed = td; + edge_cap = nc; + } + all_edges[mapped] = te[e]; + memset(&te[e], 0, sizeof(cbm_edge_t)); + es[mapped] = si; + ed[mapped] = di; + deg[si]++; + deg[di]++; + mapped++; + } else { + free((void *)te[e].project); + free((void *)te[e].type); + free((void *)te[e].properties_json); + } } - } - free(te); - } else - framework_entries_valid = false; - } + free(te); + } else + framework_entries_valid = false; + } } else { framework_entries_valid = false; } @@ -667,7 +669,7 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje if (!result || !bodies) { free(bodies); free(deg); - free(framework_entry); + cbm_free(CBM_MEM_CLASS_OTHER, framework_entry); free(es); free(ed); free(cdepth); @@ -813,7 +815,7 @@ cbm_layout_result_t *cbm_layout_from_nodes(cbm_store_t *store, const char *proje free(bodies); free(deg); - free(framework_entry); + cbm_free(CBM_MEM_CLASS_OTHER, framework_entry); free(es); free(ed); free(cdepth); From 71ec2e6e225ebc3662026b58c720e9491f796ebe Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:31:47 +0200 Subject: [PATCH 43/76] test(windows): pin the CRT aligned-block route of the aligned-free wrapper __wrap__aligned_free hands a block mimalloc did not make back to the CRT. A block from the CRT's _aligned_malloc starts past the CRT's bookkeeping header, so its matching deallocator is _aligned_free; plain free receives an interior pointer. This branch routes such blocks to __real__aligned_free. The new Windows-only mem test allocates through __real__aligned_malloc (the CRT's own allocator under --wrap), asserts mimalloc does not own the block, frees it through the wrapper by name, and checks errno and HeapValidate on the process heap. Freeing the interior pointer with plain free is a bad-free report under ASan; on an uninstrumented heap the free fails with errno set or the process terminates. Signed-off-by: Martin Vogel --- tests/test_mem.c | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_mem.c b/tests/test_mem.c index 11b3e2c2c..072476f6a 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -30,6 +30,7 @@ #define WIN32_LEAN_AND_MEAN #endif #include +#include #endif /* ASan detection — mimalloc MI_OVERRIDE=0 under ASan, mi_process_info @@ -353,6 +354,34 @@ TEST(mem_rss_reflects_external_resident_memory) { PASS(); } +#ifdef _WIN32 +/* A block the CRT's _aligned_malloc made starts past the CRT's bookkeeping + * header, so the aligned-free wrapper must return it through _aligned_free. + * Plain free receives an interior pointer: ASan reports a bad free, and the + * Windows heap either rejects the free (errno) or terminates the process. + * --wrap makes __real__aligned_malloc the CRT's own allocator, so the block is + * genuinely foreign to mimalloc. The wrapper is called by name: the routing + * inside it is what this pins, independent of which references the linker + * rewrites. */ +void *__real__aligned_malloc(size_t size, size_t alignment); +void __wrap__aligned_free(void *block); + +TEST(mem_win_aligned_free_returns_crt_block_to_crt) { + enum { CRT_BLOCK_SIZE = 256, CRT_BLOCK_ALIGNMENT = 64 }; + unsigned char *block = __real__aligned_malloc(CRT_BLOCK_SIZE, CRT_BLOCK_ALIGNMENT); + ASSERT_NOT_NULL(block); + /* Without a CRT-owned block the wrapper takes its mimalloc path instead. */ + ASSERT_FALSE(mi_is_in_heap_region(block)); + ASSERT_EQ((uintptr_t)block % CRT_BLOCK_ALIGNMENT, 0); + memset(block, 0xA5, CRT_BLOCK_SIZE); + errno = 0; + __wrap__aligned_free(block); + ASSERT_EQ(errno, 0); + ASSERT_TRUE(HeapValidate(GetProcessHeap(), 0, NULL)); + PASS(); +} +#endif + TEST(mem_collect_rss_still_positive) { cbm_mem_init(0.5); cbm_mem_collect(); @@ -1744,6 +1773,10 @@ SUITE(mem) { RUN_TEST(mem_rss_reflects_external_resident_memory); RUN_TEST(mem_collect_no_crash); RUN_TEST(mem_collect_rss_still_positive); +#ifdef _WIN32 + /* Windows allocator wrappers */ + RUN_TEST(mem_win_aligned_free_returns_crt_block_to_crt); +#endif /* Memory pressure simulation */ RUN_TEST(mem_progressive_alloc_rss_increases); RUN_TEST(mem_free_and_collect_no_crash); From 925703d614206c799494a20866de971ad553876a Mon Sep 17 00:00:00 2001 From: Petteri Ponsimaa Date: Tue, 20 Oct 2026 23:07:48 +0300 Subject: [PATCH 44/76] fix(pipeline): emit and reconstruct route properties as valid JSON An unchanged handler's Route is rebuilt from the route_path and route_method stored on its Function. They were read with a quote scan: an escaped quote or backslash cut the path short, and a nested, broken or non-string value still named a Route. They are now decoded with yyjson, and anything that is not a top-level string that fits names no Route. Every Route and HANDLES properties writer in pass_route_nodes.c now escapes its values into a buffer that fits them: the rebuilt handler QN was unescaped, a QN filling the 512-byte escape buffer left the closing "} off the 512-byte properties, a method is source text where a DRF @action names it, and the SvelteKit +server writer had both problems in 256 bytes. Tests: each of the five new infrascan tests fails on 4cf44147 without this change. Signed-off-by: Petteri Ponsimaa --- src/pipeline/pass_route_nodes.c | 90 ++++++------ tests/test_infrascan.c | 240 ++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+), 40 deletions(-) diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 071f20c41..1ac7c3e37 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -408,61 +408,56 @@ void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefin snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, cbm_route_canon_path(def->route_path, cpath, sizeof(cpath))); char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm); + char esc_m[CBM_SZ_128]; + cbm_json_escape(esc_m, sizeof(esc_m), rm); + (void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", esc_m); int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", def->route_path, route_qn, def->file_path ? def->file_path : rel, 0, 0, rprops); - char hprops[CBM_SZ_512]; + char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the + closing brace */ char esc_h[CBM_SZ_512]; cbm_json_escape(esc_h, sizeof(esc_h), def->qualified_name); - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); + (void)snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); cbm_gbuf_insert_edge(gb, func_id, route_id, "HANDLES", hprops); } /* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges. * During incremental indexing, only changed files get Route nodes from extraction. * This pass scans ALL Function/Method nodes and creates missing Route+HANDLES. */ -/* Extract a JSON string property value into buf. Returns true if found. */ -static bool extract_json_prop(const char *json, const char *key, char *buf, int bufsz) { - if (!json) { +/* Extract the string property `key` of a parsed properties object into buf, + * with its escapes decoded. Returns true if found: a non-empty string with no + * NUL that fits buf. Nothing is copied otherwise. */ +static bool extract_json_prop(yyjson_val *root, const char *key, char *buf, size_t bufsz) { + yyjson_val *val = yyjson_obj_get(root, key); + const char *str = yyjson_get_str(val); + size_t len = yyjson_get_len(val); + if (!str || len == 0 || len >= bufsz || memchr(str, '\0', len)) { return false; } - char pattern[CBM_SZ_64]; - snprintf(pattern, sizeof(pattern), "\"%s\":\"", key); - const char *p = strstr(json, pattern); - if (!p) { - return false; - } - p += strlen(pattern); - const char *end = strchr(p, '"'); - if (!end || end <= p) { - return false; - } - int len = (int)(end - p); - if (len >= bufsz) { - return false; - } - memcpy(buf, p, (size_t)len); - buf[len] = '\0'; + memcpy(buf, str, len + SKIP_ONE); return true; } /* Process a single Function/Method node: create Route+HANDLES if it has route_path. * Returns 1 if a new HANDLES edge was created, 0 otherwise. */ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *func) { - if (!func->properties_json) { + /* Most nodes carry no route: skip them before parsing their properties. */ + if (!func->properties_json || !strstr(func->properties_json, "\"route_path\"")) { return 0; } + yyjson_doc *doc = yyjson_read(func->properties_json, strlen(func->properties_json), 0); + yyjson_val *root = yyjson_doc_get_root(doc); char path[CBM_SZ_256]; - if (!extract_json_prop(func->properties_json, "route_path", path, sizeof(path))) { - return 0; - } - if (path[0] != '/') { - return 0; - } - char method[CBM_SZ_16] = "ANY"; - extract_json_prop(func->properties_json, "route_method", method, sizeof(method)); + bool routed = extract_json_prop(root, "route_path", path, sizeof(path)) && path[0] == '/'; + if (routed) { + extract_json_prop(root, "route_method", method, sizeof(method)); + } + yyjson_doc_free(doc); + if (!routed) { + return 0; + } char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; @@ -471,7 +466,9 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun const cbm_gbuf_node_t *existing = cbm_gbuf_find_by_qn(gb, route_qn); char rprops[CBM_SZ_256]; - snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", method); + char esc_m[CBM_SZ_128]; + cbm_json_escape(esc_m, sizeof(esc_m), method); + (void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", esc_m); int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", path, route_qn, func->file_path ? func->file_path : "", 0, 0, rprops); @@ -486,9 +483,12 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun } } - char hprops[CBM_SZ_512]; - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", - func->qualified_name ? func->qualified_name : ""); + /* The same properties cbm_pipeline_emit_def_route gives the edge. */ + char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the + closing brace */ + char esc_h[CBM_SZ_512]; + cbm_json_escape(esc_h, sizeof(esc_h), func->qualified_name ? func->qualified_name : ""); + (void)snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h); cbm_gbuf_insert_edge(gb, func->id, route_id, "HANDLES", hprops); return SKIP_ONE; } @@ -1180,6 +1180,17 @@ typedef struct { int files_seen; } sveltekit_ctx_t; +/* The properties of a SvelteKit handler's HANDLES edge: its QN, escaped as + * every HANDLES edge's handler is. */ +static void sveltekit_handles_props(char *out, size_t outsz, const cbm_gbuf_node_t *child, + bool is_actions) { + char esc_h[CBM_SZ_512]; + cbm_json_escape(esc_h, sizeof(esc_h), + child->qualified_name ? child->qualified_name : child->name); + (void)snprintf(out, outsz, "{\"handler\":\"%s\",\"framework\":\"sveltekit\"%s}", esc_h, + is_actions ? ",\"via\":\"actions_object\"" : ""); +} + /* Process one File node: if it matches a SvelteKit server-side file, * synthesise a Route node and HANDLES edges from any handler functions * (or actions Variable) defined in the file. */ @@ -1256,10 +1267,9 @@ static void sveltekit_file_visitor(const cbm_gbuf_node_t *node, void *userdata) } ctx->routes_created++; - char hprops[CBM_SZ_256]; - snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\",\"framework\":\"sveltekit\"%s}", - child->qualified_name ? child->qualified_name : child->name, - is_actions ? ",\"via\":\"actions_object\"" : ""); + char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the + closing brace */ + sveltekit_handles_props(hprops, sizeof(hprops), child, is_actions); cbm_gbuf_insert_edge(ctx->gb, child->id, route_id, "HANDLES", hprops); ctx->handles_created++; } diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index abfa8788c..225ae8c15 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -2,10 +2,12 @@ #include "graph_buffer/graph_buffer.h" #include "pipeline/pipeline_internal.h" #include "service_patterns.h" +#include "yyjson/yyjson.h" #include #include #include +#include static int has_data_flow(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id) { const cbm_gbuf_edge_t **edges = NULL; @@ -385,6 +387,239 @@ TEST(infrascan_http_calls_join_matching_handler_route) { PASS(); } +/* An unchanged handler's Route is rebuilt from its stored route metadata. The + * metadata is JSON, so a route path with a quote or a backslash (a legal POSIX + * directory name) is stored escaped; reading it up to the first quote named a + * second Route "/api/a\" beside the extracted one, and the handler QN went into + * the HANDLES properties unescaped. Running the pass twice proves the rebuild + * finds the Route it made instead of adding another edge. */ +TEST(infrascan_decorator_route_metadata_keeps_its_escapes) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_escapes"); + ASSERT_NOT_NULL(gb); + int64_t handler = cbm_gbuf_upsert_node( + gb, "Function", "GET", "test.app.api.a\"b.route.GET", "app/api/a\"b/route.ts", 1, 1, + "{\"route_path\":\"/api/a\\\"b\\\\c\",\"route_method\":\"GET\"}"); + ASSERT_GT(handler, 0); + + cbm_pipeline_create_route_nodes(gb); + cbm_pipeline_create_route_nodes(gb); + + const cbm_gbuf_node_t **routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 1); + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, "__route__GET__/api/a\"b\\c"); + ASSERT_NOT_NULL(route); + ASSERT_STR_EQ(route->name, "/api/a\"b\\c"); + ASSERT_STR_EQ(route->file_path, "app/api/a\"b/route.ts"); + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count), 0); + ASSERT_EQ(edge_count, 1); + ASSERT_EQ(edges[0]->source_id, handler); + yyjson_doc *doc = yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + ASSERT_NOT_NULL(doc); + const char *qn = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "handler")); + bool same_handler = qn && strcmp(qn, "test.app.api.a\"b.route.GET") == 0; + yyjson_doc_free(doc); + ASSERT_TRUE(same_handler); + + cbm_gbuf_free(gb); + PASS(); +} + +/* Does the one HANDLES edge into Route route_qn hold properties that parse to + * {"handler": qn}? */ +static bool handles_names_handler(cbm_gbuf_t *gb, const char *route_qn, const char *qn) { + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, route_qn); + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (!route || + cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &edges, &edge_count) != 0 || + edge_count != 1) { + return false; + } + yyjson_doc *doc = yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + const char *handler = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "handler")); + bool same = handler && strcmp(handler, qn) == 0; + yyjson_doc_free(doc); + return same; +} + +/* The HANDLES properties hold the handler's QN escaped. A QN that fills the + * escape buffer left the closing `"}` off them, and one malformed properties + * row fails every query that reads edge properties. The edge extraction emits + * and the one an unchanged handler's metadata rebuilds must both parse. */ +TEST(infrascan_handles_properties_hold_a_long_handler_qn) { + char qn[CBM_SZ_512]; + memset(qn, 'q', sizeof(qn) - SKIP_ONE); + qn[sizeof(qn) - SKIP_ONE] = '\0'; + + cbm_gbuf_t *emitted = cbm_gbuf_new("test", "/tmp/cbm_infrascan_long_qn_emit"); + ASSERT_NOT_NULL(emitted); + int64_t handler = cbm_gbuf_upsert_node(emitted, "Function", "GET", qn, "server.ts", 1, 1, "{}"); + ASSERT_GT(handler, 0); + CBMDefinition def = {0}; + def.qualified_name = qn; + def.file_path = "server.ts"; + def.route_path = "/long"; + def.route_method = "GET"; + cbm_pipeline_emit_def_route(emitted, handler, &def, "server.ts"); + bool emitted_parses = handles_names_handler(emitted, "__route__GET__/long", qn); + cbm_gbuf_free(emitted); + + cbm_gbuf_t *rebuilt = cbm_gbuf_new("test", "/tmp/cbm_infrascan_long_qn_rebuild"); + ASSERT_NOT_NULL(rebuilt); + ASSERT_GT(cbm_gbuf_upsert_node(rebuilt, "Function", "GET", qn, "server.ts", 1, 1, + "{\"route_path\":\"/long\",\"route_method\":\"GET\"}"), + 0); + cbm_pipeline_create_route_nodes(rebuilt); + bool rebuilt_parses = handles_names_handler(rebuilt, "__route__GET__/long", qn); + cbm_gbuf_free(rebuilt); + + ASSERT_TRUE(emitted_parses); + ASSERT_TRUE(rebuilt_parses); + PASS(); +} + +/* Does Route route_qn hold properties that parse to {"method": method}? */ +static bool route_names_method(cbm_gbuf_t *gb, const char *route_qn, const char *method) { + const cbm_gbuf_node_t *route = cbm_gbuf_find_by_qn(gb, route_qn); + if (!route || !route->properties_json) { + return false; + } + yyjson_doc *doc = yyjson_read(route->properties_json, strlen(route->properties_json), 0); + const char *got = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "method")); + bool same = got && strcmp(got, method) == 0; + yyjson_doc_free(doc); + return same; +} + +/* A route method is source text where a Python DRF action names it + * (`@action(methods=['a"b'])`), so the Route's properties hold it escaped: + * both the Route extraction emits and the one rebuilt from the handler's + * metadata must parse. */ +TEST(infrascan_route_properties_escape_the_method) { + cbm_gbuf_t *emitted = cbm_gbuf_new("test", "/tmp/cbm_infrascan_method_emit"); + ASSERT_NOT_NULL(emitted); + int64_t handler = + cbm_gbuf_upsert_node(emitted, "Function", "act", "test.views.act", "views.py", 1, 1, "{}"); + ASSERT_GT(handler, 0); + CBMDefinition def = {0}; + def.qualified_name = "test.views.act"; + def.file_path = "views.py"; + def.route_path = "/m"; + def.route_method = "A\"B"; + cbm_pipeline_emit_def_route(emitted, handler, &def, "views.py"); + bool emitted_parses = route_names_method(emitted, "__route__A\"B__/m", "A\"B"); + cbm_gbuf_free(emitted); + + cbm_gbuf_t *rebuilt = cbm_gbuf_new("test", "/tmp/cbm_infrascan_method_rebuild"); + ASSERT_NOT_NULL(rebuilt); + ASSERT_GT(cbm_gbuf_upsert_node(rebuilt, "Function", "act", "test.views.act", "views.py", 1, 1, + "{\"route_path\":\"/m\",\"route_method\":\"A\\\"B\"}"), + 0); + cbm_pipeline_create_route_nodes(rebuilt); + bool rebuilt_parses = route_names_method(rebuilt, "__route__A\"B__/m", "A\"B"); + cbm_gbuf_free(rebuilt); + + ASSERT_TRUE(emitted_parses); + ASSERT_TRUE(rebuilt_parses); + PASS(); +} + +/* A SvelteKit +server handler's HANDLES properties hold its QN escaped, as + * every other HANDLES edge's do: a quote in a route directory, or a QN longer + * than the old 256-byte properties buffer, made them malformed. */ +TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn) { + char dir[CBM_SZ_512]; + snprintf(dir, sizeof(dir), "a\"b/%0300d", 0); + char file[CBM_SZ_1K]; + snprintf(file, sizeof(file), "src/routes/%s/+server.ts", dir); + char qn[CBM_SZ_1K]; + snprintf(qn, sizeof(qn), "test.src.routes.%s.+server.GET", dir); + + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_sveltekit_handles"); + ASSERT_NOT_NULL(gb); + int64_t file_id = + cbm_gbuf_upsert_node(gb, "File", "+server.ts", "test.server", file, 1, 1, "{}"); + int64_t handler = cbm_gbuf_upsert_node(gb, "Function", "GET", qn, file, 1, 1, "{}"); + ASSERT_GT(file_id, 0); + ASSERT_GT(handler, 0); + cbm_gbuf_insert_edge(gb, file_id, handler, "DEFINES", "{}"); + cbm_pipeline_create_route_nodes(gb); + + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + bool same = false; + if (cbm_gbuf_find_edges_by_source_type(gb, handler, "HANDLES", &edges, &edge_count) == 0 && + edge_count == 1) { + yyjson_doc *doc = + yyjson_read(edges[0]->properties_json, strlen(edges[0]->properties_json), 0); + yyjson_val *root = yyjson_doc_get_root(doc); + const char *got = yyjson_get_str(yyjson_obj_get(root, "handler")); + const char *framework = yyjson_get_str(yyjson_obj_get(root, "framework")); + same = got && strcmp(got, qn) == 0 && framework && strcmp(framework, "sveltekit") == 0; + yyjson_doc_free(doc); + } + cbm_gbuf_free(gb); + ASSERT_TRUE(same); + PASS(); +} + +/* Route metadata that does not parse, is not a top-level string, holds a NUL or + * does not fit the path buffer names no Route at all, rather than whatever text + * happens to sit between two quotes. The 255-byte path is the longest that fits + * and must still route. */ +TEST(infrascan_decorator_route_metadata_rejects_malformed_values) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_malformed"); + ASSERT_NOT_NULL(gb); + char longest[CBM_SZ_256 + CBM_SZ_64]; + char too_long[CBM_SZ_256 + CBM_SZ_64]; + char path[CBM_SZ_256 + 1]; + path[0] = '/'; + memset(path + 1, 'x', CBM_SZ_256 - 1); + path[CBM_SZ_256] = '\0'; + snprintf(too_long, sizeof(too_long), "{\"route_path\":\"%s\"}", path); + path[CBM_SZ_256 - 1] = '\0'; + snprintf(longest, sizeof(longest), "{\"route_path\":\"%s\"}", path); + const char *props[] = { + "{\"route_path\":\"/truncated\"", + "{\"meta\":{\"route_path\":\"/nested\"}}", + "{\"route_path\":[\"/array\"]}", + "{\"route_path\":42}", + "{\"route_path\":\"/a\\u0000b\"}", + "{\"route_path\":\"\"}", + "{\"route_path\":\"relative\"}", + "[\"route_path\",\"/not-an-object\"]", + too_long, + }; + const int prop_count = (int)(sizeof(props) / sizeof(props[0])); + for (int i = 0; i < prop_count; i++) { + char qn[CBM_SZ_32]; + snprintf(qn, sizeof(qn), "test.bad%d", i); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", qn, qn, "server.ts", 1, 1, props[i]), 0); + } + cbm_pipeline_create_route_nodes(gb); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 0); + const cbm_gbuf_node_t **routes = NULL; + int route_count = 0; + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 0); + + int64_t fits = + cbm_gbuf_upsert_node(gb, "Function", "fits", "test.fits", "server.ts", 1, 1, longest); + ASSERT_GT(fits, 0); + cbm_pipeline_create_route_nodes(gb); + ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Route", &routes, &route_count), 0); + ASSERT_EQ(route_count, 1); + ASSERT_STR_EQ(routes[0]->name, path); + ASSERT_EQ(cbm_gbuf_edge_count_by_type(gb, "HANDLES"), 1); + + cbm_gbuf_free(gb); + PASS(); +} + /* FD16: a route's DATA_FLOWS were built from the first 64 callers and the first 32 infra handlers * in edge-insertion order (parallel worker interleaving), so the flow set changed between two * indexes of the same tree and every caller past the 64th lost its flow. */ @@ -557,6 +792,11 @@ SUITE(infrascan) { RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); RUN_TEST(infrascan_http_calls_join_matching_handler_route); + RUN_TEST(infrascan_decorator_route_metadata_keeps_its_escapes); + RUN_TEST(infrascan_decorator_route_metadata_rejects_malformed_values); + RUN_TEST(infrascan_handles_properties_hold_a_long_handler_qn); + RUN_TEST(infrascan_route_properties_escape_the_method); + RUN_TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn); RUN_TEST(infrascan_data_flows_link_every_caller_of_a_route); RUN_TEST(infrascan_data_flows_do_not_depend_on_caller_insertion_order); RUN_TEST(infrascan_data_flows_reach_every_infra_handler); From d2e00366c02ea154bdaa679a84d756a6a69ee81d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 21:21:30 +0200 Subject: [PATCH 45/76] test(contract): drop the language-count surfaces #2729 stopped claiming The #2729 messaging refresh removed the language count from docs/index.html, docs/llms.txt, pkg/npm/README.md, scripts/package-release.sh and server.json, so Step 0w (tests/test_language_count_contract.sh) failed on main for every PR: each of those files was still registered as a surface but stated no count. They leave SURFACES; README.md and the Chocolatey nuspec still carry the count and stay gated. Section 4 still fails if any dropped file states a count again without being registered (checked by re-adding one). Signed-off-by: Martin Vogel --- tests/test_language_count_contract.sh | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_language_count_contract.sh b/tests/test_language_count_contract.sh index 33c9a01cb..93d171d6f 100644 --- a/tests/test_language_count_contract.sh +++ b/tests/test_language_count_contract.sh @@ -46,14 +46,12 @@ if [[ "$manifest_count" != "$expected" ]]; then fi # --- 3. Every published surface must claim exactly that number. --- +# The #2729 messaging refresh dropped the count from docs/index.html, +# docs/llms.txt, pkg/npm/README.md, scripts/package-release.sh and server.json; +# section 4 below still fails if any of them states a count again unregistered. SURFACES=( README.md - docs/index.html - docs/llms.txt - pkg/npm/README.md pkg/chocolatey/codebase-memory-mcp.nuspec - scripts/package-release.sh - server.json ) # Files that legitimately say " languages" about something OTHER than the From 49130a3d8f7786f987d0ddb1645dc15225bff4d1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 00:48:24 +0200 Subject: [PATCH 46/76] fix(deps): patch Vite and certifi security advisories Signed-off-by: Martin Vogel --- graph-ui/package-lock.json | 8 ++++---- graph-ui/package.json | 2 +- pkg/pypi/requirements-publish.txt | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index c444f4e41..7450f2cde 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -32,7 +32,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.3", + "vite": "6.4.4", "vitest": "4.1.11" } }, @@ -5277,9 +5277,9 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/graph-ui/package.json b/graph-ui/package.json index a38ba6faa..fdcd64d97 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -62,7 +62,7 @@ "playwright": "1.62.1", "three-stdlib": "2.36.1", "typescript": "5.9.3", - "vite": "6.4.3", + "vite": "6.4.4", "vitest": "4.1.11" }, "overrides": { diff --git a/pkg/pypi/requirements-publish.txt b/pkg/pypi/requirements-publish.txt index a2f26b0ad..5b9521c02 100644 --- a/pkg/pypi/requirements-publish.txt +++ b/pkg/pypi/requirements-publish.txt @@ -15,9 +15,9 @@ build==1.3.0 \ --hash=sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397 \ --hash=sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4 -certifi==2026.7.22 \ - --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ - --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 +certifi==2026.10.19 \ + --hash=sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae \ + --hash=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b cffi==2.1.1 \ --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ From b74e91cb2f2b523a2ab00b565ff6d40f6878e920 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 21 Oct 2026 10:46:20 +0800 Subject: [PATCH 47/76] test(ts): cover module-level inferred receiver Signed-off-by: mikemikimike <13286568797@163.com> --- tests/repro/repro_lsp_ts.c | 56 ++++++++++++++------------------------ 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/tests/repro/repro_lsp_ts.c b/tests/repro/repro_lsp_ts.c index eefed2cd9..6221e4416 100644 --- a/tests/repro/repro_lsp_ts.c +++ b/tests/repro/repro_lsp_ts.c @@ -115,8 +115,9 @@ * does (".ts" → TypeScript, ".tsx" → TSX). Returns 0 on PASS (GREEN), non-zero * on FAIL (RED) — the redness is the documented per-pass status. */ -static int assert_lsp_strategy_files(const RFile *files, int nfiles, - const char *strategy) { +static int assert_lsp_strategy_files_expected(const RFile *files, int nfiles, + const char *strategy, + int expected_module_sourced) { RProj lp; cbm_store_t *store = rh_index_files(&lp, files, nfiles); if (!store) { @@ -135,12 +136,13 @@ static int assert_lsp_strategy_files(const RFile *files, int nfiles, int rc = 0; - /* (a) callable-sourcing floor: zero Module/File-sourced CALLS edges. */ - if (module_sourced != 0) { + /* (a) keep module-sourced calls explicit so top-level constructors are + * allowed only in fixtures that expect them. */ + if (module_sourced != expected_module_sourced) { printf(" %sFAIL%s %s:%d: strategy %s: %d Module-sourced CALLS " - "(expected 0)\n", + "(expected %d)\n", tf_red(), tf_reset(), __FILE__, __LINE__, strategy, - module_sourced); + module_sourced, expected_module_sourced); rc = 1; } /* There must be a callable-sourced CALLS edge, else the fixture produced no @@ -165,6 +167,11 @@ static int assert_lsp_strategy_files(const RFile *files, int nfiles, return rc; } +static int assert_lsp_strategy_files(const RFile *files, int nfiles, + const char *strategy) { + return assert_lsp_strategy_files_expected(files, nfiles, strategy, 0); +} + /* Single-file convenience wrapper. */ static int assert_lsp_strategy(const char *filename, const char *src, const char *strategy) { @@ -297,9 +304,8 @@ static const RFile kTsImport[] = { "function caller(v: number): number { return helper(v); }\n"}, }; -/* An imported class constructed through an inferred local variable must retain - * the imported class QN for subsequent member dispatch. The explicit type - * annotation path already covers this shape; this fixture isolates inference. +/* Keep the module-level inferred receiver shape from issue #1974: a local + * initialized from an imported constructor is used by a later function. */ static const RFile kTsInferredImportedMethod[] = { {"store.ts", @@ -308,21 +314,12 @@ static const RFile kTsInferredImportedMethod[] = { "}\n"}, {"service.ts", "import { WidgetStore } from \"./store\";\n" + "const inferred = new WidgetStore();\n" "function load(id: string): string {\n" - " const store = new WidgetStore();\n" - " return store.findById(id);\n" + " return inferred.findById(id);\n" "}\n"}, }; -/* A standard-library class constructed through an inferred local variable must - * retain its bare stdlib QN for member dispatch; the current expected-red - * result and investigation are documented at the board case below. */ -static const char kTsInferredStdlibMethod[] = - "function lookup(id: string): string {\n" - " const m = new Map();\n" - " return m.get(id);\n" - "}\n"; - /* lsp_ts_jsx — JSX element whose tag is a module-local component * function (ts_lsp.c:2643-2647). TSX only (jsx_mode); the tag's first letter is * uppercase so it is NOT treated as an intrinsic HTML element; it resolves via @@ -396,22 +393,12 @@ TEST(repro_lsp_ts_import) { "lsp_ts_import"); } -TEST(repro_lsp_ts_inferred_import_receiver) { - return assert_lsp_strategy_files( +TEST(repro_lsp_ts_inferred_import_module_receiver) { + return assert_lsp_strategy_files_expected( kTsInferredImportedMethod, (int)(sizeof(kTsInferredImportedMethod) / sizeof(kTsInferredImportedMethod[0])), - "lsp_ts_method"); -} - -TEST(repro_lsp_ts_inferred_stdlib_receiver) { - /* EXPECTED-RED on the non-gating board: the runner emits no callable- - * sourced CALLS edge (callable=0) and no lsp_ts_method strategy. Verified - * with scripts/repro.sh CC=gcc CXX=g++; the focused ts_lsp suite does not - * run this file. Current, unproven hypothesis: Map.get is unavailable as - * a callable target from the TypeScript stdlib seed. */ - return assert_lsp_strategy("main.ts", kTsInferredStdlibMethod, - "lsp_ts_method"); + "lsp_ts_method", 1); } TEST(repro_lsp_ts_jsx) { @@ -447,8 +434,7 @@ SUITE(repro_lsp_ts) { RUN_TEST(repro_lsp_ts_method); RUN_TEST(repro_lsp_ts_namespace); RUN_TEST(repro_lsp_ts_import); - RUN_TEST(repro_lsp_ts_inferred_import_receiver); - RUN_TEST(repro_lsp_ts_inferred_stdlib_receiver); + RUN_TEST(repro_lsp_ts_inferred_import_module_receiver); RUN_TEST(repro_lsp_ts_jsx); RUN_TEST(repro_lsp_ts_jsx_import); RUN_TEST(repro_lsp_ts_default); From d9766ae878362d614e195a89ccdbd4736a5e6f13 Mon Sep 17 00:00:00 2001 From: Petteri Ponsimaa Date: Wed, 21 Oct 2026 09:59:41 +0300 Subject: [PATCH 48/76] fix(pipeline): keep route properties that are not UTF-8 Review follow-up to the previous commit. Properties hold source text as its bytes, so a Latin-1 docstring stored beside route_path is not UTF-8. The strict yyjson parse rejected that whole object, so the route pass skipped rebuilding the handler's Route, where the old quote scan had rebuilt it. Parse with YYJSON_READ_ALLOW_INVALID_UNICODE, as internal/cbm/diagram.c does. Test: infrascan_decorator_route_metadata_survives_invalid_utf8 fails with the strict parse, and passes with this change and on 4cf44147. Signed-off-by: Petteri Ponsimaa --- src/pipeline/pass_route_nodes.c | 4 +++- tests/test_infrascan.c | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 1ac7c3e37..23dff8150 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -446,7 +446,9 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun return 0; } - yyjson_doc *doc = yyjson_read(func->properties_json, strlen(func->properties_json), 0); + /* Properties hold source text as its bytes, which need not be UTF-8. */ + yyjson_doc *doc = yyjson_read(func->properties_json, strlen(func->properties_json), + YYJSON_READ_ALLOW_INVALID_UNICODE); yyjson_val *root = yyjson_doc_get_root(doc); char path[CBM_SZ_256]; char method[CBM_SZ_16] = "ANY"; diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 225ae8c15..3b31f62e1 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -620,6 +620,31 @@ TEST(infrascan_decorator_route_metadata_rejects_malformed_values) { PASS(); } +/* Properties hold source text as its bytes, which need not be UTF-8: a + * Latin-1 docstring beside the route metadata, or a Latin-1 route path. The + * Route extraction emits for them must come back from the stored metadata too, + * as it did before the metadata was parsed. */ +TEST(infrascan_decorator_route_metadata_survives_invalid_utf8) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_route_latin1"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "doc", "test.doc", "views.py", 1, 1, + "{\"docstring\":\"caf\xe9\",\"route_path\":\"/doc\"," + "\"route_method\":\"GET\"}"), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Function", "GET", "test.app.caf\xe9.route.GET", + "app/caf\xe9/route.ts", 1, 1, + "{\"route_path\":\"/caf\xe9\",\"route_method\":\"GET\"}"), + 0); + cbm_pipeline_create_route_nodes(gb); + const cbm_gbuf_node_t *doc_route = cbm_gbuf_find_by_qn(gb, "__route__GET__/doc"); + const cbm_gbuf_node_t *dir_route = cbm_gbuf_find_by_qn(gb, "__route__GET__/caf\xe9"); + bool both = doc_route && dir_route && strcmp(dir_route->name, "/caf\xe9") == 0 && + cbm_gbuf_edge_count_by_type(gb, "HANDLES") == 2; + cbm_gbuf_free(gb); + ASSERT_TRUE(both); + PASS(); +} + /* FD16: a route's DATA_FLOWS were built from the first 64 callers and the first 32 infra handlers * in edge-insertion order (parallel worker interleaving), so the flow set changed between two * indexes of the same tree and every caller past the 64th lost its flow. */ @@ -794,6 +819,7 @@ SUITE(infrascan) { RUN_TEST(infrascan_http_calls_join_matching_handler_route); RUN_TEST(infrascan_decorator_route_metadata_keeps_its_escapes); RUN_TEST(infrascan_decorator_route_metadata_rejects_malformed_values); + RUN_TEST(infrascan_decorator_route_metadata_survives_invalid_utf8); RUN_TEST(infrascan_handles_properties_hold_a_long_handler_qn); RUN_TEST(infrascan_route_properties_escape_the_method); RUN_TEST(infrascan_sveltekit_handles_properties_escape_the_handler_qn); From 0325d6a3fa6a36b4f03f5dd6b8e7d7041a5448a6 Mon Sep 17 00:00:00 2001 From: wargloom Date: Wed, 21 Oct 2026 00:16:01 +0300 Subject: [PATCH 49/76] fix: watch explicit MCP index targets Signed-off-by: wargloom --- README.md | 14 +- docs/CONFIGURATION.md | 14 +- src/daemon/application.c | 462 +++++++++++++++++++++-------- src/mcp/mcp.c | 14 +- tests/test_daemon_application.c | 511 +++++++++++++++++++++++++++++++- 5 files changed, 860 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 7cdac83bf..f12173304 100644 --- a/README.md +++ b/README.md @@ -253,13 +253,13 @@ Enable automatic indexing on MCP session start: codebase-memory-mcp config set auto_index true ``` -`auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched independently through `auto_watch` (on by default), subject to the session rules below. +`auto_index` is off by default. When enabled, new projects are indexed automatically on first connection. The configurable file limit is `config set auto_index_limit 50000`. Previously indexed projects can be watched through `auto_watch` (on by default), subject to its global setting and session ownership. -The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched; re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. +Each initialized, long-lived MCP session can register its root project when it connects and any target from a successful `index_repository` call, including custom project names and roots in another worktree. While `auto_watch` is enabled, these watches belong to the registering sessions and remain while at least one owning session is connected; closing the last owner removes the watch. An async index registers its target only after success and while its requesting session remains connected. CLI indexing and failed MCP indexes do not register watches. Global `auto_watch` controls registration and removes all watches on the next refresh or registration request when set to `false`. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. -Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher; useful when working across many projects and you want each session contained to explicit indexing. +Watcher registration is controlled globally by `auto_watch` (default `true`). On the next refresh or registration request, `true` allows MCP sessions to register their root on connect and their successful `index_repository` targets; `false` removes all watches. CLI indexing and failed MCP indexes do not add watches. -To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch`, which is consulted per session, `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). +To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch`, which takes effect on the next refresh or registration request, `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). The watcher follows **git** projects only by default: a project indexed from a directory that is not a git repository is not watched and stays at its last index until you run `index_repository` again. To have such roots polled too, set `config set watch_non_git true` (default `false`, read when the daemon starts): they are then checked on the same cadence with a file-tree scan that uses the indexer's own ignore rules. @@ -402,7 +402,7 @@ In this repository, test narrowing is wired to the Linux x86_64 test leg when `T ### Distribution & operation - **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/` -- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`) +- **Auto-sync**: Background watcher detects file changes in projects registered by active MCP sessions, including successful explicit `index_repository` targets, and re-indexes them automatically. - **Route nodes**: REST endpoints are first-class graph entities - **CLI mode**: `codebase-memory-mcp cli search_graph --project my-project --name-pattern ".*Handler.*"` - **Available on**: npm, PyPI, Homebrew, Scoop, AUR, `go install` @@ -873,7 +873,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a | Tool | Description | |------|-------------| -| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | +| `index_repository` | Index a repository into the graph. A successful call from an active MCP session registers the target for auto-sync while that session remains connected, subject to the global `auto_watch` setting; CLI calls do not register watches. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | | `list_projects` | List indexed projects with paging; use `detail: "stats"` for node/edge counts and database sizes. | | `delete_project` | Remove a project and all its graph data. | | `index_status` | Check indexing status of a project. | @@ -967,7 +967,7 @@ See [docs/cbmignore.md](docs/cbmignore.md) for the full `.cbmignore` how-to: syn codebase-memory-mcp config list # show all settings codebase-memory-mcp config set auto_index true # auto-index on session start codebase-memory-mcp config set auto_index_limit 50000 # max files for auto-index -codebase-memory-mcp config set auto_watch false # don't register background git watcher (default: true) +codebase-memory-mcp config set auto_watch false # remove/disable all background watches (default: true) codebase-memory-mcp config set doc_candidates false # skip doc<->code candidate scoring on huge repos (default: auto = on for full, off for moderate/fast) codebase-memory-mcp config set watcher_enabled false # stop the watcher thread entirely (default: true) codebase-memory-mcp config set watch_non_git true # also poll non-git project roots (default: false) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3e9956d60..5573f4152 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,9 +87,9 @@ Current keys: |---|---|---| | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | -| `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | +| `auto_watch` | `true` | Global switch for MCP session watch registration. On the next refresh or registration request, `true` allows session roots and successful `index_repository` targets to be watched; `false` removes all watches. The background watcher can still run with no registered projects. | | `doc_candidates` | `auto` | Score documentation sections against code to store doc↔code candidates (the possible targets of a section, and the sections that may describe a function or file, each with a judged probability). Unset (`auto`), the index mode decides: on for `full` indexes (the default mode), off for `moderate` and `fast`. Set `true` or `false` to choose for every mode; `config reset doc_candidates` returns to `auto`. The scoring grows with sections × functions: under a second up to ~100k functions, ~15 s at ~400k, ~3 minutes on the Linux kernel. Off stores no candidates and leaves the rest of the graph unchanged; on in `fast` mode stores candidates without adding `fast`'s skipped `SIMILAR_TO` / `SEMANTICALLY_RELATED` edges. `index_repository` also takes `doc_candidates` for a single index, which wins over this setting. Applies from the next index. | -| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. | +| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Active MCP sessions can register their root project and successful explicit index targets; `index_status` reports the current state in its `watch` object. | | `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | @@ -102,15 +102,17 @@ has not run yet). > **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the > watcher *subsystem* starts at all (the background poll thread). `auto_watch` is -> narrower: it only controls whether a connecting session registers *its own* -> project with an already-running watcher. When `watcher_enabled=false`, +> narrower: it globally controls MCP session watch registration. On the next +> refresh or registration request, `false` removes all watches; `true` allows +> session roots and successful explicit `index_repository` targets to register. +> When `watcher_enabled=false`, > `auto_watch` has no effect — there is no watcher to register with. > > They also differ in **when they are read**, which matters because the watcher > lives in the background daemon, not in your MCP client: > -> - `auto_watch` is consulted each time a session would register its project, so -> a change applies to sessions that connect afterwards. +> - `auto_watch` takes effect on the next refresh or registration request and +> applies globally to session roots and successful `index_repository` targets. > - `watcher_enabled` is read **once, when the daemon starts**, because it decides > whether the watcher is built at all. The daemon is long-lived and outlives > individual MCP sessions, so **reconnecting your client is not enough** — retire diff --git a/src/daemon/application.c b/src/daemon/application.c index 32b407743..d45fcabab 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -102,10 +102,23 @@ typedef enum { struct cbm_daemon_application_watch { char *project; char *root; + char *index_mode; + bool persistence; size_t subscribers; cbm_daemon_application_watch_t *next; }; +typedef struct application_watch_membership { + cbm_daemon_application_watch_t *watch; + struct application_watch_membership *next; +} application_watch_membership_t; + +typedef struct application_index_watch_owner { + cbm_daemon_application_session_t *session; + bool async; + struct application_index_watch_owner *next; +} application_index_watch_owner_t; + struct cbm_daemon_application_session { cbm_daemon_application_t *application; cbm_mcp_server_t *mcp; @@ -119,7 +132,7 @@ struct cbm_daemon_application_session { bool request_active; cbm_daemon_runtime_application_token_t active_request_token; cbm_daemon_runtime_application_token_t request_cancel_token; - cbm_daemon_application_watch_t *watch; + application_watch_membership_t *watches; cbm_daemon_application_job_t *active_job; bool active_job_subscribed; cbm_daemon_application_job_t *auto_index_job; @@ -159,6 +172,7 @@ struct cbm_daemon_application_job { * job outlives the request and session that started it. */ bool async_owned; bool worker_started; + application_index_watch_owner_t *watch_owners; cbm_daemon_application_job_t *next; }; @@ -232,7 +246,7 @@ struct cbm_daemon_application { static void application_job_unsubscribe_locked(cbm_daemon_application_job_t *job); static void application_watch_job_unsubscribe_session_locked( - cbm_daemon_application_session_t *session); + cbm_daemon_application_session_t *session, const cbm_daemon_application_watch_t *watch); static bool application_watch_job_subscribe_late_session_locked( cbm_daemon_application_session_t *session, cbm_daemon_application_watch_t *watch); static bool application_unique_recovery_file(char out[APPLICATION_PATH_CAP], const char *kind); @@ -475,15 +489,21 @@ static void application_remove_watch_entry_locked(cbm_daemon_application_t *appl while (*cursor && *cursor != watch) { cursor = &(*cursor)->next; } - if (*cursor != watch) { + if (!watch || *cursor != watch) { return; } *cursor = watch->next; for (cbm_daemon_application_session_t *session = application->sessions; session; session = session->next) { - if (session->watch == watch) { - application_watch_job_unsubscribe_session_locked(session); - session->watch = NULL; + application_watch_membership_t **membership = &session->watches; + while (*membership && (*membership)->watch != watch) { + membership = &(*membership)->next; + } + if (*membership) { + application_watch_job_unsubscribe_session_locked(session, watch); + application_watch_membership_t *removed = *membership; + *membership = removed->next; + cbm_free(CBM_MEM_CLASS_OTHER, removed); } } if (unregister_physical_watch && application->watcher) { @@ -491,7 +511,8 @@ static void application_remove_watch_entry_locked(cbm_daemon_application_t *appl } free(watch->project); free(watch->root); - free(watch); + cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode); + cbm_free(CBM_MEM_CLASS_OTHER, watch); } static void application_remove_watch_locked(cbm_daemon_application_t *application, @@ -500,17 +521,18 @@ static void application_remove_watch_locked(cbm_daemon_application_t *applicatio } static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) { - cbm_daemon_application_watch_t *watch = session->watch; - if (!watch) { - return; - } - application_watch_job_unsubscribe_session_locked(session); - session->watch = NULL; - if (watch->subscribers > 0) { - watch->subscribers--; - } - if (watch->subscribers == 0) { - application_remove_watch_locked(session->application, watch); + application_watch_job_unsubscribe_session_locked(session, NULL); + while (session->watches) { + application_watch_membership_t *membership = session->watches; + cbm_daemon_application_watch_t *watch = membership->watch; + session->watches = membership->next; + cbm_free(CBM_MEM_CLASS_OTHER, membership); + if (watch->subscribers > 0) { + watch->subscribers--; + if (watch->subscribers == 0) { + application_remove_watch_locked(session->application, watch); + } + } } } @@ -531,87 +553,200 @@ static bool application_session_workspace_allowed(const cbm_daemon_application_s } /* Caller holds application->mutex. */ -static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) { - cbm_daemon_application_t *application = session->application; +static bool application_session_has_watch_locked(const cbm_daemon_application_session_t *session, + const cbm_daemon_application_watch_t *watch) { + for (application_watch_membership_t *membership = session->watches; membership; + membership = membership->next) { + if (membership->watch == watch) { + return true; + } + } + return false; +} + +static bool application_watch_set_options(cbm_daemon_application_watch_t *watch, + const char *args_json) { + if (!args_json) { + return true; + } + yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0); + yyjson_val *args = doc ? yyjson_doc_get_root(doc) : NULL; + const char *mode = yyjson_get_str(yyjson_obj_get(args, "mode")); + char *mode_copy = mode ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, mode) : NULL; + if (!yyjson_is_obj(args) || (mode && !mode_copy)) { + cbm_free(CBM_MEM_CLASS_OTHER, mode_copy); + yyjson_doc_free(doc); + return false; + } + cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode); + watch->index_mode = mode_copy; + watch->persistence = yyjson_get_bool(yyjson_obj_get(args, "persistence")); + yyjson_doc_free(doc); + return true; +} + +static bool application_watch_registration_allowed(const cbm_daemon_application_session_t *session, + const char *project, const char *root) { + const cbm_daemon_application_t *application = session->application; if (!application->watcher || !session->context_set || session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || session->hook_event || - session->hook_dialect || session->auto_index_subscribed) { + session->hook_dialect || session->session_cancelled || application->stopping || !project || + !project[0] || !root || !root[0]) { + return false; + } + char boundary_error[CBM_SZ_1K]; + return cbm_workspace_root_allowed(root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), + cbm_mcp_server_allowed_root(session->mcp), boundary_error, + sizeof(boundary_error)); +} + +static void application_register_watch_locked(cbm_daemon_application_session_t *session, + const char *project, const char *root, + const char *args_json) { + cbm_daemon_application_t *application = session->application; + if (!application_watch_registration_allowed(session, project, root)) { return; } - const char *project = cbm_mcp_server_session_project(session->mcp); - const char *root = cbm_mcp_server_session_root(session->mcp); - if (!project || !project[0] || !root || !root[0]) { + cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project); + if ((application->config && + !cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) || + !application_regular_db_exists(project)) { + if (watch) { + application_remove_watch_locked(application, watch); + } + return; + } + if (watch && strcmp(watch->root, root) != 0) { + cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root", + watch->root, "requested_root", root); + return; + } + if (watch && application_session_has_watch_locked(session, watch)) { + (void)application_watch_set_options(watch, args_json); + return; + } + application_watch_membership_t *membership = + cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*membership)); + if (!membership) { + return; + } + if (watch) { + if (!application_watch_set_options(watch, args_json) || + !application_watch_job_subscribe_late_session_locked(session, watch)) { + cbm_free(CBM_MEM_CLASS_OTHER, membership); + cbm_log_warn("daemon.watch.late_owner_allocation_failed", "project", project, "action", + "retry"); + return; + } + } else { + watch = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*watch)); + if (watch) { + watch->project = strdup(project); + watch->root = strdup(root); + } + if (!watch || !watch->project || !watch->root || + !application_watch_set_options(watch, args_json) || + !cbm_watcher_watch(application->watcher, project, root)) { + if (watch) { + free(watch->project); + free(watch->root); + cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode); + cbm_free(CBM_MEM_CLASS_OTHER, watch); + } + cbm_free(CBM_MEM_CLASS_OTHER, membership); + cbm_log_warn("daemon.watch.registration_failed", "project", project, "action", "retry"); + return; + } + watch->next = application->watches; + application->watches = watch; + } + watch->subscribers++; + membership->watch = watch; + membership->next = session->watches; + session->watches = membership; +} + +static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) { + cbm_daemon_application_t *application = session->application; + if (application->config && + !cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) { + while (application->watches) { + application_remove_watch_locked(application, application->watches); + } + return; + } + if (session->auto_index_subscribed || application_request_cancelled_locked(session)) { return; } if (!application_session_workspace_allowed(session, "watch")) { application_release_session_watch_locked(session); return; } - bool enabled = !application->config || - cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true); - bool db_exists = application_regular_db_exists(project); + application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp), + cbm_mcp_server_session_root(session->mcp), NULL); +} - /* Disconnect cancellation is the logical ownership boundary. An - * in-flight request may reach this refresh after cancel returned but - * before runtime can join it and call session_close; it must never - * recreate that session's watch. */ - if (application->stopping || application_request_cancelled_locked(session)) { +/* Pending registrations do not own an async job: the index may outlive its + * caller, but only a still-connected MCP session can acquire its watch. */ +static void application_index_watch_subscribe_locked(cbm_daemon_application_session_t *session, + cbm_daemon_application_job_t *job, + bool async) { + if (session->one_shot_request || !session->background_eligible || session->session_cancelled || + (session->application->config && + !cbm_config_get_bool(session->application->config, CBM_CONFIG_AUTO_WATCH, true))) { return; } - cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project); - if (!enabled || !db_exists) { - /* A delete/config transition is global for this project. Remove the - * physical watch and clear every logical subscriber in one step. */ - if (watch) { - application_remove_watch_locked(application, watch); + if (job->terminal) { + if (job->successful) { + application_register_watch_locked(session, job->project_key, job->root_path, + job->args_json); } return; } - if (session->watch) { - return; + for (application_index_watch_owner_t *owner = job->watch_owners; owner; owner = owner->next) { + if (owner->session == session) { + owner->async = owner->async || async; + return; + } } - if (watch) { - if (strcmp(watch->root, root) == 0) { - if (!application_watch_job_subscribe_late_session_locked(session, watch)) { - cbm_log_warn("daemon.watch.late_owner_allocation_failed", "project", project, - "action", "retry"); - return; + application_index_watch_owner_t *owner = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*owner)); + if (owner) { + owner->session = session; + owner->async = async; + owner->next = job->watch_owners; + job->watch_owners = owner; + } +} + +static void application_index_watch_unsubscribe_locked(cbm_daemon_application_session_t *session, + cbm_daemon_application_job_t *target) { + for (cbm_daemon_application_job_t *job = session->application->jobs; job; job = job->next) { + if (target && job != target) { + continue; + } + application_index_watch_owner_t **cursor = &job->watch_owners; + while (*cursor) { + application_index_watch_owner_t *owner = *cursor; + if (owner->session == session && (!target || !owner->async)) { + *cursor = owner->next; + cbm_free(CBM_MEM_CLASS_OTHER, owner); + } else { + cursor = &owner->next; } - watch->subscribers++; - session->watch = watch; - } else { - cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root", - watch->root, "requested_root", root); } - return; } +} - watch = calloc(1, sizeof(*watch)); - if (watch) { - watch->project = strdup(project); - watch->root = strdup(root); - } - if (!watch || !watch->project || !watch->root) { - if (watch) { - free(watch->project); - free(watch->root); - free(watch); +static void application_index_watch_publish_locked(cbm_daemon_application_job_t *job) { + while (job->watch_owners) { + application_index_watch_owner_t *owner = job->watch_owners; + job->watch_owners = owner->next; + if (job->successful) { + application_register_watch_locked(owner->session, job->project_key, job->root_path, + job->args_json); } - return; + cbm_free(CBM_MEM_CLASS_OTHER, owner); } - /* Physical registration is the commit point. Never publish a logical - * subscription that the shared watcher failed to install. */ - if (!cbm_watcher_watch(application->watcher, project, root)) { - cbm_log_warn("daemon.watch.registration_failed", "project", project, "action", "retry"); - free(watch->project); - free(watch->root); - free(watch); - return; - } - watch->subscribers = 1; - watch->next = application->watches; - application->watches = watch; - session->watch = watch; } static const char *application_watch_strategy_name(cbm_watcher_strategy_t strategy) { @@ -628,8 +763,8 @@ static const char *application_watch_strategy_name(cbm_watcher_strategy_t strate } /* index_status watch visibility (#2167). The physical watcher is the truth for - * "watched"; the reasons explain the documented scope: only an MCP session's - * own project is watched, and only while that session is open. Takes the + * "watched"; session roots and successful explicit targets stay watched while + * at least one owning MCP session remains open. Takes the * application mutex and the watcher lock one after the other, never nested. */ static void application_watch_status(void *context, const char *project, cbm_mcp_watch_status_t *out) { @@ -678,6 +813,11 @@ static void application_job_free(cbm_daemon_application_job_t *job) { if (!job) { return; } + while (job->watch_owners) { + application_index_watch_owner_t *owner = job->watch_owners; + job->watch_owners = owner->next; + cbm_free(CBM_MEM_CLASS_OTHER, owner); + } free(job->project_key); free(job->root_path); free(job->args_json); @@ -1787,6 +1927,7 @@ static void application_job_publish(cbm_daemon_application_job_t *job, !execution->last_result.cancellation_requested); job->terminal = true; job->thread_done = true; + application_index_watch_publish_locked(job); application_index_record_finish_locked(job); if (job->async_owned) { /* The async reference ends with the job; terminal, so no cancel. */ @@ -2533,7 +2674,8 @@ static bool application_watch_job_subscribe_sessions_locked(cbm_daemon_applicati cbm_daemon_application_watch_job_subscription_t *pending = NULL; for (cbm_daemon_application_session_t *session = application->sessions; session; session = session->next) { - if (session->session_cancelled || !session->context_set || session->watch != watch) { + if (session->session_cancelled || !session->context_set || + !application_session_has_watch_locked(session, watch)) { continue; } (*matched_out)++; @@ -2567,7 +2709,7 @@ static bool application_watch_job_subscribe_sessions_locked(cbm_daemon_applicati } static void application_watch_job_unsubscribe_session_locked( - cbm_daemon_application_session_t *session) { + cbm_daemon_application_session_t *session, const cbm_daemon_application_watch_t *watch) { if (!session || !session->application) { return; } @@ -2575,7 +2717,9 @@ static void application_watch_job_unsubscribe_session_locked( &session->application->watch_job_subscriptions; while (*cursor) { cbm_daemon_application_watch_job_subscription_t *subscription = *cursor; - if (subscription->session != session) { + if (subscription->session != session || + (watch && (strcmp(subscription->job->project_key, watch->project) != 0 || + strcmp(subscription->job->root_path, watch->root) != 0))) { cursor = &subscription->next; continue; } @@ -2693,6 +2837,7 @@ static char *application_index_execute_sync(cbm_daemon_application_session_t *se } session->active_job = job; session->active_job_subscribed = true; + application_index_watch_subscribe_locked(session, job, false); cbm_mutex_unlock(&session->application->mutex); return application_job_wait_for_session(session, job); } @@ -2803,6 +2948,7 @@ static char *application_index_start_async(cbm_daemon_application_session_t *ses bool joined = false; const char *state = "queued"; if (job) { + application_index_watch_subscribe_locked(session, job, true); joined = job->async_owned || job->subscribers > 1; if (job->async_owned) { application_job_unsubscribe_locked(job); /* one async reference per job */ @@ -3414,6 +3560,7 @@ static void application_request_cancel(void *context, cbm_daemon_application_job_t *job = session->active_job; session->active_job = NULL; session->active_job_subscribed = false; + application_index_watch_unsubscribe_locked(session, job); application_index_record_cut_short_locked(job); application_job_unsubscribe_locked(job); } @@ -3455,6 +3602,7 @@ static void application_session_cancel(void *context, reap_update = reap_update || (session->background_eligible && application->update_thread_started && application->update_owners == 0); + application_index_watch_unsubscribe_locked(session, NULL); application_release_session_watch_locked(session); bool final_live_session = newly_cancelled; for (cbm_daemon_application_session_t *other = application->sessions; @@ -3514,6 +3662,7 @@ static void application_session_close(void *context, reap_update = reap_update || (session->background_eligible && application->update_thread_started && application->update_owners == 0); + application_index_watch_unsubscribe_locked(session, NULL); application_release_session_watch_locked(session); cbm_mutex_unlock(&application->mutex); application_auto_index_cancel_join(application, join_auto_index); @@ -3722,6 +3871,11 @@ bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *applicat application_index_records_free(index_records); while (sessions) { cbm_daemon_application_session_t *next = sessions->next; + while (sessions->watches) { + application_watch_membership_t *membership = sessions->watches; + sessions->watches = membership->next; + cbm_free(CBM_MEM_CLASS_OTHER, membership); + } cbm_mcp_server_free(sessions->mcp); free(sessions); sessions = next; @@ -3733,7 +3887,8 @@ bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *applicat } free(watches->project); free(watches->root); - free(watches); + cbm_free(CBM_MEM_CLASS_OTHER, watches->index_mode); + cbm_free(CBM_MEM_CLASS_OTHER, watches); watches = next; } while (watch_job_subscriptions) { @@ -4033,44 +4188,27 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_hook_augme timeout_ms); } -static int application_background_index(cbm_daemon_application_t *application, - const char *project_name, const char *root_path, - bool require_live_watch) { - if (!application || !project_name || !root_path) { - return -1; - } - char canonical_root[APPLICATION_PATH_CAP]; - if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) || - !application_canonical_directory_exists(canonical_root)) { - return -1; - } - yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; - if (!document || !root) { - yyjson_mut_doc_free(document); - return -1; - } - yyjson_mut_doc_set_root(document, root); - bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) && - application_index_args_add_policy(application, document, root); - char *default_project = cbm_project_name_from_path(canonical_root); - bool custom_project = - project_name[0] && (!default_project || strcmp(default_project, project_name) != 0); - if (encoded && custom_project) { - encoded = yyjson_mut_obj_add_strcpy(document, root, "name", project_name); - } - free(default_project); - char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; - yyjson_mut_doc_free(document); - if (!args) { - return -1; - } - char *project_key = application_index_project_key(canonical_root, args); - if (!project_key) { - free(args); - return -1; - } +enum { + APPLICATION_BACKGROUND_INDEX_FAILED = -1, + APPLICATION_BACKGROUND_INDEX_SUCCESS = 0, + APPLICATION_BACKGROUND_INDEX_SKIPPED = 1, +}; +/* Caller holds application->mutex through option encoding and job admission. */ +static void application_watch_encode_index_args_locked(const cbm_daemon_application_watch_t *watch, + yyjson_mut_doc *document, + yyjson_mut_val *root, char **args) { + bool encoded = + !watch->index_mode || yyjson_mut_obj_add_strcpy(document, root, "mode", watch->index_mode); + encoded = encoded && yyjson_mut_obj_add_bool(document, root, "persistence", watch->persistence); + free(*args); + *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; +} + +static cbm_daemon_application_job_t *application_background_index_admit( + cbm_daemon_application_t *application, const char *project_name, const char *canonical_root, + const char *project_key, yyjson_mut_doc *document, yyjson_mut_val *root, char **args, + bool require_live_watch, int *no_job_result) { application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; application_jobs_reap_completed(application); cbm_mutex_lock(&application->mutex); @@ -4078,12 +4216,16 @@ static int application_background_index(cbm_daemon_application_t *application, require_live_watch ? application_find_watch_locked(application, project_name) : NULL; bool watch_live = !require_live_watch || (watch && watch->subscribers > 0 && strcmp(watch->root, canonical_root) == 0); + if (require_live_watch && watch_live) { + application_watch_encode_index_args_locked(watch, document, root, args); + } size_t watch_owner_count = 0; bool watch_subscriptions_ok = true; cbm_daemon_application_job_t *job = - watch_live ? application_job_subscribe_locked(application, project_key, canonical_root, - args, &subscribe_status) - : NULL; + watch_live && *args + ? application_job_subscribe_locked(application, project_key, canonical_root, *args, + &subscribe_status) + : NULL; if (job && require_live_watch) { watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked( application, watch, job, &watch_owner_count); @@ -4101,22 +4243,27 @@ static int application_background_index(cbm_daemon_application_t *application, } } cbm_mutex_unlock(&application->mutex); - free(project_key); - free(args); if (!job) { if (require_live_watch && (!watch_live || (watch_subscriptions_ok && watch_owner_count == 0))) { /* The physical watcher can retain a poll snapshot after the last * owning session unwatches it. Treat that stale callback as a * harmless skip; no job was admitted. */ - return 1; + *no_job_result = APPLICATION_BACKGROUND_INDEX_SKIPPED; + return NULL; } - return subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT || - subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY || - subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING - ? 1 - : -1; + *no_job_result = subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING + ? APPLICATION_BACKGROUND_INDEX_SKIPPED + : APPLICATION_BACKGROUND_INDEX_FAILED; } + return job; +} + +static int application_background_index_wait(cbm_daemon_application_t *application, + cbm_daemon_application_job_t *job, + bool require_live_watch) { bool successful = false; bool cancelled = false; for (;;) { @@ -4139,7 +4286,60 @@ static int application_background_index(cbm_daemon_application_t *application, cbm_usleep(APPLICATION_JOB_POLL_US); } application_jobs_reap_completed(application); - return successful ? 0 : (cancelled ? 1 : -1); + if (successful) { + return APPLICATION_BACKGROUND_INDEX_SUCCESS; + } + return cancelled ? APPLICATION_BACKGROUND_INDEX_SKIPPED : APPLICATION_BACKGROUND_INDEX_FAILED; +} + +static int application_background_index(cbm_daemon_application_t *application, + const char *project_name, const char *root_path, + bool require_live_watch) { + if (!application || !project_name || !root_path) { + return APPLICATION_BACKGROUND_INDEX_FAILED; + } + char canonical_root[APPLICATION_PATH_CAP]; + if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) || + !application_canonical_directory_exists(canonical_root)) { + return APPLICATION_BACKGROUND_INDEX_FAILED; + } + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (!document || !root) { + yyjson_mut_doc_free(document); + return APPLICATION_BACKGROUND_INDEX_FAILED; + } + yyjson_mut_doc_set_root(document, root); + bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root) && + application_index_args_add_policy(application, document, root); + char *default_project = cbm_project_name_from_path(canonical_root); + bool custom_project = + project_name[0] && (!default_project || strcmp(default_project, project_name) != 0); + if (encoded && custom_project) { + encoded = yyjson_mut_obj_add_strcpy(document, root, "name", project_name); + } + free(default_project); + char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; + if (!args) { + yyjson_mut_doc_free(document); + return APPLICATION_BACKGROUND_INDEX_FAILED; + } + char *project_key = application_index_project_key(canonical_root, args); + if (!project_key) { + free(args); + yyjson_mut_doc_free(document); + return APPLICATION_BACKGROUND_INDEX_FAILED; + } + + int no_job_result = APPLICATION_BACKGROUND_INDEX_FAILED; + cbm_daemon_application_job_t *job = application_background_index_admit( + application, project_name, canonical_root, project_key, document, root, &args, + require_live_watch, &no_job_result); + free(project_key); + free(args); + yyjson_mut_doc_free(document); + return job ? application_background_index_wait(application, job, require_live_watch) + : no_job_result; } int cbm_daemon_application_index(cbm_daemon_application_t *application, const char *project_name, diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 5bd02e4e3..930638ccd 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1468,8 +1468,10 @@ static const char MCP_SERVER_INSTRUCTIONS[] = "query_graph runs multi-hop Cypher queries; get_architecture summarizes structure; " "detect_changes maps the current diff to affected symbols. Grep and file reads fit literal " "text, config values, comments and non-code files better; search_code combines text search " - "with graph context. The session project's index refreshes automatically; other projects " - "need index_repository. check_index_coverage tells whether a cited file was fully indexed. " + "with graph context. With auto_watch enabled, an index registered by an active MCP session " + "refreshes automatically; sessions register their root and successful explicit " + "index_repository targets. CLI calls do not add watches. check_index_coverage tells whether " + "a cited file was fully indexed. " "Results are paginated."; static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] = @@ -7379,8 +7381,8 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args } /* index_status "watch" object (#2167): is this project kept fresh by the - * background watcher right now, and if not, why. Auto-sync covers the active - * MCP session's project only; anything else needs index_repository again. */ + * background watcher right now, and if not, why. Active MCP sessions register + * their root on connect and successful explicit index_repository targets. */ static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yyjson_mut_val *root, const char *project) { cbm_mcp_watch_status_t status = {0}; @@ -7406,8 +7408,8 @@ static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yy } else { yyjson_mut_obj_add_str(doc, watch, "reason", status.reason ? status.reason : "unknown"); yyjson_mut_obj_add_str(doc, watch, "hint", - "Auto-sync watches only the active MCP session's project; " - "re-run index_repository to refresh this index."); + "To register this project for auto-sync, successfully " + "re-index it from an active MCP session with auto_watch enabled."); } yyjson_mut_obj_add_val(doc, root, "watch", watch); } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index a923043ca..2f38c31df 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1997,13 +1997,15 @@ typedef struct { app_fake_worker_context_t fake; cbm_store_t *store; cbm_watcher_t *watcher; + cbm_config_t *config; cbm_daemon_application_t *application; cbm_daemon_runtime_application_callbacks_t callbacks; cbm_daemon_runtime_application_session_t *session; } app_watch_race_fixture_t; -static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, - cbm_daemon_client_id_t client_id) { +static bool app_watch_race_fixture_init_with_auto_watch(app_watch_race_fixture_t *fixture, + cbm_daemon_client_id_t client_id, + bool auto_watch) { memset(fixture, 0, sizeof(*fixture)); app_fake_worker_context_init(&fixture->fake); const char *old_cache = getenv("CBM_CACHE_DIR"); @@ -2022,6 +2024,9 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, fixture->project); } bool db_ok = fixture->project && app_test_create_empty_file(fixture->db_path); + fixture->config = cbm_config_open(fixture->cache); + bool config_ok = fixture->config && cbm_config_set(fixture->config, CBM_CONFIG_AUTO_WATCH, + auto_watch ? "true" : "false") == 0; cbm_daemon_application_worker_ops_t worker_ops = { .context = &fixture->fake, .start = app_fake_worker_start, @@ -2034,6 +2039,7 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, fixture->watcher = cbm_watcher_new(fixture->store, app_test_index_noop, NULL); cbm_daemon_application_config_t config = { .watcher = fixture->watcher, + .config = fixture->config, .worker_ops = &worker_ops, }; fixture->application = cbm_daemon_application_new(&config); @@ -2049,8 +2055,8 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, uint8_t *response = NULL; uint32_t response_length = 0; bool encoded = - env_ok && dirs_ok && db_ok && fixture->store && fixture->watcher && fixture->application && - fixture->session && + env_ok && dirs_ok && db_ok && config_ok && fixture->store && fixture->watcher && + fixture->application && fixture->session && app_test_context_request(fixture->root, fixture->root, &context, &context_length) && app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, "{\"jsonrpc\":\"2.0\",\"id\":19,\"method\":\"ping\"}", &ping, @@ -2067,7 +2073,45 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, free(response); free(context); free(ping); - return initialized && cbm_watcher_watch_count(fixture->watcher) == 1; + return initialized && cbm_watcher_watch_count(fixture->watcher) == (auto_watch ? 1 : 0); +} + +static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, + cbm_daemon_client_id_t client_id) { + return app_watch_race_fixture_init_with_auto_watch(fixture, client_id, true); +} + +static bool app_test_initialize_profile(const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, + const char *root, cbm_mcp_tool_profile_t profile, + const char *hook_event, const char *hook_dialect); + +static bool app_test_initialize_existing_context( + const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session) { + uint8_t *initialize = NULL; + uint32_t initialize_length = 0; + bool encoded = session && app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":4100," + "\"method\":\"initialize\",\"params\":{}}", + &initialize, &initialize_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + encoded ? app_test_request(callbacks, session, initialize, initialize_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool initialized = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + strstr((char *)response, "\"result\""); + free(initialize); + free(response); + return initialized; +} + +static bool app_watch_race_fixture_init_mcp(app_watch_race_fixture_t *fixture, + cbm_daemon_client_id_t client_id, bool auto_watch) { + bool ready = app_watch_race_fixture_init_with_auto_watch(fixture, client_id, auto_watch); + return ready && app_test_initialize_existing_context(&fixture->callbacks, fixture->session); } static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) { @@ -2086,6 +2130,10 @@ static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) { cbm_store_close(fixture->store); fixture->store = NULL; } + if (stopped && fixture->config) { + cbm_config_close(fixture->config); + fixture->config = NULL; + } free(fixture->project); fixture->project = NULL; (void)th_rmtree(fixture->root); @@ -4587,6 +4635,452 @@ TEST(daemon_application_late_watcher_session_owns_active_watcher_job) { * Any ADR write accepted during that lifetime can therefore be silently lost * (and can make replacement fail on Windows). The daemon job must keep the * project reserved against mutations until the physical worker is terminal. */ +static bool app_test_mcp_index_request(const char *root, const char *name, const char *extra_args, + uint8_t **request_out, uint32_t *request_length_out) { + char message[APP_TEST_PATH_CAP + 256]; + (void)snprintf(message, sizeof(message), + "{\"jsonrpc\":\"2.0\",\"id\":9300,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\",\"arguments\":{" + "\"repo_path\":\"%s\",\"name\":\"%s\"%s}}}", + root, name, extra_args ? extra_args : ""); + return app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, message, request_out, + request_length_out); +} + +static bool app_test_explicit_index_with_options( + cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, const char *root, const char *name, + const char *extra_args) { + uint8_t *request = NULL; + uint32_t request_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = app_test_mcp_index_request(root, name, extra_args, &request, &request_length); + cbm_daemon_runtime_application_status_t status = + encoded ? app_test_request(callbacks, session, request, request_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + bool indexed = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + strstr((char *)response, "indexed") != NULL; + free(request); + free(response); + return indexed; +} + +static bool app_test_explicit_index(cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, + const char *root, const char *name) { + return app_test_explicit_index_with_options(callbacks, session, root, name, NULL); +} + +TEST(daemon_application_explicit_index_registers_target_watch_for_live_session) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9301, true); + char targets[2][APP_TEST_PATH_CAP]; + (void)snprintf(targets[0], sizeof(targets[0]), "%s/cbm-app-explicit-target-a-XXXXXX", + fixture.root); + (void)snprintf(targets[1], sizeof(targets[1]), "%s/cbm-app-explicit-target-b-XXXXXX", + fixture.root); + bool targets_ready = fixture_ready && cbm_mkdtemp(targets[0]) && cbm_mkdtemp(targets[1]); + char target_dbs[2][APP_TEST_PATH_CAP]; + (void)snprintf(target_dbs[0], sizeof(target_dbs[0]), "%s/custom-target.db", fixture.cache); + (void)snprintf(target_dbs[1], sizeof(target_dbs[1]), "%s/second-target.db", fixture.cache); + bool target_dbs_ready = targets_ready && app_test_create_empty_file(target_dbs[0]) && + app_test_create_empty_file(target_dbs[1]); + cbm_daemon_runtime_application_session_t *second = + target_dbs_ready ? app_test_open(&fixture.callbacks, 9302) : NULL; + bool second_ready = + second && app_test_initialize_profile(&fixture.callbacks, second, fixture.root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + atomic_store(&fixture.fake.allow_completion, true); + bool first_indexed = + second_ready && app_test_explicit_index_with_options( + &fixture.callbacks, fixture.session, targets[0], "custom-target", + ",\"mode\":\"fast\",\"persistence\":true"); + int after_first = cbm_watcher_watch_count(fixture.watcher); + bool second_indexed = + first_indexed && + app_test_explicit_index(&fixture.callbacks, fixture.session, targets[1], "second-target"); + int after_second = cbm_watcher_watch_count(fixture.watcher); + bool shared_indexed = + second_indexed && app_test_explicit_index_with_options( + &fixture.callbacks, second, targets[0], "custom-target", + ",\"mode\":\"fast\",\"persistence\":true"); + int after_shared = cbm_watcher_watch_count(fixture.watcher); + app_watcher_index_thread_t watcher_request = { + .application = fixture.application, + .project = "custom-target", + .root = targets[0], + .result = -1, + }; + atomic_init(&watcher_request.ready, false); + atomic_init(&watcher_request.proceed, true); + atomic_init(&watcher_request.done, false); + cbm_thread_t watcher_thread; + bool watcher_thread_started = + shared_indexed && + cbm_thread_create(&watcher_thread, 0, app_watcher_index_thread, &watcher_request) == 0; + bool watcher_worker_started = + watcher_thread_started && app_wait_for_atomic_int_at_least(&fixture.fake.starts, 4); + bool watcher_done = + watcher_worker_started && app_wait_for_atomic_bool(&watcher_request.done, true); + bool watcher_thread_joined = watcher_thread_started && cbm_thread_join(&watcher_thread) == 0; + bool saved_options = watcher_worker_started && + strstr(fixture.fake.args_json[3], "\"mode\":\"fast\"") && + strstr(fixture.fake.args_json[3], "\"persistence\":true"); + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int after_first_close = cbm_watcher_watch_count(fixture.watcher); + if (second) { + fixture.callbacks.session_close(fixture.callbacks.context, second); + second = NULL; + } + int after_last_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (targets_ready) { + (void)th_rmtree(targets[0]); + (void)th_rmtree(targets[1]); + } + if (targets_ready) { + (void)cbm_unlink(target_dbs[0]); + (void)cbm_unlink(target_dbs[1]); + } + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(targets_ready); + ASSERT_TRUE(target_dbs_ready); + ASSERT_TRUE(second_ready); + ASSERT_TRUE(first_indexed); + ASSERT_TRUE(second_indexed); + ASSERT_TRUE(shared_indexed); + ASSERT_TRUE(watcher_worker_started); + ASSERT_TRUE(watcher_done); + ASSERT_TRUE(watcher_thread_joined); + ASSERT_EQ(watcher_request.result, 0); + ASSERT_TRUE(saved_options); + ASSERT_TRUE(strstr(fixture.fake.args_json[0], "custom-target") != NULL); + ASSERT_EQ(after_first, 2); + ASSERT_EQ(after_second, 3); + ASSERT_EQ(after_shared, 3); + ASSERT_EQ(after_first_close, 2); + ASSERT_EQ(after_last_close, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_failed_explicit_index_does_not_register_target_watch) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9303, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-explicit-failed-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/failed-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + atomic_store(&fixture.fake.scripted, true); + for (size_t i = 0; i < APP_FAKE_MAX_ATTEMPTS; i++) { + fixture.fake.outcomes[i] = CBM_PROC_CRASH; + } + bool request_failed = db_ready && !app_test_explicit_index(&fixture.callbacks, fixture.session, + target, "failed-target"); + int watches_after_failure = cbm_watcher_watch_count(fixture.watcher); + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (target_ready) { + (void)th_rmtree(target); + (void)cbm_unlink(db_path); + } + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(request_failed); + ASSERT_EQ(watches_after_failure, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_auto_watch_false_suppresses_explicit_target_watch) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9304, false); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-explicit-no-watch-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/no-watch-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + atomic_store(&fixture.fake.allow_completion, true); + bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target, + "no-watch-target"); + int watches_after_index = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watches_after_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (target_ready) { + (void)th_rmtree(target); + (void)cbm_unlink(db_path); + } + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(indexed); + ASSERT_EQ(watches_after_index, 0); + ASSERT_EQ(watches_after_close, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_one_shot_cli_index_does_not_register_target_watch) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9305, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-one-shot-target-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/one-shot-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + char args[APP_TEST_PATH_CAP + 96]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"one-shot-target\"}", + target); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = db_ready && app_test_tool_request("index_repository", args, &tool, &tool_length); + atomic_store(&fixture.fake.allow_completion, true); + cbm_daemon_runtime_application_status_t status = + encoded ? app_test_request(&fixture.callbacks, fixture.session, tool, tool_length, + &response, &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + bool indexed = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + strstr((char *)response, "indexed") != NULL; + int watches_after_one_shot = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watches_after_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (target_ready) { + (void)th_rmtree(target); + (void)cbm_unlink(db_path); + } + free(tool); + free(response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(encoded); + ASSERT_TRUE(indexed); + ASSERT_EQ(watches_after_one_shot, 1); + ASSERT_EQ(watches_after_close, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_async_index_interest_survives_cancelled_sync_join) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9306, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-async-interest-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/async-interest.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + uint8_t *async_request = NULL; + uint32_t async_request_length = 0; + bool async_encoded = + db_ready && app_test_mcp_index_request(target, "async-interest", ",\"async\":true", + &async_request, &async_request_length); + uint8_t *async_response = NULL; + uint32_t async_response_length = 0; + cbm_daemon_runtime_application_status_t async_status = + async_encoded + ? app_test_request(&fixture.callbacks, fixture.session, async_request, + async_request_length, &async_response, &async_response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + bool worker_started = async_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && async_response && + strstr((char *)async_response, "\"async\":true") != NULL && + app_wait_for_atomic_int(&fixture.fake.starts, 1); + + uint8_t *sync_request = NULL; + uint32_t sync_request_length = 0; + bool sync_encoded = + worker_started && app_test_mcp_index_request(target, "async-interest", NULL, &sync_request, + &sync_request_length); + app_request_thread_t sync_call = { + .callbacks = fixture.callbacks, + .session = fixture.session, + .request_token = UINT64_C(9307), + .request = sync_request, + .request_length = sync_request_length, + .status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, + }; + atomic_init(&sync_call.done, false); + cbm_thread_t sync_thread; + bool sync_thread_started = + sync_encoded && cbm_thread_create(&sync_thread, 0, app_request_thread, &sync_call) == 0; + bool sync_joined_job = + sync_thread_started && app_wait_for_subscribers(fixture.application, "async-interest", 2); + if (sync_joined_job) { + fixture.callbacks.request_cancel(fixture.callbacks.context, fixture.session, + sync_call.request_token); + } + bool sync_cancelled = sync_joined_job && app_wait_for_atomic_bool(&sync_call.done, true); + atomic_store(&fixture.fake.allow_completion, true); + bool sync_thread_joined = sync_thread_started && cbm_thread_join(&sync_thread) == 0; + bool drained = worker_started && app_wait_for_active_jobs(fixture.application, 0); + int watches_after_success = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watches_after_close = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (target_ready) { + (void)th_rmtree(target); + (void)cbm_unlink(db_path); + } + free(async_request); + free(async_response); + free(sync_request); + free(sync_call.response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(async_encoded); + ASSERT_TRUE(worker_started); + ASSERT_TRUE(sync_encoded); + ASSERT_TRUE(sync_thread_started); + ASSERT_TRUE(sync_joined_job); + ASSERT_TRUE(sync_cancelled); + ASSERT_EQ(sync_call.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_TRUE(sync_thread_joined); + ASSERT_TRUE(drained); + ASSERT_EQ(watches_after_success, 2); + ASSERT_EQ(watches_after_close, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_auto_watch_false_removes_existing_secondary_watches) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9308, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-toggle-watch-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/toggle-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + atomic_store(&fixture.fake.allow_completion, true); + bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target, + "toggle-target"); + int watches_before_toggle = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool disabled = + fixture.config && cbm_config_set(fixture.config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool ping_encoded = app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, "{\"jsonrpc\":\"2.0\",\"id\":9309,\"method\":\"ping\"}", + &ping, &ping_length); + cbm_daemon_runtime_application_status_t ping_status = + disabled && ping_encoded ? app_test_request(&fixture.callbacks, fixture.session, ping, + ping_length, &response, &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + int watches_after_ping = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (target_ready) { + (void)th_rmtree(target); + (void)cbm_unlink(db_path); + } + free(ping); + free(response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(indexed); + ASSERT_EQ(watches_before_toggle, 2); + ASSERT_TRUE(disabled); + ASSERT_EQ(ping_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(watches_after_ping, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_async_index_drops_watch_interest_on_requester_disconnect) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9310, true); + cbm_daemon_runtime_application_session_t *observer = + fixture_ready ? app_test_open(&fixture.callbacks, 9311) : NULL; + bool observer_ready = + observer && app_test_initialize_profile(&fixture.callbacks, observer, fixture.root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-disconnect-async-XXXXXX", fixture.root); + bool target_ready = observer_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/disconnect-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + uint8_t *request = NULL; + uint32_t request_length = 0; + bool encoded = + db_ready && app_test_mcp_index_request(target, "disconnect-target", ",\"async\":true", + &request, &request_length); + uint8_t *async_response = NULL; + uint32_t async_response_length = 0; + cbm_daemon_runtime_application_status_t async_status = + encoded ? app_test_request(&fixture.callbacks, fixture.session, request, request_length, + &async_response, &async_response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + bool worker_started = async_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && async_response && + app_wait_for_atomic_int(&fixture.fake.starts, 1); + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + int watches_after_disconnect = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + atomic_store(&fixture.fake.allow_completion, true); + bool drained = worker_started && app_wait_for_active_jobs(fixture.application, 0); + int watches_after_completion = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + if (observer) { + fixture.callbacks.session_close(fixture.callbacks.context, observer); + observer = NULL; + } + bool cleaned = app_watch_race_fixture_finish(&fixture); + if (target_ready) { + (void)th_rmtree(target); + (void)cbm_unlink(db_path); + } + free(request); + free(async_response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(observer_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(encoded); + ASSERT_TRUE(worker_started); + ASSERT_EQ(watches_after_disconnect, 1); + ASSERT_TRUE(drained); + ASSERT_EQ(watches_after_completion, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(daemon_application_serializes_adr_mutation_with_index_job) { const char *old_cache = getenv("CBM_CACHE_DIR"); bool had_cache = old_cache != NULL; @@ -6973,6 +7467,13 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job); + RUN_TEST(daemon_application_explicit_index_registers_target_watch_for_live_session); + RUN_TEST(daemon_application_failed_explicit_index_does_not_register_target_watch); + RUN_TEST(daemon_application_auto_watch_false_suppresses_explicit_target_watch); + RUN_TEST(daemon_application_one_shot_cli_index_does_not_register_target_watch); + RUN_TEST(daemon_application_async_index_interest_survives_cancelled_sync_join); + RUN_TEST(daemon_application_auto_watch_false_removes_existing_secondary_watches); + RUN_TEST(daemon_application_async_index_drops_watch_interest_on_requester_disconnect); RUN_TEST(daemon_application_serializes_adr_mutation_with_index_job); RUN_TEST(daemon_application_reserved_mutation_delays_worker_start); RUN_TEST(daemon_application_mutation_does_not_block_other_project); From 0799f74afaae8846bdab7f77fa3609918a066ba0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 20:03:07 +0200 Subject: [PATCH 50/76] fix(ui): hide stale focus warning while arranging graph Signed-off-by: Martin Vogel --- .../galaxy/GalaxyPanel.reader-focus.test.tsx | 39 ++++++++++++++++--- graph-ui/src/galaxy/GalaxyPanel.tsx | 2 +- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx index 966bcc84e..44e3904a5 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.reader-focus.test.tsx @@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import GalaxyPanel, { type GalaxyPanelProps } from './GalaxyPanel'; import type { GraphData, GraphNode } from './types'; +import type { OrganicLayoutRequest, OrganicLayoutResponse } from './organic-layout.worker'; /** The layout can be capped; focused relationships come independently from RPC. */ function graphFetch(nodes: GraphNode[], edges: GraphData['edges'], total = nodes.length) { @@ -102,10 +103,24 @@ it('opens a file hierarchy without a caret symbol and follows literal marked ran expect(fetchLayout.mock.calls.filter(([url]) => String(url).includes('/api/layout'))).toHaveLength(1); }); -it('does not report a file missing from a capped layout while its indexed scope is loading', async () => { +it.each([true, false])('does not report a file missing while its scope or layout is loading (indexed: %s)', async indexed => { + let worker!: LayoutWorker; + class LayoutWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + requests: OrganicLayoutRequest[] = []; + constructor() { worker = this; } + postMessage(request: OrganicLayoutRequest) { this.requests.push(request); } + terminate() {} + reply() { + const { sequence, data } = this.requests.at(-1)!; + this.onmessage?.(new MessageEvent('message', { data: { sequence, + result: { data, groups: [], groupByNode: new Map() } } })); + } + } + vi.stubGlobal('Worker', LayoutWorker); const node: GraphNode = { id: 15, name: 'outside', qualified_name: 'sample.outside', label: 'Function', file_path: 'src/a.ts', start_line: 1, end_line: 10, x: 0, y: 0, z: 0, size: 1, color: '#999999' }; - const source = graphFetch([node], []); + const source = graphFetch(indexed ? [node] : [], []); let release!: () => void; const pending = new Promise(resolve => { release = resolve; }); const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { @@ -114,15 +129,27 @@ it('does not report a file missing from a capped layout while its indexed scope return source(url, init); }); await act(async () => root.render()); - // Hand test K8: the status names the layer that is loading instead of an endless "Loading relationships". - // It may first say "Checking index…" while the index generation is read; wait for the load itself. - await vi.waitFor(() => expect(host.textContent).toContain('Loading layer 1…')); + expect(host.textContent).toContain('Loading layer 1…'); + // Finish the empty capped-layout preview while the indexed scope is held. + // This leaves a missing-file note in state, hidden until the scope finishes. + expect(worker.requests).toHaveLength(1); + expect(worker.requests[0]!.data.nodes).toEqual([]); + await act(async () => worker.reply()); expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull(); await act(async () => { release(); }); expect(host.textContent).toContain('All indexed direct dependencies included.'); + // The scope is complete, but its new picture has not been arranged yet. + expect(worker.requests).toHaveLength(2); + expect(host.querySelector('[role="status"][data-state="arranging"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull(); expect(host.textContent).not.toContain('not in the loaded graph'); + await act(async () => worker.reply()); + expect(host.querySelector('[role="status"][data-state="complete"]')).not.toBeNull(); + expect(JSON.parse(host.querySelector('[data-testid="graph-selection"]')!.textContent!)).toEqual(indexed ? [15] : []); + if (indexed) expect(host.querySelector('[data-testid="atlas-galaxy-note"]')).toBeNull(); + else expect(host.querySelector('[data-testid="atlas-galaxy-note"]')?.textContent).toBe('src/a.ts is not in the loaded graph layout.'); }); -afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; }); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; vi.unstubAllGlobals(); }); it('selects the whole file, narrows only to marked code, and restores the file when the mark clears', async () => { const common = { file_path: 'src/a.ts', x: 0, y: 0, z: 0, size: 1, color: '#999999' }; diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index a7a338973..c1618464b 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -3055,7 +3055,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { Selection details
{selectionContent}
} - {note.length > 0 && !scope.loading && ( + {note.length > 0 && !scope.loading && !organicTask.loading && (

{props.workspaceExpanded && note === GALAXY_NO_FOCUS_NOTE ? 'Select a node or entry point to attach graph context to chat.' : note} From 7890796662760e89b7778c51902d10c1d1ab3c24 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 20:04:54 +0200 Subject: [PATCH 51/76] fix(ci): normalize Windows paths in CodeQL retracing Signed-off-by: Martin Vogel --- scripts/ci/codeql-retrace-vendored.sh | 2 ++ tests/test_select_lanes.sh | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/scripts/ci/codeql-retrace-vendored.sh b/scripts/ci/codeql-retrace-vendored.sh index 51e33402e..eaf4e3353 100755 --- a/scripts/ci/codeql-retrace-vendored.sh +++ b/scripts/ci/codeql-retrace-vendored.sh @@ -80,6 +80,8 @@ fi printf '%s\n' "$changed" | bash "$ROOT/scripts/ci/select-lanes.sh" --codeql-scope c-cpp | while IFS= read -r path; do + # Native Windows Python writes CRLF; read removes only the LF. + path="${path%$'\r'}" entrypoints=() case "$path" in internal/cbm/grammar_*.c | internal/cbm/ts_runtime.c) diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index 175ff88d8..4c8183fdc 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -275,6 +275,31 @@ if proc.returncode != 0 or got != ["internal/cbm/grammar_go.c", "vendored/sqlite3/sqlite3.c"]: failures.append(f"codeql (c): the retrace helper must list the translation units for " f"changed vendored code and wrappers, got {got} (exit {proc.returncode})") +# Native Windows Python emits CRLF even when the change list uses LF. Pin +# that producer/consumer boundary on every platform, including direct wrapper +# paths that must survive verbatim rather than being mapped to a family name. +crlf_root = work / "crlf-selector" +crlf_scripts = crlf_root / "scripts" / "ci" +crlf_scripts.mkdir(parents=True) +(crlf_scripts / retrace.name).write_bytes(retrace.read_bytes()) +expected_retrace = ["internal/cbm/grammar_go.c", "internal/cbm/grammar_json.c", + "internal/cbm/ts_runtime.c", "vendored/sqlite3/sqlite3.c"] +for path in expected_retrace: + fixture = crlf_root / path + fixture.parent.mkdir(parents=True, exist_ok=True) + fixture.touch() +(crlf_scripts / "select-lanes.sh").write_bytes( + b"#!/usr/bin/env bash\nprintf '%s\\r\\n' " + b"internal/cbm/vendored/grammars/go/parser.c internal/cbm/grammar_json.c " + b"internal/cbm/ts_runtime.c vendored/sqlite3/sqlite3.c\n") +proc = subprocess.run([bash, str(crlf_scripts / retrace.name), + "--changed", str(changed), str(listed)], + capture_output=True, text=True, env=env) +got = listed.read_bytes() if listed.exists() else None +if proc.returncode != 0 or got != ("\n".join(expected_retrace) + "\n").encode("utf-8"): + failures.append(f"codeql CRLF: the retrace helper must preserve every changed " + f"wrapper and write LF entrypoints, got {got!r} (exit {proc.returncode})") + for label, args in (("an unreadable change list", ["--changed", str(work / "missing.txt")]), ("no pull request", [])): listed.write_text("stale\n", encoding="utf-8") From 5aa31bee626c36e3b2def9435994d41b91e88615 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 20:50:57 +0200 Subject: [PATCH 52/76] ci: parallelize cppcheck with isolated whole-program state Signed-off-by: Martin Vogel --- Makefile.cbm | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index 3b93146f1..443946c7f 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1386,6 +1386,7 @@ LLVM_BIN := $(shell brew --prefix llvm 2>/dev/null)/bin CLANG_TIDY ?= $(shell [ -x "$(LLVM_BIN)/clang-tidy" ] && echo "$(LLVM_BIN)/clang-tidy" || echo clang-tidy) CLANG_FORMAT ?= $(shell [ -x "$(LLVM_BIN)/clang-format" ] && echo "$(LLVM_BIN)/clang-format" || echo clang-format) CPPCHECK ?= cppcheck +CPPCHECK_JOBS ?= 2 # macOS SDK sysroot (needed for Homebrew LLVM to find system headers) SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null) @@ -1410,10 +1411,16 @@ lint-tidy: lint-tidy-diff: @scripts/lint-tidy-diff.sh "$(CLANG_TIDY)" $(LINT_SRCS) -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) -# cppcheck: complementary analysis (config in .cppcheck) +# cppcheck: complementary analysis (config in .cppcheck). A build directory +# retains whole-program analysis with parallel workers; each run owns its state. lint-cppcheck: @echo "=== cppcheck ===" - @$(CPPCHECK) --enable=warning,style,performance,portability \ + @set -e; \ + cppcheck_dir=$$(mktemp -d "$${TMPDIR:-/tmp}/cbm-cppcheck.XXXXXX"); \ + trap 'status=$$?; rm -rf "$$cppcheck_dir" || :; exit "$$status"' EXIT; \ + trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM; \ + $(CPPCHECK) -j$(CPPCHECK_JOBS) --cppcheck-build-dir="$$cppcheck_dir" \ + --enable=warning,style,performance,portability \ --std=c11 --language=c \ --suppressions-list=.cppcheck \ --error-exitcode=1 \ From bd21af33f3f6f7f981c9aab28d03326372c8c284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Ol=C3=A1h?= Date: Sat, 17 Oct 2026 15:04:29 +0200 Subject: [PATCH 53/76] fix: prefer toolchain-provided linker to embed web-ui assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Oláh --- scripts/embed-frontend.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/embed-frontend.sh b/scripts/embed-frontend.sh index 73a693191..65928007d 100755 --- a/scripts/embed-frontend.sh +++ b/scripts/embed-frontend.sh @@ -72,7 +72,7 @@ for file in "${FILES[@]}"; do if $IS_LINUX; then # Linux: ld -r -b binary (zero bloat, ELF only) abs_obj="$(cd "$(dirname "$0")/.." && pwd)/$obj" - (cd "$DIST_DIR" && ld -r -b binary -o "$abs_obj" "$rel") + (cd "$DIST_DIR" && "${LD:-ld}" -r -b binary -o "$abs_obj" "$rel") else # macOS/Windows/MSYS2: generate C byte array + cc (no xxd dependency) local_c="$OUTPUT_DIR/embed_${mangled}.c" From b260a4bf70ab2d0b9890f3e93b01119c5fab7ee5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 22:57:14 +0200 Subject: [PATCH 54/76] fix: preserve watcher roots and refresh options Match canonical Windows separators across watch ownership and admission, drain all session watches when auto_watch is disabled, and retain the resolved doc_candidates choice for later refreshes. Add deterministic regressions for the five observed failures and an absent-option guard. Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 6 + src/daemon/application.c | 85 +++++++++++--- src/daemon/application_internal.h | 5 + tests/test_daemon_application.c | 182 ++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 16 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5573f4152..0981f6fc2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -105,6 +105,12 @@ has not run yet). > narrower: it globally controls MCP session watch registration. On the next > refresh or registration request, `false` removes all watches; `true` allows > session roots and successful explicit `index_repository` targets to register. +> Watcher refreshes retain `mode`, `persistence`, and the resolved +> `doc_candidates` boolean from the latest successful explicit index registered +> for that target. This boolean can come from the request or its global setting. +> When neither supplied a boolean, refresh uses the current setting or mode default. +> A watcher callback also observes `auto_watch=false`: it removes all watches +> before admitting any new refresh work. > When `watcher_enabled=false`, > `auto_watch` has no effect — there is no watcher to register with. > diff --git a/src/daemon/application.c b/src/daemon/application.c index d45fcabab..c431fe88f 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -104,6 +104,8 @@ struct cbm_daemon_application_watch { char *root; char *index_mode; bool persistence; + bool has_doc_candidates; + bool doc_candidates; size_t subscribers; cbm_daemon_application_watch_t *next; }; @@ -472,6 +474,35 @@ static bool application_canonical_directory_exists(const char *path) { return cbm_path_info_utf8(path, &info) == 0 && info.is_directory; } +/* Compare canonical roots without changing the native spelling used by workspace policy. */ +static bool application_watch_roots_equal(const char *left, const char *right, bool windows_paths) { + while (*left && *right) { + char left_char = *left++; + char right_char = *right++; + if (windows_paths) { + left_char = left_char == '\\' ? '/' : left_char; + right_char = right_char == '\\' ? '/' : right_char; + } + if (left_char != right_char) { + return false; + } + } + return *left == *right; +} + +static bool application_watch_root_matches(const char *left, const char *right) { +#ifdef _WIN32 + return application_watch_roots_equal(left, right, true); +#else + return application_watch_roots_equal(left, right, false); +#endif +} + +bool cbm_daemon_application_watch_roots_equal_for_test(const char *left, const char *right, + bool windows_paths) { + return application_watch_roots_equal(left, right, windows_paths); +} + static cbm_daemon_application_watch_t *application_find_watch_locked( cbm_daemon_application_t *application, const char *project) { for (cbm_daemon_application_watch_t *watch = application->watches; watch; watch = watch->next) { @@ -520,6 +551,19 @@ static void application_remove_watch_locked(cbm_daemon_application_t *applicatio application_remove_watch_entry_locked(application, watch, true); } +/* Registration and refresh can each be the first observer of the global + * switch. Drain memberships and physical watches together under the mutex. */ +static bool application_watches_disabled_locked(cbm_daemon_application_t *application) { + if (!application->config || + cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) { + return false; + } + while (application->watches) { + application_remove_watch_locked(application, application->watches); + } + return true; +} + static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) { application_watch_job_unsubscribe_session_locked(session, NULL); while (session->watches) { @@ -581,6 +625,9 @@ static bool application_watch_set_options(cbm_daemon_application_watch_t *watch, cbm_free(CBM_MEM_CLASS_OTHER, watch->index_mode); watch->index_mode = mode_copy; watch->persistence = yyjson_get_bool(yyjson_obj_get(args, "persistence")); + yyjson_val *doc_candidates = yyjson_obj_get(args, "doc_candidates"); + watch->has_doc_candidates = yyjson_is_bool(doc_candidates); + watch->doc_candidates = yyjson_get_bool(doc_candidates); yyjson_doc_free(doc); return true; } @@ -604,19 +651,18 @@ static void application_register_watch_locked(cbm_daemon_application_session_t * const char *project, const char *root, const char *args_json) { cbm_daemon_application_t *application = session->application; - if (!application_watch_registration_allowed(session, project, root)) { + if (application_watches_disabled_locked(application) || + !application_watch_registration_allowed(session, project, root)) { return; } cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project); - if ((application->config && - !cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) || - !application_regular_db_exists(project)) { + if (!application_regular_db_exists(project)) { if (watch) { application_remove_watch_locked(application, watch); } return; } - if (watch && strcmp(watch->root, root) != 0) { + if (watch && !application_watch_root_matches(watch->root, root)) { cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root", watch->root, "requested_root", root); return; @@ -668,11 +714,7 @@ static void application_register_watch_locked(cbm_daemon_application_session_t * static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) { cbm_daemon_application_t *application = session->application; - if (application->config && - !cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) { - while (application->watches) { - application_remove_watch_locked(application, application->watches); - } + if (application_watches_disabled_locked(application)) { return; } if (session->auto_index_subscribed || application_request_cancelled_locked(session)) { @@ -2641,7 +2683,8 @@ static bool application_watch_job_subscribe_late_session_locked( cbm_daemon_application_t *application = session->application; cbm_daemon_application_job_t *job = application_find_active_job_locked(application, watch->project); - if (!job || job->watcher_waiters == 0 || strcmp(job->root_path, watch->root) != 0 || + if (!job || job->watcher_waiters == 0 || + !application_watch_root_matches(job->root_path, watch->root) || application_watch_job_subscription_exists_locked(application, session, job)) { return true; } @@ -2667,7 +2710,7 @@ static bool application_watch_job_subscribe_sessions_locked(cbm_daemon_applicati size_t *matched_out) { *matched_out = 0; if (!watch || !job || strcmp(watch->project, job->project_key) != 0 || - strcmp(watch->root, job->root_path) != 0) { + !application_watch_root_matches(watch->root, job->root_path)) { return true; } @@ -2718,8 +2761,9 @@ static void application_watch_job_unsubscribe_session_locked( while (*cursor) { cbm_daemon_application_watch_job_subscription_t *subscription = *cursor; if (subscription->session != session || - (watch && (strcmp(subscription->job->project_key, watch->project) != 0 || - strcmp(subscription->job->root_path, watch->root) != 0))) { + (watch && + (strcmp(subscription->job->project_key, watch->project) != 0 || + !application_watch_root_matches(subscription->job->root_path, watch->root)))) { cursor = &subscription->next; continue; } @@ -4201,6 +4245,11 @@ static void application_watch_encode_index_args_locked(const cbm_daemon_applicat bool encoded = !watch->index_mode || yyjson_mut_obj_add_strcpy(document, root, "mode", watch->index_mode); encoded = encoded && yyjson_mut_obj_add_bool(document, root, "persistence", watch->persistence); + if (watch->has_doc_candidates) { + (void)yyjson_mut_obj_remove_key(root, "doc_candidates"); + encoded = encoded && + yyjson_mut_obj_add_bool(document, root, "doc_candidates", watch->doc_candidates); + } free(*args); *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; } @@ -4212,10 +4261,14 @@ static cbm_daemon_application_job_t *application_background_index_admit( application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; application_jobs_reap_completed(application); cbm_mutex_lock(&application->mutex); + if (require_live_watch) { + (void)application_watches_disabled_locked(application); + } cbm_daemon_application_watch_t *watch = require_live_watch ? application_find_watch_locked(application, project_name) : NULL; - bool watch_live = !require_live_watch || - (watch && watch->subscribers > 0 && strcmp(watch->root, canonical_root) == 0); + bool watch_live = + !require_live_watch || (watch && watch->subscribers > 0 && + application_watch_root_matches(watch->root, canonical_root)); if (require_live_watch && watch_live) { application_watch_encode_index_args_locked(watch, document, root, args); } diff --git a/src/daemon/application_internal.h b/src/daemon/application_internal.h index bd06e4efe..39b483898 100644 --- a/src/daemon/application_internal.h +++ b/src/daemon/application_internal.h @@ -51,4 +51,9 @@ char *cbm_daemon_application_framable_response_for_test(char *response, * on Windows, while the fold itself runs on every platform. */ bool cbm_daemon_application_index_args_equal_for_test(const char *left, const char *right); +/* Exercise the production root comparison's Windows branch on any host; + * POSIX backslashes remain literal filename characters. */ +bool cbm_daemon_application_watch_roots_equal_for_test(const char *left, const char *right, + bool windows_paths); + #endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */ diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 2f38c31df..343750fbf 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -4673,6 +4673,182 @@ static bool app_test_explicit_index(cbm_daemon_runtime_application_callbacks_t * return app_test_explicit_index_with_options(callbacks, session, root, name, NULL); } +TEST(daemon_application_watch_roots_match_windows_separators_only) { + ASSERT_TRUE( + cbm_daemon_application_watch_roots_equal_for_test("C:\\repos\\cbm", "C:/repos/cbm", true)); + ASSERT_TRUE(cbm_daemon_application_watch_roots_equal_for_test("\\\\server\\share\\cbm", + "//server/share/cbm", true)); + ASSERT_FALSE( + cbm_daemon_application_watch_roots_equal_for_test("C:\\repos\\cbm", "C:/repos/cbm2", true)); + ASSERT_FALSE(cbm_daemon_application_watch_roots_equal_for_test("C:\\repos\\cbm", + "C:/repos/cbm/sub", true)); + ASSERT_FALSE( + cbm_daemon_application_watch_roots_equal_for_test("/repos/a\\b", "/repos/a/b", false)); + ASSERT_TRUE( + cbm_daemon_application_watch_roots_equal_for_test("/repos/a\\b", "/repos/a\\b", false)); + PASS(); +} + +/* A held async worker completes after the global switch changes. No subsequent + * request may hide target-only cleanup by refreshing the session root. */ +TEST(daemon_application_async_completion_disables_all_watches) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9320, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-disable-async-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/disable-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + atomic_store(&fixture.fake.allow_completion, true); + bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target, + "disable-target"); + int watches_before = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + atomic_store(&fixture.fake.allow_completion, false); + uint8_t *request = NULL; + uint32_t request_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = + indexed && app_test_mcp_index_request(target, "disable-target", ",\"async\":true", &request, + &request_length); + cbm_daemon_runtime_application_status_t status = + encoded ? app_test_request(&fixture.callbacks, fixture.session, request, request_length, + &response, &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + bool started = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + strstr((char *)response, "\"async\":true") && + app_wait_for_atomic_int(&fixture.fake.starts, 2); + bool disabled = started && cbm_config_set(fixture.config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + atomic_store(&fixture.fake.allow_completion, true); + bool drained = started && app_wait_for_active_jobs(fixture.application, 0); + int watches_after = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool cleaned = app_watch_race_fixture_finish(&fixture); + free(request); + free(response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(indexed); + ASSERT_EQ(watches_before, 2); + ASSERT_TRUE(started); + ASSERT_TRUE(disabled); + ASSERT_TRUE(drained); + ASSERT_EQ(watches_after, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +/* The callback can be the first observer of a config change, without a + * client request. It must drain every target before admitting refresh work. */ +TEST(daemon_application_watcher_callback_disables_all_watches) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9322, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-disable-callback-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/callback-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + atomic_store(&fixture.fake.allow_completion, true); + bool indexed = db_ready && app_test_explicit_index(&fixture.callbacks, fixture.session, target, + "callback-target"); + int watches_before = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool disabled = indexed && cbm_config_set(fixture.config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + int refreshed = disabled ? cbm_daemon_application_watcher_index("callback-target", target, + fixture.application) + : -1; + int watches_after = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + int starts = atomic_load(&fixture.fake.starts); + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(indexed); + ASSERT_EQ(watches_before, 2); + ASSERT_TRUE(disabled); + ASSERT_EQ(refreshed, 1); + ASSERT_EQ(watches_after, 0); + ASSERT_EQ(starts, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + +static int app_watch_preserves_doc_candidates(int explicit_value) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9321, true); + char target[APP_TEST_PATH_CAP]; + (void)snprintf(target, sizeof(target), "%s/cbm-app-watch-options-XXXXXX", fixture.root); + bool target_ready = fixture_ready && cbm_mkdtemp(target) != NULL; + char db_path[APP_TEST_PATH_CAP]; + (void)snprintf(db_path, sizeof(db_path), "%s/options-target.db", fixture.cache); + bool db_ready = target_ready && app_test_create_empty_file(db_path); + /* The opposite global setting must be replaced, not duplicated in JSON. */ + bool configured = db_ready && cbm_config_set(fixture.config, CBM_CONFIG_DOC_CANDIDATES, + explicit_value < 0 ? "auto" + : explicit_value ? "false" + : "true") == 0; + atomic_store(&fixture.fake.allow_completion, true); + bool indexed = + configured && + app_test_explicit_index_with_options( + &fixture.callbacks, fixture.session, target, "options-target", + explicit_value < 0 ? ",\"mode\":\"fast\",\"persistence\":true" + : explicit_value ? ",\"mode\":\"fast\",\"persistence\":true,\"doc_candidates\":true" + : ",\"mode\":\"fast\",\"persistence\":true,\"doc_candidates\":false"); + bool default_updated = + explicit_value >= 0 || + (indexed && cbm_config_set(fixture.config, CBM_CONFIG_DOC_CANDIDATES, "true") == 0); + int refreshed = + indexed && default_updated + ? cbm_daemon_application_watcher_index("options-target", target, fixture.application) + : -1; + const char *args = fixture.fake.args_json[1]; + yyjson_doc *doc = refreshed == 0 ? yyjson_read(args, strlen(args), 0) : NULL; + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *choice = yyjson_obj_get(root, "doc_candidates"); + int forwarded = !choice ? -1 : !yyjson_is_bool(choice) ? -2 : yyjson_get_bool(choice) ? 1 : 0; + bool mode_preserved = yyjson_equals_str(yyjson_obj_get(root, "mode"), "fast"); + bool persistence_preserved = yyjson_get_bool(yyjson_obj_get(root, "persistence")); + size_t choices = 0; + size_t idx, max; + yyjson_val *key, *value; + yyjson_obj_foreach(root, idx, max, key, value) { + if (yyjson_equals_str(key, "doc_candidates")) { + choices++; + } + } + yyjson_doc_free(doc); + bool cleaned = app_watch_race_fixture_finish(&fixture); + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(target_ready); + ASSERT_TRUE(db_ready); + ASSERT_TRUE(configured); + ASSERT_TRUE(indexed); + ASSERT_TRUE(default_updated); + ASSERT_EQ(refreshed, 0); + ASSERT_TRUE(cleaned); + ASSERT_TRUE(mode_preserved); + ASSERT_TRUE(persistence_preserved); + ASSERT_EQ(choices, 1); + ASSERT_EQ(forwarded, explicit_value != 0); + PASS(); +} + +TEST(daemon_application_watch_refresh_uses_current_doc_candidates_default) { + return app_watch_preserves_doc_candidates(-1); +} + +TEST(daemon_application_watch_refresh_preserves_doc_candidates_true) { + return app_watch_preserves_doc_candidates(true); +} + +TEST(daemon_application_watch_refresh_preserves_doc_candidates_false) { + return app_watch_preserves_doc_candidates(false); +} + TEST(daemon_application_explicit_index_registers_target_watch_for_live_session) { app_watch_race_fixture_t fixture; bool fixture_ready = app_watch_race_fixture_init_mcp(&fixture, 9301, true); @@ -7467,6 +7643,12 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job); + RUN_TEST(daemon_application_watch_roots_match_windows_separators_only); + RUN_TEST(daemon_application_async_completion_disables_all_watches); + RUN_TEST(daemon_application_watcher_callback_disables_all_watches); + RUN_TEST(daemon_application_watch_refresh_uses_current_doc_candidates_default); + RUN_TEST(daemon_application_watch_refresh_preserves_doc_candidates_true); + RUN_TEST(daemon_application_watch_refresh_preserves_doc_candidates_false); RUN_TEST(daemon_application_explicit_index_registers_target_watch_for_live_session); RUN_TEST(daemon_application_failed_explicit_index_does_not_register_target_watch); RUN_TEST(daemon_application_auto_watch_false_suppresses_explicit_target_watch); From 8c4747666c9369699346216c390f1e3fe745b29d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 23:12:06 +0200 Subject: [PATCH 55/76] fix: preserve literal input and static asset boundaries Escape project names before building hand-test patterns, compare transport identifiers as literal tokens, preserve boundaries when extracting prose and XML facts, and require canonical containment for smoke static assets. Add regression coverage for metacharacters, dollar identifiers, markup joining, traversal, symlinks, and malformed URL encoding. Signed-off-by: Martin Vogel --- .../architecture/container-topology.test.ts | 6 ++ .../src/architecture/container-topology.ts | 7 +- .../src/architecture/repository-guide.test.ts | 7 ++ graph-ui/src/architecture/repository-guide.ts | 2 +- graph-ui/src/browser-ai/file-facts.test.ts | 7 ++ graph-ui/src/browser-ai/file-facts.ts | 8 ++- .../tools/codeql-input-boundaries.test.mjs | 64 +++++++++++++++++++ graph-ui/tools/handtest-fixes-chat.mjs | 5 +- graph-ui/tools/handtest-fixes-k27.mjs | 3 +- graph-ui/tools/literal-pattern.mjs | 5 ++ graph-ui/tools/smoke-spike.mjs | 18 ++---- graph-ui/tools/smoke-static-assets.mjs | 39 +++++++++++ 12 files changed, 150 insertions(+), 21 deletions(-) create mode 100644 graph-ui/tools/codeql-input-boundaries.test.mjs create mode 100644 graph-ui/tools/literal-pattern.mjs create mode 100644 graph-ui/tools/smoke-static-assets.mjs diff --git a/graph-ui/src/architecture/container-topology.test.ts b/graph-ui/src/architecture/container-topology.test.ts index 3cfa2db5f..2cef1596e 100644 --- a/graph-ui/src/architecture/container-topology.test.ts +++ b/graph-ui/src/architecture/container-topology.test.ts @@ -343,3 +343,9 @@ describe('Spring service connection evidence', () => { expect(links(result)).toEqual([]); }); }); + +it.each(['$endpoint', 'endpoint$'])('tracks a literal identifier through a transport wrapper: %s', parameter => { + const compose = 'services:\n web:\n build: ./web\n api:\n image: example/api\n'; + const source = `function load(${parameter}) { return fetch(${parameter}); }\nload("http://api:8080/items");`; + expect(links(buildContainerTopology([project(compose, { 'web/client.js': source })]))).toEqual(['web>api:http']); +}); diff --git a/graph-ui/src/architecture/container-topology.ts b/graph-ui/src/architecture/container-topology.ts index 51de635aa..b1735aaf7 100644 --- a/graph-ui/src/architecture/container-topology.ts +++ b/graph-ui/src/architecture/container-topology.ts @@ -300,7 +300,12 @@ function networkCall(name: string): boolean { || /^(?:requests|http|https|axios|urllib\.request|urllib3|grpc|redis|psycopg2?|pymysql|mysql|amqp|amqplib|net|socket|ConnectionMultiplexer|Dns)\./i.test(name) && /(?:get|post|put|patch|delete|request|urlopen|dial|newclient|connect|connectasync|createclient|createconnection|insecure_channel|secure_channel|gethostentry(?:async)?)$/i.test(name); } -const wordIn = (code: string, name: string): boolean => new RegExp(`\\b${name.replace(/[$]/g, '\\$&')}\\b`).test(code); +function wordIn(code: string, name: string): boolean { + for (const [identifier] of code.matchAll(/[A-Za-z_$][\w$]*/g)) { + if (identifier === name) return true; + } + return false; +} interface Wrapper { parameter: number; hint: string } /** Bounded local argument propagation to known transport APIs, not a function-name dictionary. */ function connectionWrappers(code: string, calls: Call[]): Map { diff --git a/graph-ui/src/architecture/repository-guide.test.ts b/graph-ui/src/architecture/repository-guide.test.ts index 0cb221754..a161a1216 100644 --- a/graph-ui/src/architecture/repository-guide.test.ts +++ b/graph-ui/src/architecture/repository-guide.test.ts @@ -64,3 +64,10 @@ it('follows only actual call edges across components and retains their evidence' expect(paths[0].edges[0]).toBe(map.evidence[0]); expect(entryRoutes(graph.nodes[2], map.evidence)).toEqual([]); }); + +it('keeps token boundaries when dropping markup from repository prose', () => { + const quote = sourceQuote('README.md', 'This repository provides useful source browsing and <