mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #1833 from LeoLan-Digital/fix/permanent-daemon-watch
fix: persist daemon watches across client disconnects
This commit is contained in:
4 files changed
+455
-30
No files matched your search
@@ -255,7 +255,7 @@ 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 through `auto_watch` (on by default), subject to its global setting and session ownership.
|
||||
|
||||
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.
|
||||
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. Under a daemon started with `daemon start` a watch outlives its last owning session: the project stays watched and is re-indexed in the background when it changes, until that daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. 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 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.
|
||||
|
||||
@@ -904,7 +904,7 @@ Tools requiring arguments accept JSON piped on stdin. Tools with only optional a
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `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). |
|
||||
| `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 (under `daemon start`, also after it disconnects), 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. |
|
||||
|
||||
@@ -89,7 +89,7 @@ Current keys:
|
||||
| `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. |
|
||||
| `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. Active MCP sessions can register their root project and successful explicit index targets; `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; under `daemon start` those watches outlive the session until the daemon stops, the project is deleted, its directory disappears, or `auto_watch` is set to `false`. `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. |
|
||||
|
||||
+86
-27
@@ -196,11 +196,11 @@ struct cbm_daemon_application_index_record {
|
||||
cbm_daemon_application_index_record_t *next;
|
||||
};
|
||||
|
||||
/* A watcher-triggered physical job is owned by the exact live sessions that
|
||||
* currently subscribe to its project/root watch. The callback waiting for the
|
||||
* job is only a storage waiter; it is deliberately not an ownership
|
||||
* subscription, so the worker is cancelled when the last matching session
|
||||
* disconnects even while unrelated daemon sessions remain alive. */
|
||||
/* A watcher-triggered physical job is normally owned by the exact live
|
||||
* sessions subscribed to its project/root watch. In permanent daemon mode the
|
||||
* watcher itself additionally owns the physical job so background indexing can
|
||||
* continue while no client session is connected. The callback remains a
|
||||
* storage waiter in both modes and releases daemon ownership on completion. */
|
||||
struct cbm_daemon_application_watch_job_subscription {
|
||||
cbm_daemon_application_session_t *session;
|
||||
cbm_daemon_application_job_t *job;
|
||||
@@ -564,8 +564,26 @@ static bool application_watches_disabled_locked(cbm_daemon_application_t *applic
|
||||
return true;
|
||||
}
|
||||
|
||||
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) {
|
||||
static void application_release_session_watch_locked(cbm_daemon_application_session_t *session,
|
||||
bool session_ending) {
|
||||
application_watch_job_unsubscribe_session_locked(session, NULL);
|
||||
|
||||
/*
|
||||
* A permanent daemon owns the physical project watch independently of
|
||||
* individual client sessions. Session disconnect releases only that
|
||||
* session's logical subscription; the watcher remains available for
|
||||
* background change detection while the daemon generation is alive.
|
||||
*
|
||||
* Non-permanent applications retain the historical session-scoped
|
||||
* lifecycle and remove the physical watch after the final subscriber.
|
||||
* A release decided by workspace policy (session_ending false: the
|
||||
* session's root is no longer allowed) is not a disconnect and removes
|
||||
* the watch in both modes. Daemon shutdown and auto_watch=false remove
|
||||
* persistent watches through their own paths; a deleted project's watch
|
||||
* ends at its next watcher callback
|
||||
* (application_permanent_watch_orphaned_locked).
|
||||
*/
|
||||
bool daemon_keeps_watch = session_ending && session->application->permanent;
|
||||
while (session->watches) {
|
||||
application_watch_membership_t *membership = session->watches;
|
||||
cbm_daemon_application_watch_t *watch = membership->watch;
|
||||
@@ -573,7 +591,7 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess
|
||||
cbm_free(CBM_MEM_CLASS_OTHER, membership);
|
||||
if (watch->subscribers > 0) {
|
||||
watch->subscribers--;
|
||||
if (watch->subscribers == 0) {
|
||||
if (!daemon_keeps_watch && watch->subscribers == 0) {
|
||||
application_remove_watch_locked(session->application, watch);
|
||||
}
|
||||
}
|
||||
@@ -721,7 +739,7 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
|
||||
return;
|
||||
}
|
||||
if (!application_session_workspace_allowed(session, "watch")) {
|
||||
application_release_session_watch_locked(session);
|
||||
application_release_session_watch_locked(session, false);
|
||||
return;
|
||||
}
|
||||
application_register_watch_locked(session, cbm_mcp_server_session_project(session->mcp),
|
||||
@@ -806,7 +824,9 @@ 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"; session roots and successful explicit targets stay watched while
|
||||
* at least one owning MCP session remains open. Takes the
|
||||
* at least one owning MCP session remains open (under `daemon start`, also
|
||||
* after the last one closes, until the daemon stops, the project is deleted,
|
||||
* its root disappears, or auto_watch is switched off). 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) {
|
||||
@@ -3649,7 +3669,7 @@ static void application_session_cancel(void *context,
|
||||
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);
|
||||
application_release_session_watch_locked(session, true);
|
||||
bool final_live_session = newly_cancelled;
|
||||
for (cbm_daemon_application_session_t *other = application->sessions;
|
||||
final_live_session && other; other = other->next) {
|
||||
@@ -3709,7 +3729,7 @@ static void application_session_close(void *context,
|
||||
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);
|
||||
application_release_session_watch_locked(session, true);
|
||||
cbm_mutex_unlock(&application->mutex);
|
||||
application_auto_index_cancel_join(application, join_auto_index);
|
||||
application_jobs_reap_completed(application);
|
||||
@@ -4256,11 +4276,27 @@ static void application_watch_encode_index_args_locked(const cbm_daemon_applicat
|
||||
*args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL;
|
||||
}
|
||||
|
||||
/* Under `daemon start` a watch outlives its last session, so delete_project
|
||||
* from another session or the CLI can remove the project's DB while nothing
|
||||
* owns the watch any more: daemon MCP sessions carry no watcher pointer and
|
||||
* never unwatch it. Re-indexing such an orphan would resurrect the deleted
|
||||
* project. A full reindex also unlinks the DB while it rebuilds it, so a
|
||||
* missing DB means "deleted" only while no job for the project is running.
|
||||
* Caller holds application->mutex. */
|
||||
static bool application_permanent_watch_orphaned_locked(cbm_daemon_application_t *application,
|
||||
const cbm_daemon_application_watch_t *watch,
|
||||
const char *project_key) {
|
||||
return application->permanent && watch->subscribers == 0 &&
|
||||
!application_find_active_job_locked(application, project_key) &&
|
||||
!application_regular_db_exists(watch->project);
|
||||
}
|
||||
|
||||
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) {
|
||||
bool require_live_watch, bool *watcher_owns_job, int *no_job_result) {
|
||||
application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE;
|
||||
*watcher_owns_job = false;
|
||||
application_jobs_reap_completed(application);
|
||||
cbm_mutex_lock(&application->mutex);
|
||||
if (require_live_watch) {
|
||||
@@ -4268,9 +4304,15 @@ static cbm_daemon_application_job_t *application_background_index_admit(
|
||||
}
|
||||
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 &&
|
||||
application_watch_root_matches(watch->root, canonical_root));
|
||||
if (watch && application_permanent_watch_orphaned_locked(application, watch, project_key)) {
|
||||
/* End the orphan the way a session refresh ends a deleted project's
|
||||
* watch; the callback below becomes a harmless skip. */
|
||||
application_remove_watch_locked(application, watch);
|
||||
watch = NULL;
|
||||
}
|
||||
bool watch_live = !require_live_watch ||
|
||||
(watch && application_watch_root_matches(watch->root, canonical_root) &&
|
||||
(watch->subscribers > 0 || application->permanent));
|
||||
if (require_live_watch && watch_live) {
|
||||
application_watch_encode_index_args_locked(watch, document, root, args);
|
||||
}
|
||||
@@ -4284,16 +4326,28 @@ static cbm_daemon_application_job_t *application_background_index_admit(
|
||||
if (job && require_live_watch) {
|
||||
watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked(
|
||||
application, watch, job, &watch_owner_count);
|
||||
if (watch_subscriptions_ok && watch_owner_count > 0) {
|
||||
job->watcher_waiters++;
|
||||
} else if (!watch_subscriptions_ok) {
|
||||
|
||||
if (!watch_subscriptions_ok) {
|
||||
subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED;
|
||||
}
|
||||
/* application_job_subscribe_locked() lends the caller one ordinary
|
||||
* subscriber. A watcher callback is only a storage waiter: exact live
|
||||
* session subscriptions above own the physical work. */
|
||||
application_job_unsubscribe_locked(job);
|
||||
if (!watch_subscriptions_ok || watch_owner_count == 0) {
|
||||
application_job_unsubscribe_locked(job);
|
||||
job = NULL;
|
||||
} else if (application->permanent) {
|
||||
/*
|
||||
* The ordinary subscription acquired above is retained as daemon
|
||||
* ownership. This allows the physical watcher to run indexing even
|
||||
* while no client session currently owns the project watch.
|
||||
*/
|
||||
*watcher_owns_job = true;
|
||||
job->watcher_waiters++;
|
||||
} else if (watch_owner_count > 0) {
|
||||
/*
|
||||
* Historical non-permanent behavior: exact live sessions own the
|
||||
* physical work; the watcher callback itself is only a waiter.
|
||||
*/
|
||||
job->watcher_waiters++;
|
||||
application_job_unsubscribe_locked(job);
|
||||
} else {
|
||||
application_job_unsubscribe_locked(job);
|
||||
job = NULL;
|
||||
}
|
||||
}
|
||||
@@ -4318,7 +4372,7 @@ static cbm_daemon_application_job_t *application_background_index_admit(
|
||||
|
||||
static int application_background_index_wait(cbm_daemon_application_t *application,
|
||||
cbm_daemon_application_job_t *job,
|
||||
bool require_live_watch) {
|
||||
bool require_live_watch, bool watcher_owns_job) {
|
||||
bool successful = false;
|
||||
bool cancelled = false;
|
||||
for (;;) {
|
||||
@@ -4331,6 +4385,9 @@ static int application_background_index_wait(cbm_daemon_application_t *applicati
|
||||
if (job->watcher_waiters > 0) {
|
||||
job->watcher_waiters--;
|
||||
}
|
||||
if (watcher_owns_job) {
|
||||
application_job_unsubscribe_locked(job);
|
||||
}
|
||||
} else {
|
||||
application_job_unsubscribe_locked(job);
|
||||
}
|
||||
@@ -4387,13 +4444,15 @@ static int application_background_index(cbm_daemon_application_t *application,
|
||||
}
|
||||
|
||||
int no_job_result = APPLICATION_BACKGROUND_INDEX_FAILED;
|
||||
bool watcher_owns_job = false;
|
||||
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);
|
||||
require_live_watch, &watcher_owns_job, &no_job_result);
|
||||
free(project_key);
|
||||
free(args);
|
||||
yyjson_mut_doc_free(document);
|
||||
return job ? application_background_index_wait(application, job, require_live_watch)
|
||||
return job ? application_background_index_wait(application, job, require_live_watch,
|
||||
watcher_owns_job)
|
||||
: no_job_result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2822,6 +2822,122 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Under `daemon start` a session that ENDS leaves its watch to the daemon, but
|
||||
* a workspace-policy release is not a disconnect: once the session's root is
|
||||
* no longer allowed (here its approval is revoked), the next request's
|
||||
* refresh must still end the watch in a permanent generation too. */
|
||||
TEST(daemon_application_permanent_workspace_release_still_ends_watch) {
|
||||
app_env_backup_t cache_environment;
|
||||
app_env_backup_t home_environment;
|
||||
bool environments_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR") &&
|
||||
app_env_backup_capture(&home_environment, "HOME");
|
||||
char sensitive_raw[APP_TEST_PATH_CAP];
|
||||
char cache_raw[APP_TEST_PATH_CAP];
|
||||
(void)snprintf(sensitive_raw, sizeof(sensitive_raw),
|
||||
"%s/cbm-app-permanent-release-home-XXXXXX", cbm_tmpdir());
|
||||
(void)snprintf(cache_raw, sizeof(cache_raw), "%s/cbm-app-permanent-release-cache-XXXXXX",
|
||||
cbm_tmpdir());
|
||||
bool dirs_ok = cbm_mkdtemp(sensitive_raw) != NULL && cbm_mkdtemp(cache_raw) != NULL;
|
||||
char sensitive[APP_TEST_PATH_CAP] = {0};
|
||||
char cache[APP_TEST_PATH_CAP] = {0};
|
||||
bool canonical = dirs_ok && cbm_canonical_path(sensitive_raw, sensitive, sizeof(sensitive)) &&
|
||||
cbm_canonical_path(cache_raw, cache, sizeof(cache));
|
||||
bool environments_set = environments_saved && canonical &&
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 &&
|
||||
cbm_setenv("HOME", sensitive, 1) == 0;
|
||||
cbm_config_t *stored_config = environments_set ? cbm_config_open(cache) : NULL;
|
||||
bool config_ready = stored_config &&
|
||||
cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "false") == 0 &&
|
||||
cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "true") == 0;
|
||||
char *project = canonical ? cbm_project_name_from_path(sensitive) : NULL;
|
||||
char db_path[APP_TEST_PATH_CAP] = {0};
|
||||
if (project) {
|
||||
(void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||||
}
|
||||
bool db_ready = config_ready && project && app_test_create_empty_file(db_path);
|
||||
/* The root is $HOME, so only an explicit sensitive approval allows it. */
|
||||
char grant_error[APP_TEST_PATH_CAP];
|
||||
bool approved = db_ready && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false,
|
||||
grant_error, sizeof(grant_error));
|
||||
char grant_path[APP_TEST_PATH_CAP] = {0};
|
||||
bool grant_path_ok =
|
||||
approved && cbm_workspace_grant_path(cache, grant_path, sizeof(grant_path));
|
||||
|
||||
cbm_store_t *store = cbm_store_open_memory();
|
||||
cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL);
|
||||
cbm_daemon_application_config_t config = {
|
||||
.watcher = watcher,
|
||||
.config = stored_config,
|
||||
};
|
||||
cbm_daemon_application_t *application =
|
||||
grant_path_ok && watcher ? cbm_daemon_application_new(&config) : NULL;
|
||||
if (application) {
|
||||
cbm_daemon_application_set_permanent(application, true);
|
||||
}
|
||||
cbm_daemon_runtime_application_callbacks_t callbacks =
|
||||
cbm_daemon_application_runtime_callbacks(application);
|
||||
cbm_daemon_runtime_application_session_t *session =
|
||||
application ? app_test_open(&callbacks, 4036) : NULL;
|
||||
|
||||
bool initialized = app_test_initialize_profile(&callbacks, session, sensitive,
|
||||
CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
|
||||
int watch_while_approved = watcher ? cbm_watcher_watch_count(watcher) : -1;
|
||||
|
||||
/* Revoke the approval. Every request ends with a synchronous watch
|
||||
* refresh, so one ping is the point where the policy is re-read. */
|
||||
bool revoked = initialized && cbm_unlink(grant_path) == 0;
|
||||
uint8_t *ping = NULL;
|
||||
uint32_t ping_length = 0;
|
||||
uint8_t *response = NULL;
|
||||
uint32_t response_length = 0;
|
||||
bool pinged = revoked &&
|
||||
app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP,
|
||||
"{\"jsonrpc\":\"2.0\",\"id\":4136,\"method\":\"ping\"}",
|
||||
&ping, &ping_length) &&
|
||||
app_test_request(&callbacks, session, ping, ping_length, &response,
|
||||
&response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
|
||||
free(response);
|
||||
free(ping);
|
||||
int watch_after_revoke = watcher ? cbm_watcher_watch_count(watcher) : -1;
|
||||
|
||||
if (session) {
|
||||
callbacks.session_cancel(callbacks.context, session);
|
||||
callbacks.session_close(callbacks.context, session);
|
||||
}
|
||||
bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS);
|
||||
cbm_daemon_application_free(application);
|
||||
if (watcher) {
|
||||
cbm_watcher_stop(watcher);
|
||||
cbm_watcher_free(watcher);
|
||||
}
|
||||
cbm_store_close(store);
|
||||
cbm_config_close(stored_config);
|
||||
free(project);
|
||||
(void)th_rmtree(sensitive);
|
||||
(void)th_rmtree(cache);
|
||||
bool cache_restored = app_env_backup_restore(&cache_environment);
|
||||
bool home_restored = app_env_backup_restore(&home_environment);
|
||||
|
||||
ASSERT_TRUE(environments_saved);
|
||||
ASSERT_TRUE(dirs_ok);
|
||||
ASSERT_TRUE(canonical);
|
||||
ASSERT_TRUE(environments_set);
|
||||
ASSERT_TRUE(config_ready);
|
||||
ASSERT_TRUE(db_ready);
|
||||
ASSERT_TRUE(approved);
|
||||
ASSERT_TRUE(grant_path_ok);
|
||||
ASSERT_TRUE(initialized);
|
||||
ASSERT_EQ(watch_while_approved, 1);
|
||||
ASSERT_TRUE(revoked);
|
||||
ASSERT_TRUE(pinged);
|
||||
/* A policy release, not a disconnect: the permanent watch ends as well. */
|
||||
ASSERT_EQ(watch_after_revoke, 0);
|
||||
ASSERT_TRUE(stopped);
|
||||
ASSERT_TRUE(cache_restored);
|
||||
ASSERT_TRUE(home_restored);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_application_programmatic_index_injects_resource_policy) {
|
||||
char *root = th_mktempdir("cbm_app_policy_root");
|
||||
char *cache = th_mktempdir("cbm_app_policy_cache");
|
||||
@@ -4588,6 +4704,252 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
|
||||
TEST(daemon_application_permanent_watch_survives_disconnect_and_indexes_without_session) {
|
||||
app_watch_race_fixture_t fixture;
|
||||
bool fixture_ready = app_watch_race_fixture_init(&fixture, 47);
|
||||
|
||||
if (fixture_ready) {
|
||||
cbm_daemon_application_set_permanent(fixture.application, true);
|
||||
}
|
||||
|
||||
int watch_before_disconnect =
|
||||
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
if (fixture.session) {
|
||||
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
|
||||
}
|
||||
|
||||
int watch_after_cancel =
|
||||
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
if (fixture.session) {
|
||||
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
|
||||
fixture.session = NULL;
|
||||
}
|
||||
|
||||
int watch_after_close =
|
||||
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
/*
|
||||
* No client session remains at this point. A permanent daemon-owned
|
||||
* physical watch must nevertheless be allowed to launch its background
|
||||
* indexing job.
|
||||
*/
|
||||
app_watcher_index_thread_t request = {
|
||||
.application = fixture.application,
|
||||
.project = fixture.project,
|
||||
.root = fixture.root,
|
||||
.pause_before_subscribe = false,
|
||||
.result = -1,
|
||||
};
|
||||
atomic_init(&request.ready, false);
|
||||
atomic_init(&request.proceed, true);
|
||||
atomic_init(&request.done, false);
|
||||
|
||||
cbm_thread_t thread;
|
||||
bool thread_started =
|
||||
fixture_ready &&
|
||||
watch_after_close == 1 &&
|
||||
cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0;
|
||||
|
||||
bool worker_started =
|
||||
thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1);
|
||||
|
||||
int watch_during_job =
|
||||
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
/*
|
||||
* The fake worker deliberately waits until the test releases it.
|
||||
* Let the daemon-owned watcher job finish normally.
|
||||
*/
|
||||
if (worker_started) {
|
||||
atomic_store(&fixture.fake.allow_completion, true);
|
||||
}
|
||||
|
||||
bool request_done =
|
||||
thread_started && app_wait_for_atomic_bool(&request.done, true);
|
||||
|
||||
bool thread_joined =
|
||||
request_done && cbm_thread_join(&thread) == 0;
|
||||
|
||||
int watch_after_job =
|
||||
fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
int destroys_before_cleanup =
|
||||
fixture_ready ? atomic_load(&fixture.fake.destroys) : -1;
|
||||
|
||||
bool cleaned = app_watch_race_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(fixture_ready);
|
||||
ASSERT_EQ(watch_before_disconnect, 1);
|
||||
|
||||
/* Permanent mode: disconnect must not unwatch the project. */
|
||||
ASSERT_EQ(watch_after_cancel, 1);
|
||||
ASSERT_EQ(watch_after_close, 1);
|
||||
|
||||
/* No session exists, yet the watcher still owns background indexing. */
|
||||
ASSERT_TRUE(thread_started);
|
||||
ASSERT_TRUE(worker_started);
|
||||
ASSERT_EQ(watch_during_job, 1);
|
||||
|
||||
ASSERT_TRUE(request_done);
|
||||
ASSERT_TRUE(thread_joined);
|
||||
ASSERT_EQ(request.result, 0);
|
||||
ASSERT_EQ(destroys_before_cleanup, 1);
|
||||
|
||||
/* Completing the background job must not consume the persistent watch. */
|
||||
ASSERT_EQ(watch_after_job, 1);
|
||||
|
||||
ASSERT_TRUE(cleaned);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* A permanent daemon keeps a project watched after its last session closes.
|
||||
* delete_project from another session or the CLI removes the project's DB but
|
||||
* owns no watch, so the watch itself must notice: a later change must end the
|
||||
* orphaned watch, never re-create the deleted index. */
|
||||
TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted) {
|
||||
app_watch_race_fixture_t fixture;
|
||||
bool fixture_ready = app_watch_race_fixture_init(&fixture, 48);
|
||||
if (fixture_ready) {
|
||||
cbm_daemon_application_set_permanent(fixture.application, true);
|
||||
/* Should a job be admitted, it completes at once: the verdict is
|
||||
* read from the start counter, never from a timeout. */
|
||||
atomic_store(&fixture.fake.allow_completion, true);
|
||||
}
|
||||
if (fixture.session) {
|
||||
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
|
||||
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
|
||||
fixture.session = NULL;
|
||||
}
|
||||
int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
bool db_removed = fixture_ready && cbm_unlink(fixture.db_path) == 0;
|
||||
int result = db_removed ? cbm_daemon_application_watcher_index(fixture.project, fixture.root,
|
||||
fixture.application)
|
||||
: -2;
|
||||
int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1;
|
||||
int watch_after_index = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
bool cleaned = app_watch_race_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(fixture_ready);
|
||||
ASSERT_EQ(watch_after_close, 1);
|
||||
ASSERT_TRUE(db_removed);
|
||||
/* A harmless skip: nothing admitted, the deleted index stays deleted. */
|
||||
ASSERT_EQ(result, 1);
|
||||
ASSERT_EQ(starts, 0);
|
||||
/* The orphaned physical watch is torn down. */
|
||||
ASSERT_EQ(watch_after_index, 0);
|
||||
ASSERT_TRUE(cleaned);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* The second watcher callback either joins the running job (its retained
|
||||
* ownership is the job's second subscriber) or returns. Both states latch, so
|
||||
* whichever happens decides the wait; the deadline only catches "neither". */
|
||||
static bool app_wait_for_watcher_join_or_return(cbm_daemon_application_t *application,
|
||||
const char *project, atomic_bool *returned) {
|
||||
uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS;
|
||||
while (cbm_now_ms() < deadline) {
|
||||
if (cbm_daemon_application_job_subscribers(application, project) == 2 ||
|
||||
atomic_load(returned)) {
|
||||
return true;
|
||||
}
|
||||
cbm_usleep(1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A full reindex unlinks the project's DB while it rebuilds it, so a missing
|
||||
* DB alone does not mean the project was deleted. While the permanent watch's
|
||||
* own job runs, a second change in its root must join that job and keep the
|
||||
* watch; once the job ends, both callbacks hand their ownership back. */
|
||||
TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs) {
|
||||
app_watch_race_fixture_t fixture;
|
||||
bool fixture_ready = app_watch_race_fixture_init(&fixture, 49);
|
||||
if (fixture_ready) {
|
||||
cbm_daemon_application_set_permanent(fixture.application, true);
|
||||
}
|
||||
if (fixture.session) {
|
||||
fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session);
|
||||
fixture.callbacks.session_close(fixture.callbacks.context, fixture.session);
|
||||
fixture.session = NULL;
|
||||
}
|
||||
int watch_after_close = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
app_watcher_index_thread_t first = {
|
||||
.application = fixture.application,
|
||||
.project = fixture.project,
|
||||
.root = fixture.root,
|
||||
.pause_before_subscribe = false,
|
||||
.result = -1,
|
||||
};
|
||||
atomic_init(&first.ready, false);
|
||||
atomic_init(&first.proceed, true);
|
||||
atomic_init(&first.done, false);
|
||||
app_watcher_index_thread_t second = {
|
||||
.application = fixture.application,
|
||||
.project = fixture.project,
|
||||
.root = fixture.root,
|
||||
.pause_before_subscribe = false,
|
||||
.result = -1,
|
||||
};
|
||||
atomic_init(&second.ready, false);
|
||||
atomic_init(&second.proceed, true);
|
||||
atomic_init(&second.done, false);
|
||||
|
||||
/* The fake worker keeps the first job running until allow_completion. */
|
||||
cbm_thread_t first_thread;
|
||||
bool first_started = fixture_ready && watch_after_close == 1 &&
|
||||
cbm_thread_create(&first_thread, 0, app_watcher_index_thread, &first) == 0;
|
||||
bool job_running = first_started && app_wait_for_atomic_int(&fixture.fake.starts, 1);
|
||||
bool db_removed = job_running && cbm_unlink(fixture.db_path) == 0;
|
||||
cbm_thread_t second_thread;
|
||||
bool second_started =
|
||||
db_removed && cbm_thread_create(&second_thread, 0, app_watcher_index_thread, &second) == 0;
|
||||
bool settled = second_started && app_wait_for_watcher_join_or_return(
|
||||
fixture.application, fixture.project, &second.done);
|
||||
size_t subscribers_while_running =
|
||||
fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project)
|
||||
: 0;
|
||||
bool second_returned_while_running = atomic_load(&second.done);
|
||||
int watch_while_running = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
|
||||
if (fixture_ready) {
|
||||
atomic_store(&fixture.fake.allow_completion, true);
|
||||
}
|
||||
bool first_joined = first_started && app_wait_for_atomic_bool(&first.done, true) &&
|
||||
cbm_thread_join(&first_thread) == 0;
|
||||
bool second_joined = second_started && app_wait_for_atomic_bool(&second.done, true) &&
|
||||
cbm_thread_join(&second_thread) == 0;
|
||||
int starts = fixture_ready ? atomic_load(&fixture.fake.starts) : -1;
|
||||
size_t subscribers_after =
|
||||
fixture_ready ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project)
|
||||
: SIZE_MAX;
|
||||
int watch_after = fixture_ready ? cbm_watcher_watch_count(fixture.watcher) : -1;
|
||||
bool cleaned = app_watch_race_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(fixture_ready);
|
||||
ASSERT_EQ(watch_after_close, 1);
|
||||
ASSERT_TRUE(job_running);
|
||||
ASSERT_TRUE(db_removed);
|
||||
ASSERT_TRUE(settled);
|
||||
/* The second change joined the running job instead of ending the watch. */
|
||||
ASSERT_EQ(subscribers_while_running, 2);
|
||||
ASSERT_FALSE(second_returned_while_running);
|
||||
ASSERT_EQ(watch_while_running, 1);
|
||||
ASSERT_TRUE(first_joined);
|
||||
ASSERT_TRUE(second_joined);
|
||||
ASSERT_EQ(first.result, 0);
|
||||
ASSERT_EQ(second.result, 0);
|
||||
ASSERT_EQ(starts, 1);
|
||||
/* Each callback released the ownership it retained at admission. */
|
||||
ASSERT_EQ(subscribers_after, 0);
|
||||
ASSERT_EQ(watch_after, 1);
|
||||
ASSERT_TRUE(cleaned);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(daemon_application_late_watcher_session_owns_active_watcher_job) {
|
||||
app_watch_race_fixture_t fixture;
|
||||
bool fixture_ready = app_watch_race_fixture_init(&fixture, 45);
|
||||
@@ -7660,6 +8022,7 @@ SUITE(daemon_application) {
|
||||
RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions);
|
||||
RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls);
|
||||
RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls);
|
||||
RUN_TEST(daemon_application_permanent_workspace_release_still_ends_watch);
|
||||
RUN_TEST(daemon_application_ui_index_accepts_non_ascii_directory);
|
||||
RUN_TEST(daemon_application_index_args_compare_repo_path_separator_equivalently);
|
||||
RUN_TEST(daemon_application_async_index_returns_before_completion_and_status_polls);
|
||||
@@ -7693,6 +8056,9 @@ SUITE(daemon_application) {
|
||||
RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission);
|
||||
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_permanent_watch_survives_disconnect_and_indexes_without_session);
|
||||
RUN_TEST(daemon_application_permanent_watch_ends_when_project_db_is_deleted);
|
||||
RUN_TEST(daemon_application_permanent_watch_kept_while_its_reindex_runs);
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user