From 0d1f60d1d69219eb49d6f9e9441de74c95b8d7a7 Mon Sep 17 00:00:00 2001 From: pcristin Date: Wed, 1 Jul 2026 15:30:40 +0300 Subject: [PATCH] fix(watcher): prune sustained missing roots Signed-off-by: pcristin --- src/watcher/watcher.c | 123 ++++++++++++++++++++++++++++++---- tests/test_watcher.c | 151 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 14 deletions(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 65f935022..20fea095c 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -22,6 +22,7 @@ #include "foundation/compat.h" #include "foundation/compat_thread.h" #include "foundation/compat_fs.h" +#include "foundation/platform.h" #include "foundation/str_util.h" #include @@ -30,6 +31,7 @@ #include #include #include +#include /* ── Per-project state ──────────────────────────────────────────── */ @@ -39,6 +41,7 @@ typedef struct { char last_head[CBM_SZ_64]; /* git HEAD hash */ bool is_git; /* false → skip polling */ bool baseline_done; /* true after first poll */ + int missing_root_count; /* consecutive polls where root_path was absent */ int file_count; /* approximate, for interval calc */ int interval_ms; /* adaptive poll interval */ int64_t next_poll_ns; /* next poll time (monotonic ns) */ @@ -69,6 +72,8 @@ struct cbm_watcher { #define POLL_BASE_MS 5000 #define POLL_FILE_STEP 500 /* add 1s per this many files */ #define POLL_MAX_MS 60000 +/* Sustained absence window before deleting an irreversible cached DB. */ +#define MISSING_ROOT_DELETE_AFTER ((3 * 60 * CBM_MSEC_PER_SEC) / POLL_BASE_MS) /* Sleep chunk for responsive shutdown (ms) */ #define SLEEP_CHUNK_MS 500 @@ -245,6 +250,45 @@ static void state_free(project_state_t *s) { free(s); } +typedef enum { + ROOT_PATH_EXISTS, + ROOT_PATH_MISSING, + ROOT_PATH_UNAVAILABLE, +} root_path_status_t; + +static root_path_status_t root_path_status(const char *root_path) { + if (!root_path) { + return ROOT_PATH_UNAVAILABLE; + } + + struct stat st; + if (stat(root_path, &st) == 0) { + return S_ISDIR(st.st_mode) ? ROOT_PATH_EXISTS : ROOT_PATH_UNAVAILABLE; + } + return (errno == ENOENT || errno == ENOTDIR) ? ROOT_PATH_MISSING : ROOT_PATH_UNAVAILABLE; +} + +static void delete_cached_project_db(const char *project_name) { + if (!cbm_validate_project_name(project_name)) { + return; + } + + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir) { + return; + } + + char path[CBM_SZ_1K]; + char wal[CBM_SZ_1K]; + char shm[CBM_SZ_1K]; + snprintf(path, sizeof(path), "%s/%s.db", cache_dir, project_name); + snprintf(wal, sizeof(wal), "%s-wal", path); + snprintf(shm, sizeof(shm), "%s-shm", path); + (void)cbm_unlink(path); + (void)cbm_unlink(wal); + (void)cbm_unlink(shm); +} + /* Hash table foreach callback to free state entries */ static void free_state_entry(const char *key, void *val, void *ud) { (void)key; @@ -252,6 +296,25 @@ static void free_state_entry(const char *key, void *val, void *ud) { state_free(val); } +static void defer_state_free_locked(cbm_watcher_t *w, project_state_t *s) { + if (!w || !s) { + return; + } + if (w->pending_free_count >= w->pending_free_cap) { + int new_cap = w->pending_free_cap ? w->pending_free_cap * 2 : 8; + project_state_t **tmp = realloc(w->pending_free, (size_t)new_cap * sizeof(project_state_t *)); + if (tmp) { + w->pending_free = tmp; + w->pending_free_cap = new_cap; + } + } + if (w->pending_free_count < w->pending_free_cap) { + w->pending_free[w->pending_free_count++] = s; + } else { + state_free(s); /* realloc failed — fall back to immediate free */ + } +} + /* ── Watcher lifecycle ──────────────────────────────────────────── */ cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void *user_data) { @@ -336,20 +399,7 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) { /* Defer free: the state may still be referenced by a poll_once * snapshot taken before we acquired the lock. poll_once will * drain this list at the start of its next cycle. */ - if (w->pending_free_count >= w->pending_free_cap) { - int new_cap = w->pending_free_cap ? w->pending_free_cap * 2 : 8; - project_state_t **tmp = - realloc(w->pending_free, (size_t)new_cap * sizeof(project_state_t *)); - if (tmp) { - w->pending_free = tmp; - w->pending_free_cap = new_cap; - } - } - if (w->pending_free_count < w->pending_free_cap) { - w->pending_free[w->pending_free_count++] = s; - } else { - state_free(s); /* realloc failed — fall back to immediate free */ - } + defer_state_free_locked(w, s); removed = true; } cbm_mutex_unlock(&w->projects_lock); @@ -437,6 +487,30 @@ typedef struct { int reindexed; } poll_ctx_t; +static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) { + if (!w || !s || !s->project_name) { + return; + } + + char project_name[CBM_SZ_1K]; + snprintf(project_name, sizeof(project_name), "%s", s->project_name); + + bool removed = false; + cbm_mutex_lock(&w->projects_lock); + project_state_t *current = cbm_ht_get(w->projects, project_name); + if (current == s) { + delete_cached_project_db(project_name); + cbm_ht_delete(w->projects, project_name); + defer_state_free_locked(w, s); + removed = true; + } + cbm_mutex_unlock(&w->projects_lock); + + if (removed) { + cbm_log_info("watcher.root_pruned", "project", project_name); + } +} + static void poll_project(const char *key, void *val, void *ud) { (void)key; poll_ctx_t *ctx = ud; @@ -445,6 +519,27 @@ static void poll_project(const char *key, void *val, void *ud) { return; } + root_path_status_t root_status = root_path_status(s->root_path); + if (root_status == ROOT_PATH_MISSING) { + s->missing_root_count++; + cbm_log_warn("watcher.root_missing", "project", s->project_name, "path", s->root_path); + if (s->missing_root_count >= MISSING_ROOT_DELETE_AFTER) { + prune_missing_project(ctx->w, s); + } + return; + } + if (root_status == ROOT_PATH_UNAVAILABLE) { + if (s->missing_root_count > 0) { + s->missing_root_count = 0; + } + cbm_log_warn("watcher.root_unavailable", "project", s->project_name, "path", s->root_path); + return; + } + if (s->missing_root_count > 0) { + cbm_log_info("watcher.root_restored", "project", s->project_name, "path", s->root_path); + s->missing_root_count = 0; + } + /* Initialize baseline on first poll */ if (!s->baseline_done) { init_baseline(s); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index c313f8b74..a825f2b55 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -5,6 +5,7 @@ * poll_once behavior. */ #include "../src/foundation/compat.h" +#include "../src/foundation/platform.h" #include "test_framework.h" #include "test_helpers.h" #include @@ -190,6 +191,154 @@ TEST(watcher_poll_nonexistent_path) { PASS(); } +TEST(watcher_prunes_sustained_missing_root) { + char rootdir[256]; + snprintf(rootdir, sizeof(rootdir), "/tmp/cbm_watcher_stale_root_XXXXXX"); + if (!cbm_mkdtemp(rootdir)) { + FAIL("cbm_mkdtemp root failed"); + } + + char cachedir[256]; + snprintf(cachedir, sizeof(cachedir), "/tmp/cbm_watcher_stale_cache_XXXXXX"); + if (!cbm_mkdtemp(cachedir)) { + th_rmtree(rootdir); + FAIL("cbm_mkdtemp cache failed"); + } + + char saved_cache_dir[1024]; + bool had_cache_dir = + cbm_safe_getenv("CBM_CACHE_DIR", saved_cache_dir, sizeof(saved_cache_dir), NULL) != NULL; + cbm_setenv("CBM_CACHE_DIR", cachedir, 1); + + char db_path[512]; + char wal_path[512]; + char shm_path[512]; + snprintf(db_path, sizeof(db_path), "%s/stale-project.db", cachedir); + snprintf(wal_path, sizeof(wal_path), "%s/stale-project.db-wal", cachedir); + snprintf(shm_path, sizeof(shm_path), "%s/stale-project.db-shm", cachedir); + th_write_file(db_path, "db\n"); + th_write_file(wal_path, "wal\n"); + th_write_file(shm_path, "shm\n"); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "stale-project", rootdir); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + + /* Existing root: first poll initializes baseline only. */ + cbm_watcher_poll_once(w); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + + th_rmtree(rootdir); + + /* Transient miss: keep the project and cached DB. */ + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + ASSERT_EQ(access(db_path, F_OK), 0); + + /* Sustained absence is minutes-scale: keep the project before threshold. */ + for (int i = 1; i < 35; i++) { + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + } + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + ASSERT_EQ(access(db_path, F_OK), 0); + + /* Threshold reached: prune watcher state and cached DB files. */ + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + ASSERT_NEQ(access(db_path, F_OK), 0); + ASSERT_NEQ(access(wal_path, F_OK), 0); + ASSERT_NEQ(access(shm_path, F_OK), 0); + + cbm_watcher_free(w); + cbm_store_close(store); + if (had_cache_dir) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_dir, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(cachedir); + PASS(); +} + +TEST(watcher_does_not_prune_inaccessible_root) { +#ifdef _WIN32 + PASS(); +#else + char parentdir[256]; + snprintf(parentdir, sizeof(parentdir), "/tmp/cbm_watcher_inaccessible_parent_XXXXXX"); + if (!cbm_mkdtemp(parentdir)) { + FAIL("cbm_mkdtemp parent failed"); + } + + char rootdir[512]; + snprintf(rootdir, sizeof(rootdir), "%s/root", parentdir); + if (mkdir(rootdir, 0700) != 0) { + th_rmtree(parentdir); + FAIL("mkdir root failed"); + } + + char cachedir[256]; + snprintf(cachedir, sizeof(cachedir), "/tmp/cbm_watcher_inaccessible_cache_XXXXXX"); + if (!cbm_mkdtemp(cachedir)) { + th_rmtree(parentdir); + FAIL("cbm_mkdtemp cache failed"); + } + + char saved_cache_dir[1024]; + bool had_cache_dir = + cbm_safe_getenv("CBM_CACHE_DIR", saved_cache_dir, sizeof(saved_cache_dir), NULL) != NULL; + cbm_setenv("CBM_CACHE_DIR", cachedir, 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/inaccessible-project.db", cachedir); + th_write_file(db_path, "db\n"); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "inaccessible-project", rootdir); + cbm_watcher_poll_once(w); + + if (chmod(parentdir, 0000) != 0) { + cbm_watcher_free(w); + cbm_store_close(store); + if (had_cache_dir) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_dir, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(cachedir); + th_rmtree(parentdir); + FAIL("chmod parent failed"); + } + + for (int i = 0; i < 40; i++) { + cbm_watcher_touch(w, "inaccessible-project"); + cbm_watcher_poll_once(w); + } + int watch_count = cbm_watcher_watch_count(w); + int db_exists = access(db_path, F_OK); + + chmod(parentdir, 0700); + cbm_watcher_free(w); + cbm_store_close(store); + if (had_cache_dir) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_dir, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(cachedir); + th_rmtree(parentdir); + + ASSERT_EQ(watch_count, 1); + ASSERT_EQ(db_exists, 0); + PASS(); +#endif +} + TEST(watcher_poll_this_repo) { /* Use this project's own repo as a real git repo test */ cbm_store_t *store = cbm_store_open_memory(); @@ -1693,6 +1842,8 @@ SUITE(watcher) { /* Polling */ RUN_TEST(watcher_poll_no_projects); RUN_TEST(watcher_poll_nonexistent_path); + RUN_TEST(watcher_prunes_sustained_missing_root); + RUN_TEST(watcher_does_not_prune_inaccessible_root); RUN_TEST(watcher_poll_this_repo); RUN_TEST(watcher_stop_flag);