From 783dac9a062e3deb3f2512c3bd6dc0f3a7fb384b Mon Sep 17 00:00:00 2001 From: pcristin Date: Sun, 28 Jun 2026 14:26:51 +0300 Subject: [PATCH 1/2] fix watcher stale root cleanup Signed-off-by: pcristin --- src/watcher/watcher.c | 66 +++++++++++++++++++++++++++++++++++++++++ tests/test_watcher.c | 69 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 4d0794352..8be09245f 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 @@ -39,6 +40,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) */ @@ -65,6 +67,7 @@ struct cbm_watcher { #define POLL_BASE_MS 5000 #define POLL_FILE_STEP 500 /* add 1s per this many files */ #define POLL_MAX_MS 60000 +#define MISSING_ROOT_DELETE_AFTER 3 /* Sleep chunk for responsive shutdown (ms) */ #define SLEEP_CHUNK_MS 500 @@ -220,6 +223,32 @@ static void state_free(project_state_t *s) { free(s); } +static bool root_path_exists(const char *root_path) { + struct stat st; + return root_path && stat(root_path, &st) == 0 && S_ISDIR(st.st_mode); +} + +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; @@ -389,6 +418,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); + state_free(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; @@ -397,6 +450,19 @@ static void poll_project(const char *key, void *val, void *ud) { return; } + if (!root_path_exists(s->root_path)) { + 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 (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 7c2fa5fe7..625fc1ec1 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 @@ -173,6 +174,73 @@ 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)) { + SKIP("cbm_mkdtemp root failed"); + } + + char cachedir[256]; + snprintf(cachedir, sizeof(cachedir), "/tmp/cbm_watcher_stale_cache_XXXXXX"); + if (!cbm_mkdtemp(cachedir)) { + th_rmtree(rootdir); + SKIP("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: prune watcher state and cached DB files. */ + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + 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_poll_this_repo) { /* Use this project's own repo as a real git repo test */ cbm_store_t *store = cbm_store_open_memory(); @@ -1617,6 +1685,7 @@ 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_poll_this_repo); RUN_TEST(watcher_stop_flag); From 756fc9d19ef6c630b6b36cc62122542e9417b429 Mon Sep 17 00:00:00 2001 From: pcristin Date: Sun, 28 Jun 2026 14:42:00 +0300 Subject: [PATCH 2/2] fix watcher test setup failures Signed-off-by: pcristin --- tests/test_watcher.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 95ee8a04b..a2cc39d7b 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -195,14 +195,14 @@ TEST(watcher_prunes_sustained_missing_root) { char rootdir[256]; snprintf(rootdir, sizeof(rootdir), "/tmp/cbm_watcher_stale_root_XXXXXX"); if (!cbm_mkdtemp(rootdir)) { - SKIP("cbm_mkdtemp root failed"); + 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); - SKIP("cbm_mkdtemp cache failed"); + FAIL("cbm_mkdtemp cache failed"); } char saved_cache_dir[1024];