From 3b93827fe2fa6ed95d747a88cbcfd90e9f5fbbb6 Mon Sep 17 00:00:00 2001 From: Gol3vka Date: Sat, 20 Jun 2026 22:23:42 +0800 Subject: [PATCH 1/4] fix: unwatch deleted projects to prevent zombie reindex Signed-off-by: Gol3vka --- src/mcp/mcp.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 368d73f3..6251a03f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1857,6 +1857,11 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { } cbm_pipeline_unlock(); + + if (srv->watcher) { + cbm_watcher_unwatch(srv->watcher, name); + } + cbm_mem_collect(); /* return freed pages to OS after closing database */ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); From 2bc4a539ac9c1236c78568e47e419fc895eab736 Mon Sep 17 00:00:00 2001 From: Gol3vka Date: Sat, 20 Jun 2026 22:45:08 +0800 Subject: [PATCH 2/4] fix(watcher): defer state_free in unwatch to prevent UAF Signed-off-by: Gol3vka --- src/watcher/watcher.c | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 04f27f12..3d506f92 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -53,6 +53,10 @@ struct cbm_watcher { CBMHashTable *projects; /* name → project_state_t* */ cbm_mutex_t projects_lock; atomic_int stopped; + /* Deferred-free list: freed after the next poll_once. */ + project_state_t **pending_free; + int pending_free_count; + int pending_free_cap; }; /* ── Constants ─────────────────────────────────────────────────── */ @@ -275,6 +279,10 @@ void cbm_watcher_free(cbm_watcher_t *w) { cbm_mutex_lock(&w->projects_lock); cbm_ht_foreach(w->projects, free_state_entry, NULL); cbm_ht_free(w->projects); + for (int i = 0; i < w->pending_free_count; i++) { + state_free(w->pending_free[i]); + } + free(w->pending_free); cbm_mutex_unlock(&w->projects_lock); cbm_mutex_destroy(&w->projects_lock); free(w); @@ -322,7 +330,23 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) { project_state_t *s = cbm_ht_get(w->projects, project_name); if (s) { cbm_ht_delete(w->projects, project_name); - state_free(s); + /* 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 */ + } removed = true; } cbm_mutex_unlock(&w->projects_lock); @@ -484,6 +508,13 @@ int cbm_watcher_poll_once(cbm_watcher_t *w) { * This keeps the critical section small — poll_project does git I/O * and may invoke index_fn which runs the full pipeline. */ cbm_mutex_lock(&w->projects_lock); + + /* Free deferred entries from the previous cycle. */ + for (int i = 0; i < w->pending_free_count; i++) { + state_free(w->pending_free[i]); + } + w->pending_free_count = 0; + int n = cbm_ht_count(w->projects); if (n == 0) { cbm_mutex_unlock(&w->projects_lock); From 7814d960f35c7d981a03dc8e1ab91782337a78ec Mon Sep 17 00:00:00 2001 From: Gol3vka Date: Sun, 21 Jun 2026 21:54:09 +0800 Subject: [PATCH 3/4] format src/watcher.c Signed-off-by: Gol3vka --- src/watcher/watcher.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 3d506f92..cad39631 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -335,8 +335,8 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) { * 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 *)); + 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; From 1c602c6c34fb9ffa647a17b1418c74a4c010c2e1 Mon Sep 17 00:00:00 2001 From: Gol3vka Date: Fri, 26 Jun 2026 09:31:23 +0800 Subject: [PATCH 4/4] fix(watcher): guard cbm_watcher_free and add deferred-free test Signed-off-by: Gol3vka --- src/watcher/watcher.c | 3 + src/watcher/watcher.h | 3 +- tests/test_watcher.c | 272 +++++++++++++++++++++++++++++++++++------- 3 files changed, 234 insertions(+), 44 deletions(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index cad39631..65f93502 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -276,6 +276,9 @@ void cbm_watcher_free(cbm_watcher_t *w) { if (!w) { return; } + /* Safety net: ensure stopped is set before draining pending_free. + * In production the caller should cbm_watcher_stop() + join first. */ + atomic_store(&w->stopped, 1); cbm_mutex_lock(&w->projects_lock); cbm_ht_foreach(w->projects, free_state_entry, NULL); cbm_ht_free(w->projects); diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 80ef826d..6647fc38 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -34,7 +34,8 @@ typedef int (*cbm_index_fn)(const char *project_name, const char *root_path, voi * user_data is passed to index_fn. */ cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void *user_data); -/* Free the watcher and all per-project state. NULL-safe. */ +/* Free the watcher and all per-project state. NULL-safe. + * Precondition: cbm_watcher_stop() + thread join must have completed. */ void cbm_watcher_free(cbm_watcher_t *w); /* ── Watch list management ──────────────────────────────────────── */ diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 5234b73d..c313f8b7 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -252,8 +252,14 @@ TEST(watcher_detects_git_commit) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -268,7 +274,10 @@ TEST(watcher_detects_git_commit) { ASSERT_EQ(index_call_count, 0); /* Make a change: new commit */ - { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "world\n"); } + { + char p[300]; + th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "world\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m add-world"); @@ -296,8 +305,14 @@ TEST(watcher_detects_dirty_worktree) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -336,8 +351,14 @@ TEST(watcher_detects_new_file) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -377,8 +398,14 @@ TEST(watcher_no_change_no_reindex) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -415,13 +442,27 @@ TEST(watcher_multiple_projects) { if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdirA, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirA, "a.txt"), "a\n"); } + if (wt_git(tmpdirA, "init -q") != 0) { + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdirA, "a.txt"), "a\n"); + } wt_git(tmpdirA, "add a.txt"); wt_git(tmpdirA, "commit -q -m init"); - if (wt_git(tmpdirB, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirB, "b.txt"), "b\n"); } + if (wt_git(tmpdirB, "init -q") != 0) { + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdirB, "b.txt"), "b\n"); + } wt_git(tmpdirB, "add b.txt"); wt_git(tmpdirB, "commit -q -m init"); @@ -527,8 +568,14 @@ TEST(watcher_interval_blocks_repoll) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -597,8 +644,14 @@ TEST(watcher_git_removed_no_crash) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -638,8 +691,14 @@ TEST(watcher_continued_dirty) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -702,8 +761,14 @@ TEST(watcher_baseline_dirty_repo) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -742,8 +807,14 @@ TEST(watcher_unwatch_prunes_state) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -784,8 +855,14 @@ TEST(watcher_watch_after_unwatch) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -841,9 +918,18 @@ TEST(watcher_detects_file_delete) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "todelete.go"), "todelete\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "todelete.go"), "todelete\n"); + } wt_git(tmpdir, "add -A"); wt_git(tmpdir, "commit -q -m init"); @@ -882,8 +968,14 @@ TEST(watcher_detects_subdir_file) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "hello\n"); + } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); @@ -948,8 +1040,14 @@ TEST(watcher_full_flow_new_file) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "package main\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "package main\n"); + } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); @@ -994,8 +1092,14 @@ TEST(watcher_fallback_still_detects) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "hello\n"); + } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); @@ -1009,7 +1113,10 @@ TEST(watcher_fallback_still_detects) { ASSERT_EQ(index_call_count, 0); /* Remove .git and re-init (simulates strategy reset) */ - { char p[300]; th_rmtree(wt_path(p, sizeof(p), tmpdir, ".git")); } + { + char p[300]; + th_rmtree(wt_path(p, sizeof(p), tmpdir, ".git")); + } wt_git(tmpdir, "init -q"); wt_git(tmpdir, "add -A"); wt_git(tmpdir, "commit -q -m reinit"); @@ -1049,13 +1156,27 @@ TEST(watcher_poll_only_watched_projects) { FAIL("cbm_mkdtemp failed"); /* Init both repos */ - if (wt_git(tmpdirA, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirA, "a.txt"), "a\n"); } + if (wt_git(tmpdirA, "init -q") != 0) { + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdirA, "a.txt"), "a\n"); + } wt_git(tmpdirA, "add a.txt"); wt_git(tmpdirA, "commit -q -m init"); - if (wt_git(tmpdirB, "init -q") != 0) { th_rmtree(tmpdirA); th_rmtree(tmpdirB); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdirB, "b.txt"), "b\n"); } + if (wt_git(tmpdirB, "init -q") != 0) { + th_rmtree(tmpdirA); + th_rmtree(tmpdirB); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdirB, "b.txt"), "b\n"); + } wt_git(tmpdirB, "add b.txt"); wt_git(tmpdirB, "commit -q -m init"); @@ -1104,8 +1225,14 @@ TEST(watcher_touch_resets_immediate) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); @@ -1150,8 +1277,14 @@ TEST(watcher_modify_tracked_file) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "package main\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "main.go"), "package main\n"); + } wt_git(tmpdir, "add main.go"); wt_git(tmpdir, "commit -q -m init"); @@ -1473,6 +1606,58 @@ TEST(watcher_callback_data_passed) { PASS(); } +TEST(watcher_unwatch_drains_pending_free) { + /* Unwatch moves project_state to pending_free; the next poll_once + * must drain it without crash or leak. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_df_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "df-repo", tmpdir); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make dirty + detect change */ + { + char p[300]; + th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "dirty\n"); + } + cbm_watcher_touch(w, "df-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* Unwatch — state moves to pending_free */ + cbm_watcher_unwatch(w, "df-repo"); + ASSERT_EQ(cbm_watcher_watch_count(w), 0); + + /* Next poll drains pending_free — no crash, no double-free */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + TEST(watcher_null_poll_once) { /* poll_once(NULL) → 0 */ int reindexed = cbm_watcher_poll_once(NULL); @@ -1561,6 +1746,7 @@ SUITE(watcher) { RUN_TEST(watcher_poll_non_git_dir); RUN_TEST(watcher_stop_prevents_run); RUN_TEST(watcher_watch_unwatch_rapid_cycle); + RUN_TEST(watcher_unwatch_drains_pending_free); RUN_TEST(watcher_callback_data_passed); RUN_TEST(watcher_null_poll_once); RUN_TEST(watcher_null_watch_count);