From 0325d6a3fa6a36b4f03f5dd6b8e7d7041a5448a6 Mon Sep 17 00:00:00 2001 From: wargloom Date: Wed, 21 Oct 2026 00:16:01 +0300 Subject: [PATCH 1/2] fix: watch explicit MCP index targets Signed-off-by: wargloom --- README.md | 14 +- docs/CONFIGURATION.md | 14 +- src/daemon/application.c | 458 ++++++++++++++++++++-------- src/mcp/mcp.c | 14 +- tests/test_daemon_application.c | 511 +++++++++++++++++++++++++++++++- 5 files changed, 858 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 7cdac83bf1..f121733048 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 3e9956d601..5573f41529 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 32b407743c..d45fcabab3 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; - if (!application->watcher || !session->context_set || - session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || session->hook_event || - session->hook_dialect || session->auto_index_subscribed) { - return; +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; + } } - 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]) { - return; + return false; +} + +static bool application_watch_set_options(cbm_daemon_application_watch_t *watch, + const char *args_json) { + if (!args_json) { + return true; } - if (!application_session_workspace_allowed(session, "watch")) { - application_release_session_watch_locked(session); - return; + 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; } - bool enabled = !application->config || - cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true); - bool db_exists = application_regular_db_exists(project); + 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; +} - /* 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)) { +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->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; } 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 ((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 (session->watch) { + 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 (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; + 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); } - watch->subscribers++; - session->watch = watch; - } else { - cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root", - watch->root, "requested_root", root); + 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; + } + application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp), + cbm_mcp_server_session_root(session->mcp), NULL); +} - watch = calloc(1, sizeof(*watch)); - if (watch) { - watch->project = strdup(project); - watch->root = strdup(root); +/* 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; } - if (!watch || !watch->project || !watch->root) { - if (watch) { - free(watch->project); - free(watch->root); - free(watch); + if (job->terminal) { + if (job->successful) { + application_register_watch_locked(session, job->project_key, job->root_path, + job->args_json); } return; } - /* 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; + for (application_index_watch_owner_t *owner = job->watch_owners; owner; owner = owner->next) { + if (owner->session == session) { + owner->async = owner->async || async; + 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; + } + } + } +} + +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); + } + cbm_free(CBM_MEM_CLASS_OTHER, owner); } - 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 5bd02e4e38..930638ccd1 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 a923043ca4..2f38c31dfc 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 b260a4bf70ab2d0b9890f3e93b01119c5fab7ee5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 22:57:14 +0200 Subject: [PATCH 2/2] 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 5573f41529..0981f6fc20 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 d45fcabab3..c431fe88fc 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 bd06e4efee..39b483898e 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 2f38c31dfc..343750fbf1 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);