/* * test_watcher.c — Tests for the file change watcher module. * * Covers: adaptive interval, watch/unwatch lifecycle, git change detection, * poll_once behavior. */ #include "../src/foundation/compat.h" #include "../src/foundation/compat_thread.h" #include "../src/foundation/constants.h" #include "../src/foundation/log.h" #include "../src/foundation/platform.h" #include "test_framework.h" #include "test_helpers.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 #include "../src/foundation/win_utf8.h" #endif #ifdef __APPLE__ #include #endif /* Portable git: `git -C "" ` with identity + non-interactive * config injected via -c, so it needs no global config and no POSIX shell * (runs under cmd.exe on Windows). Returns the git exit status. */ static int wt_git(const char *dir, const char *args) { char cmd[1024]; snprintf(cmd, sizeof(cmd), "git -C \"%s\" -c user.name=t -c user.email=t@t.io " "-c init.defaultBranch=master -c commit.gpgsign=false %s", dir, args); return system(cmd); } /* Build "/" into buf (forward slashes work on Windows + git). */ static const char *wt_path(char *buf, size_t n, const char *dir, const char *rel) { snprintf(buf, n, "%s/%s", dir, rel); return buf; } /* ══════════════════════════════════════════════════════════════════ * ADAPTIVE INTERVAL * ══════════════════════════════════════════════════════════════════ */ TEST(poll_interval_base) { /* 0 files → 5s base */ int ms = cbm_watcher_poll_interval_ms(0); ASSERT_EQ(ms, 5000); PASS(); } TEST(poll_interval_scaling) { /* 1000 files → 5000 + 2*1000 = 7000ms */ int ms = cbm_watcher_poll_interval_ms(1000); ASSERT_EQ(ms, 7000); /* 5000 files → 5000 + 10*1000 = 15000ms */ ms = cbm_watcher_poll_interval_ms(5000); ASSERT_EQ(ms, 15000); PASS(); } TEST(poll_interval_cap) { /* 100K files → capped at 60s */ int ms = cbm_watcher_poll_interval_ms(100000); ASSERT_EQ(ms, 60000); PASS(); } TEST(poll_interval_small) { /* 499 files → 5000 + 0*1000 = 5000ms (integer division) */ int ms = cbm_watcher_poll_interval_ms(499); ASSERT_EQ(ms, 5000); /* 500 files → 5000 + 1*1000 = 6000ms */ ms = cbm_watcher_poll_interval_ms(500); ASSERT_EQ(ms, 6000); PASS(); } /* ══════════════════════════════════════════════════════════════════ * HARD INDEX-FAILURE BACKOFF * ══════════════════════════════════════════════════════════════════ */ TEST(index_backoff_no_failures_keeps_interval) { /* The success and busy-skip paths must be completely unaffected. */ ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 0), 5000); ASSERT_EQ(cbm_watcher_index_backoff_ms(60000, 0), 60000); PASS(); } TEST(index_backoff_doubles_per_failure) { ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 1), 10000); ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 2), 20000); ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 3), 40000); ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 4), 80000); PASS(); } TEST(index_backoff_reaches_ceiling_and_stays) { /* 5000 << 6 = 320000, above the 5-minute ceiling. The shift is capped * too, so an arbitrarily long failure streak cannot overflow the * intermediate value or wrap back to a short delay. */ ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 6), 300000); ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, 100), 300000); ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, INT_MAX), 300000); ASSERT_EQ(cbm_watcher_index_backoff_ms(60000, INT_MAX), 300000); PASS(); } TEST(index_backoff_is_monotonic_and_bounded) { /* The property that actually matters: the delay never decreases as the * streak grows, and never exceeds the ceiling. This is what bounds the * fork rate of a permanently failing project. */ int previous = cbm_watcher_index_backoff_ms(5000, 0); for (int failures = 1; failures < 200; failures++) { int delay = cbm_watcher_index_backoff_ms(5000, failures); ASSERT_TRUE(delay >= previous); ASSERT_TRUE(delay <= 300000); previous = delay; } PASS(); } TEST(index_backoff_degenerate_inputs) { ASSERT_EQ(cbm_watcher_index_backoff_ms(0, 5), 0); ASSERT_EQ(cbm_watcher_index_backoff_ms(-1, 0), 0); ASSERT_EQ(cbm_watcher_index_backoff_ms(5000, -1), 5000); PASS(); } TEST(index_backoff_never_schedules_sooner_than_the_interval) { /* An interval above the ceiling must not be SHORTENED by backing off: * the clamp is a floor as well as a cap. Not reachable while * POLL_MAX_MS < the ceiling, but the helper is exported, so its contract * has to hold for what a caller can pass, not only for what today's * constants produce. */ int over = 400000; /* > INDEX_FAIL_CEILING_MS */ ASSERT_EQ(cbm_watcher_index_backoff_ms(over, 0), over); for (int failures = 1; failures < 20; failures++) { ASSERT_TRUE(cbm_watcher_index_backoff_ms(over, failures) >= over); } PASS(); } /* ══════════════════════════════════════════════════════════════════ * LIFECYCLE * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_create_free) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_unwatch) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_watch(w, "project-a", "/tmp/project-a"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_watch(w, "project-b", "/tmp/project-b"); ASSERT_EQ(cbm_watcher_watch_count(w), 2); cbm_watcher_unwatch(w, "project-a"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_unwatch(w, "project-b"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_unwatch_nonexistent) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); /* Should not crash */ cbm_watcher_unwatch(w, "nonexistent"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_replace) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_watch(w, "project-a", "/tmp/old-path"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Replace with new path */ cbm_watcher_watch(w, "project-a", "/tmp/new-path"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* still 1 */ cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_stopped_rejects_new_registration) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); cbm_watcher_stop(w); ASSERT_FALSE(cbm_watcher_watch(w, "late-project", "/tmp/late-project")); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_null_safety) { /* All functions should be NULL-safe */ cbm_watcher_free(NULL); cbm_watcher_watch(NULL, "x", "/x"); cbm_watcher_unwatch(NULL, "x"); cbm_watcher_touch(NULL, "x"); ASSERT_EQ(cbm_watcher_watch_count(NULL), 0); ASSERT_EQ(cbm_watcher_poll_once(NULL), 0); PASS(); } /* ══════════════════════════════════════════════════════════════════ * POLL WITH REAL GIT REPO * ══════════════════════════════════════════════════════════════════ */ /* Index callback counter */ static int index_call_count = 0; static int index_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; (void)ud; index_call_count++; return 0; } TEST(watcher_poll_no_projects) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_poll_nonexistent_path) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "ghost", "/tmp/cbm_test_nonexistent_path_12345"); /* First poll → init_baseline (path doesn't exist → skip) */ index_call_count = 0; int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } /* ══════════════════════════════════════════════════════════════════ * STALE-ROOT PRUNING (#286) * ══════════════════════════════════════════════════════════════════ */ /* Shared fixture for the stale-root pruning tests: a temp project root, a * temp CBM_CACHE_DIR seeded with db/-wal/-shm files for "stale-project", * and saved copies of the env vars the tests override. */ typedef struct { char rootdir[256]; char cachedir[256]; char db_path[512]; char wal_path[512]; char shm_path[512]; char saved_cache_dir[1024]; bool had_cache_dir; char saved_grace[64]; bool had_grace; } prune_fixture_t; /* Returns false (with partial state cleaned up) if setup failed. */ static bool prune_fixture_setup(prune_fixture_t *f, const char *grace_s) { snprintf(f->rootdir, sizeof(f->rootdir), "/tmp/cbm_watcher_stale_root_XXXXXX"); if (!cbm_mkdtemp(f->rootdir)) { return false; } snprintf(f->cachedir, sizeof(f->cachedir), "/tmp/cbm_watcher_stale_cache_XXXXXX"); if (!cbm_mkdtemp(f->cachedir)) { th_rmtree(f->rootdir); return false; } f->had_cache_dir = cbm_safe_getenv("CBM_CACHE_DIR", f->saved_cache_dir, sizeof(f->saved_cache_dir), NULL) != NULL; f->had_grace = cbm_safe_getenv("CBM_WATCHER_PRUNE_GRACE_S", f->saved_grace, sizeof(f->saved_grace), NULL) != NULL; cbm_setenv("CBM_CACHE_DIR", f->cachedir, 1); cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", grace_s, 1); snprintf(f->db_path, sizeof(f->db_path), "%s/stale-project.db", f->cachedir); snprintf(f->wal_path, sizeof(f->wal_path), "%s/stale-project.db-wal", f->cachedir); snprintf(f->shm_path, sizeof(f->shm_path), "%s/stale-project.db-shm", f->cachedir); th_write_file(f->db_path, "db\n"); th_write_file(f->wal_path, "wal\n"); th_write_file(f->shm_path, "shm\n"); return true; } static void prune_fixture_teardown(prune_fixture_t *f) { if (f->had_cache_dir) { cbm_setenv("CBM_CACHE_DIR", f->saved_cache_dir, 1); } else { cbm_unsetenv("CBM_CACHE_DIR"); } if (f->had_grace) { cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", f->saved_grace, 1); } else { cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); } th_rmtree(f->rootdir); th_rmtree(f->cachedir); } typedef struct { const char *root_to_restore; bool allow; bool restore_root; int begins; int ends; int pruned; } prune_guard_probe_t; static bool prune_guard_probe_begin(void *context, const char *project) { (void)project; prune_guard_probe_t *probe = context; probe->begins++; if (probe->restore_root && probe->root_to_restore) { (void)cbm_mkdir_p(probe->root_to_restore, 0755); } return probe->allow; } static void prune_guard_probe_end(void *context, const char *project) { (void)project; prune_guard_probe_t *probe = context; probe->ends++; } static void prune_guard_probe_pruned(void *context, const char *project) { (void)project; prune_guard_probe_t *probe = context; probe->pruned++; } TEST(watcher_prunes_sustained_missing_root) { /* Positive prune path. Grace window 0s isolates the streak-threshold * logic; the time gate is guarded by watcher_grace_window_blocks_prune. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } 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", f.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(f.rootdir); /* Misses #1 and #2: below the streak threshold — keep project + DB. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); /* Miss #3 with the grace window already satisfied: prune the watch * entry and the 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(f.db_path, F_OK), 0); ASSERT_NEQ(access(f.wal_path, F_OK), 0); ASSERT_NEQ(access(f.shm_path, F_OK), 0); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); PASS(); } TEST(watcher_prune_waits_for_daemon_project_mutation) { /* The daemon application owns project-operation coordination. Supplying * its watcher must route destructive stale-root pruning through that * coordination boundary: a busy project is retained and retried after * the active operation releases its lease, never unlinked directly. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_daemon_application_config_t config = {.watcher = w}; cbm_daemon_application_t *application = cbm_daemon_application_new(&config); if (!store || !w || !application) { cbm_daemon_application_free(application); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); FAIL("coordinated prune fixture setup failed"); } cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); /* establish the existing-root baseline */ th_rmtree(f.rootdir); bool mutation_held = cbm_daemon_application_project_mutation_try_begin(application, "stale-project"); for (int miss = 0; mutation_held && miss < 3; miss++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } bool watch_preserved_while_busy = cbm_watcher_watch_count(w) == 1; bool files_preserved_while_busy = access(f.db_path, F_OK) == 0 && access(f.wal_path, F_OK) == 0 && access(f.shm_path, F_OK) == 0; if (mutation_held) { cbm_daemon_application_project_mutation_end(application, "stale-project"); } if (cbm_watcher_watch_count(w) == 1) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } bool pruned_after_release = cbm_watcher_watch_count(w) == 0 && access(f.db_path, F_OK) != 0 && access(f.wal_path, F_OK) != 0 && access(f.shm_path, F_OK) != 0; cbm_daemon_application_free(application); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); ASSERT_TRUE(mutation_held); ASSERT_TRUE(watch_preserved_while_busy); ASSERT_TRUE(files_preserved_while_busy); ASSERT_TRUE(pruned_after_release); PASS(); } TEST(watcher_prune_guard_denial_and_success_are_balanced) { prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); prune_guard_probe_t probe = {0}; cbm_watcher_set_project_mutation_guard(w, prune_guard_probe_begin, prune_guard_probe_end, prune_guard_probe_pruned, &probe); cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); th_rmtree(f.rootdir); for (int miss = 0; miss < 3; miss++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } bool denied_preserved = cbm_watcher_watch_count(w) == 1 && access(f.db_path, F_OK) == 0 && probe.begins == 1 && probe.ends == 0 && probe.pruned == 0; probe.allow = true; cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); bool success_balanced = cbm_watcher_watch_count(w) == 0 && access(f.db_path, F_OK) != 0 && probe.begins == 2 && probe.ends == 1 && probe.pruned == 1; cbm_watcher_set_project_mutation_guard(w, NULL, NULL, NULL, NULL); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); ASSERT_TRUE(denied_preserved); ASSERT_TRUE(success_balanced); PASS(); } TEST(watcher_prune_restats_root_after_guard_acquisition) { prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); prune_guard_probe_t probe = { .root_to_restore = f.rootdir, .allow = true, .restore_root = true, }; cbm_watcher_set_project_mutation_guard(w, prune_guard_probe_begin, prune_guard_probe_end, prune_guard_probe_pruned, &probe); cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); th_rmtree(f.rootdir); for (int miss = 0; miss < 3; miss++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } bool restored = access(f.rootdir, F_OK) == 0; bool retained = cbm_watcher_watch_count(w) == 1 && access(f.db_path, F_OK) == 0 && access(f.wal_path, F_OK) == 0 && access(f.shm_path, F_OK) == 0; bool balanced = probe.begins == 1 && probe.ends == 1 && probe.pruned == 0; cbm_watcher_set_project_mutation_guard(w, NULL, NULL, NULL, NULL); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); ASSERT_TRUE(restored); ASSERT_TRUE(retained); ASSERT_TRUE(balanced); PASS(); } TEST(watcher_prune_delete_failure_retains_watch_for_retry) { prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } 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", f.rootdir); cbm_watcher_poll_once(w); th_rmtree(f.rootdir); /* A directory at the main DB path makes unlink fail deterministically on * POSIX and Windows. The watcher must remain registered for a later retry * and must not delete recoverable sidecars after that failure. */ bool failure_ready = cbm_unlink(f.db_path) == 0 && cbm_mkdir_p(f.db_path, 0755); for (int miss = 0; failure_ready && miss < 3; miss++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } bool watch_retained = cbm_watcher_watch_count(w) == 1; bool artifacts_retained = access(f.db_path, F_OK) == 0 && access(f.wal_path, F_OK) == 0 && access(f.shm_path, F_OK) == 0; cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); ASSERT_TRUE(failure_ready); ASSERT_TRUE(watch_retained); ASSERT_TRUE(artifacts_retained); PASS(); } TEST(watcher_grace_window_blocks_prune) { /* 3+ missing polls but elapsed < grace → NOT pruned. Uses an explicit * 600s window so a fast poll burst can never satisfy the time gate. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "600")) { FAIL("prune fixture setup failed"); } 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", f.rootdir); cbm_watcher_poll_once(w); /* baseline */ th_rmtree(f.rootdir); /* 4 consecutive misses in quick succession: streak threshold reached, * but the sustained-absence window (600s) has not elapsed. */ for (int i = 0; i < 4; i++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); ASSERT_EQ(access(f.wal_path, F_OK), 0); ASSERT_EQ(access(f.shm_path, F_OK), 0); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); PASS(); } TEST(watcher_root_missing_errno_classification) { /* Only ENOENT/ENOTDIR may count toward pruning; EACCES-style failures * (permissions, I/O errors, transient mounts, macOS TCC revocation) * must never increment the missing streak. The classifier is unit- * tested with injected errno values because a real EACCES cannot be * simulated portably (tests may run as root on CI; Windows ACLs). */ ASSERT_TRUE(cbm_watcher_root_missing_errno(ENOENT)); ASSERT_TRUE(cbm_watcher_root_missing_errno(ENOTDIR)); ASSERT_FALSE(cbm_watcher_root_missing_errno(0)); ASSERT_FALSE(cbm_watcher_root_missing_errno(EACCES)); ASSERT_FALSE(cbm_watcher_root_missing_errno(EIO)); ASSERT_FALSE(cbm_watcher_root_missing_errno(EINVAL)); ASSERT_FALSE(cbm_watcher_root_missing_errno(ENAMETOOLONG)); PASS(); } TEST(watcher_root_restore_resets_prune_streak) { /* A reappearing root must reset the missing streak AND its first-miss * timestamp — pruning requires a fresh uninterrupted streak. */ prune_fixture_t f; if (!prune_fixture_setup(&f, "0")) { FAIL("prune fixture setup failed"); } 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", f.rootdir); cbm_watcher_poll_once(w); /* baseline */ th_rmtree(f.rootdir); /* Misses #1 and #2 — one short of the threshold. */ 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), 1); /* Root comes back (e.g. remount / re-clone): streak resets. */ if (!cbm_mkdir_p(f.rootdir, 0755)) { FAIL("mkdir_p restore failed"); } cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); th_rmtree(f.rootdir); /* Misses #1 and #2 of the NEW streak: must not prune even though the * total number of misses is now four. */ 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), 1); ASSERT_EQ(access(f.db_path, F_OK), 0); /* Miss #3 of the new streak → prune. */ cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); ASSERT_NEQ(access(f.db_path, F_OK), 0); cbm_watcher_free(w); cbm_store_close(store); prune_fixture_teardown(&f); 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(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); /* Watch our own repo root (we know it's a git repo) */ char cwd[4096]; if (!getcwd(cwd, sizeof(cwd))) { cbm_watcher_free(w); cbm_store_close(store); FAIL("getcwd failed"); } cbm_watcher_watch(w, "self", cwd); /* First poll: init baseline (no reindex expected) */ index_call_count = 0; int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); /* baseline only */ /* Second poll: check for changes. This repo has dirty working tree * (from the tests we just created), so it should detect changes. * But the adaptive interval hasn't elapsed yet, so it won't poll. */ /* Touch to reset interval, then poll */ cbm_watcher_touch(w, "self"); reindexed = cbm_watcher_poll_once(w); /* May or may not reindex depending on whether working tree is dirty. * In CI, working tree might be clean. Just verify it doesn't crash. */ cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_stop_flag) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); /* Set stop flag */ cbm_watcher_stop(w); /* Run should return immediately */ int rc = cbm_watcher_run(w, 1000); ASSERT_EQ(rc, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } /* test_main turns an exact private-path alias named git/git.exe into a * deterministic child that publishes its PID and ignores graceful shutdown. * This reproduces the production failure where popen() left watcher shutdown * blocked forever in pclose(). The test owns a verified forced-termination * backstop so the pre-fix RED cannot wedge the rest of the suite. */ #define WATCHER_TEST_BLOCKING_GIT_MARKER_ENV "CBM_TEST_RUNTIME_BLOCKING_GIT_PID_FILE" /* Budget for the blocked-git child to spawn and write its PID marker (watcher * poll + process spawn + first write). 5s was missed twice on starved 4-vCPU * windows-11-arm runners (release run 30300256783, both attempts) with no * failure on any faster leg — spawn latency under CPU starvation, not a * watcher defect. 20s keeps the wait bounded without re-arming that miss. */ #define WATCHER_TEST_SPAWN_MARKER_BUDGET_MS 20000 #ifdef _WIN32 typedef struct { cbm_watcher_t *watcher; atomic_bool completed; } watcher_windows_blocked_run_t; typedef struct { char canonical_path[CBM_SZ_4K]; BY_HANDLE_FILE_INFORMATION information; } watcher_windows_image_identity_t; static void *watcher_windows_blocked_run_thread(void *opaque) { watcher_windows_blocked_run_t *run = opaque; (void)cbm_watcher_run(run->watcher, 10); atomic_store_explicit(&run->completed, true, memory_order_release); return NULL; } static bool watcher_windows_copy_self(const char *destination) { wchar_t source[32768]; DWORD source_length = GetModuleFileNameW(NULL, source, (DWORD)(sizeof(source) / sizeof(source[0]))); wchar_t *destination_wide = cbm_utf8_to_wide(destination); bool copied = source_length > 0 && source_length < (DWORD)(sizeof(source) / sizeof(source[0])) && destination_wide && CopyFileW(source, destination_wide, TRUE) != 0; free(destination_wide); return copied; } static bool watcher_windows_capture_image_identity(const char *path, watcher_windows_image_identity_t *identity) { if (!path || !identity) { return false; } memset(identity, 0, sizeof(*identity)); if (!cbm_canonical_path(path, identity->canonical_path, sizeof(identity->canonical_path))) { return false; } wchar_t *wide = cbm_utf8_to_wide(identity->canonical_path); HANDLE file = wide ? CreateFileW(wide, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL) : INVALID_HANDLE_VALUE; bool captured = file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && GetFileInformationByHandle(file, &identity->information) != 0 && (identity->information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0; if (file != INVALID_HANDLE_VALUE) { (void)CloseHandle(file); } free(wide); if (!captured) { memset(identity, 0, sizeof(*identity)); } return captured; } static bool watcher_windows_same_image_identity(const watcher_windows_image_identity_t *first, const watcher_windows_image_identity_t *second) { return first && second && _stricmp(first->canonical_path, second->canonical_path) == 0 && first->information.dwVolumeSerialNumber == second->information.dwVolumeSerialNumber && first->information.nFileIndexHigh == second->information.nFileIndexHigh && first->information.nFileIndexLow == second->information.nFileIndexLow; } static bool watcher_windows_process_matches_image( HANDLE process, const watcher_windows_image_identity_t *expected) { if (!process || !expected) { return false; } DWORD exit_code = 0; wchar_t image[32768]; DWORD image_length = (DWORD)(sizeof(image) / sizeof(image[0])); bool queried = GetExitCodeProcess(process, &exit_code) != 0 && exit_code == STILL_ACTIVE && QueryFullProcessImageNameW(process, 0U, image, &image_length) != 0 && image_length > 0 && image_length < (DWORD)(sizeof(image) / sizeof(image[0])); if (queried) { image[image_length] = L'\0'; } char *image_utf8 = queried ? cbm_wide_to_utf8(image) : NULL; watcher_windows_image_identity_t actual; bool captured = image_utf8 && watcher_windows_capture_image_identity(image_utf8, &actual); free(image_utf8); return captured && watcher_windows_same_image_identity(&actual, expected); } static bool watcher_windows_wait_pid(const char *marker, uint64_t deadline_ms, uint64_t *process_id_out) { if (process_id_out) { *process_id_out = 0; } while (cbm_now_ms() < deadline_ms) { FILE *file = cbm_fopen(marker, "rb"); unsigned long long parsed = 0; bool valid = file && fscanf(file, "%llu", &parsed) == 1 && parsed > 1 && parsed <= MAXDWORD && parsed != (unsigned long long)GetCurrentProcessId(); if (file) { (void)fclose(file); } if (valid) { if (process_id_out) { *process_id_out = (uint64_t)parsed; } return true; } cbm_usleep(1000); } return false; } static HANDLE watcher_windows_open_exact_process(uint64_t process_id, const watcher_windows_image_identity_t *expected) { if (process_id <= 1 || process_id > MAXDWORD || process_id == (uint64_t)GetCurrentProcessId()) { return NULL; } HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | SYNCHRONIZE, FALSE, (DWORD)process_id); if (!watcher_windows_process_matches_image(process, expected)) { if (process) { (void)CloseHandle(process); } return NULL; } return process; } static bool watcher_windows_wait_complete(atomic_bool *completed, uint64_t deadline_ms) { while (cbm_now_ms() < deadline_ms) { if (atomic_load_explicit(completed, memory_order_acquire)) { return true; } cbm_usleep(1000); } return atomic_load_explicit(completed, memory_order_acquire); } static bool watcher_windows_wait_process_gone(HANDLE process, uint64_t deadline_ms) { while (cbm_now_ms() < deadline_ms) { if (WaitForSingleObject(process, 0U) == WAIT_OBJECT_0) { return true; } cbm_usleep(1000); } return WaitForSingleObject(process, 0U) == WAIT_OBJECT_0; } static bool watcher_windows_terminate_verified(HANDLE process, const watcher_windows_image_identity_t *expected, uint64_t deadline_ms) { if (!process) { return false; } if (WaitForSingleObject(process, 0U) == WAIT_OBJECT_0) { return true; } /* Re-query the live process image immediately before the only test-side * kill. The retained handle fixes the process instance; canonical path and * file identity ensure a cleanup backstop can target only our private copy. */ if (!watcher_windows_process_matches_image(process, expected) || TerminateProcess(process, 99U) == 0) { return false; } return watcher_windows_wait_process_gone(process, deadline_ms); } #endif #if defined(__APPLE__) || defined(__linux__) typedef struct { cbm_watcher_t *watcher; atomic_bool completed; } watcher_blocked_run_t; static void *watcher_blocked_run_thread(void *opaque) { watcher_blocked_run_t *run = opaque; (void)cbm_watcher_run(run->watcher, 10); atomic_store_explicit(&run->completed, true, memory_order_release); return NULL; } static bool watcher_test_self_image(char out[CBM_SZ_4K]) { #ifdef __APPLE__ int length = proc_pidpath(getpid(), out, CBM_SZ_4K); if (length <= 0 || length >= CBM_SZ_4K) { return false; } out[length] = '\0'; return true; #else ssize_t length = readlink("/proc/self/exe", out, CBM_SZ_4K - 1); if (length <= 0 || length >= CBM_SZ_4K - 1) { return false; } out[length] = '\0'; return true; #endif } static bool watcher_test_wait_pid(const char *marker, uint64_t deadline_ms, pid_t *pid_out) { while (cbm_now_ms() < deadline_ms) { FILE *file = cbm_fopen(marker, "rb"); long parsed = 0; bool valid = file && fscanf(file, "%ld", &parsed) == 1 && parsed > 1 && parsed != getpid(); if (file) { (void)fclose(file); } if (valid) { *pid_out = (pid_t)parsed; return true; } cbm_usleep(1000); } return false; } static bool watcher_test_wait_complete(atomic_bool *completed, uint64_t deadline_ms) { while (cbm_now_ms() < deadline_ms) { if (atomic_load_explicit(completed, memory_order_acquire)) { return true; } cbm_usleep(1000); } return atomic_load_explicit(completed, memory_order_acquire); } static bool watcher_test_wait_process_gone(pid_t process, uint64_t deadline_ms) { while (cbm_now_ms() < deadline_ms) { errno = 0; if (kill(process, 0) != 0 && errno == ESRCH) { return true; } cbm_usleep(1000); } errno = 0; return kill(process, 0) != 0 && errno == ESRCH; } #endif TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { #ifdef _WIN32 char work[CBM_SZ_1K] = "/tmp/cbm-watcher-blocked-git-XXXXXX"; ASSERT_NOT_NULL(cbm_mkdtemp(work)); char root[CBM_SZ_1K]; char bin[CBM_SZ_1K]; char fake_git[CBM_SZ_1K]; char marker[CBM_SZ_1K]; char unwatch_marker[CBM_SZ_1K]; ASSERT_TRUE(snprintf(root, sizeof(root), "%s/root", work) > 0); ASSERT_TRUE(snprintf(bin, sizeof(bin), "%s/bin", work) > 0); ASSERT_TRUE(snprintf(fake_git, sizeof(fake_git), "%s/git.exe", bin) > 0); ASSERT_TRUE(snprintf(marker, sizeof(marker), "%s/git.pid", work) > 0); ASSERT_TRUE(snprintf(unwatch_marker, sizeof(unwatch_marker), "%s/unwatch-git.pid", work) > 0); ASSERT_TRUE(cbm_mkdir_p(root, 0700)); ASSERT_TRUE(cbm_mkdir_p(bin, 0700)); ASSERT_TRUE(watcher_windows_copy_self(fake_git)); watcher_windows_image_identity_t expected_git; ASSERT_TRUE(watcher_windows_capture_image_identity(fake_git, &expected_git)); const char *old_path_raw = getenv("PATH"); const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? cbm_strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? cbm_strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); char *path = malloc(path_size); ASSERT_NOT_NULL(path); (void)snprintf(path, path_size, "%s;%s", bin, old_path ? old_path : ""); ASSERT_EQ(cbm_setenv("PATH", path, 1), 0); ASSERT_EQ(cbm_setenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV, marker, 1), 0); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(watcher); ASSERT_TRUE(cbm_watcher_watch(watcher, "blocked-git", root)); watcher_windows_blocked_run_t run = {.watcher = watcher}; atomic_init(&run.completed, false); cbm_thread_t thread; ASSERT_EQ(cbm_thread_create(&thread, 128U * 1024U, watcher_windows_blocked_run_thread, &run), 0); uint64_t child_id = 0; bool marker_ready = watcher_windows_wait_pid( marker, cbm_now_ms() + WATCHER_TEST_SPAWN_MARKER_BUDGET_MS, &child_id); HANDLE child_process = marker_ready ? watcher_windows_open_exact_process(child_id, &expected_git) : NULL; bool exact_stop_image = child_process != NULL; cbm_watcher_stop(watcher); uint64_t stop_deadline = cbm_now_ms() + 2500; bool stop_process_gone = exact_stop_image && watcher_windows_wait_process_gone(child_process, stop_deadline); bool stop_run_completed = watcher_windows_wait_complete(&run.completed, stop_deadline); bool stopped_without_backstop = stop_process_gone && stop_run_completed; bool stop_cleanup_terminated = true; if (exact_stop_image && !stop_process_gone) { stop_cleanup_terminated = watcher_windows_terminate_verified(child_process, &expected_git, cbm_now_ms() + 5000); } bool cleanup_process_gone = exact_stop_image && watcher_windows_wait_process_gone(child_process, cbm_now_ms() + 5000); bool cleanup_completed = watcher_windows_wait_complete(&run.completed, cbm_now_ms() + 5000); int join_rc = cleanup_completed ? cbm_thread_join(&thread) : -1; if (child_process) { (void)CloseHandle(child_process); } cbm_watcher_free(watcher); /* A project losing its final daemon-session owner must cancel that * project's in-flight Git tree even while the shared watcher stays live. */ ASSERT_EQ(cbm_setenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV, unwatch_marker, 1), 0); cbm_watcher_t *unwatch_watcher = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(unwatch_watcher); ASSERT_TRUE(cbm_watcher_watch(unwatch_watcher, "blocked-unwatch", root)); watcher_windows_blocked_run_t unwatch_run = {.watcher = unwatch_watcher}; atomic_init(&unwatch_run.completed, false); cbm_thread_t unwatch_thread; ASSERT_EQ(cbm_thread_create(&unwatch_thread, 128U * 1024U, watcher_windows_blocked_run_thread, &unwatch_run), 0); uint64_t unwatch_child_id = 0; bool unwatch_marker_ready = watcher_windows_wait_pid( unwatch_marker, cbm_now_ms() + WATCHER_TEST_SPAWN_MARKER_BUDGET_MS, &unwatch_child_id); HANDLE unwatch_process = unwatch_marker_ready ? watcher_windows_open_exact_process(unwatch_child_id, &expected_git) : NULL; bool exact_unwatch_image = unwatch_process != NULL; cbm_watcher_unwatch(unwatch_watcher, "blocked-unwatch"); bool unwatch_cancelled_without_backstop = exact_unwatch_image && watcher_windows_wait_process_gone(unwatch_process, cbm_now_ms() + 2500); bool unwatch_cleanup_terminated = true; if (exact_unwatch_image && !unwatch_cancelled_without_backstop) { unwatch_cleanup_terminated = watcher_windows_terminate_verified(unwatch_process, &expected_git, cbm_now_ms() + 5000); } bool unwatch_cleanup_gone = exact_unwatch_image && watcher_windows_wait_process_gone( unwatch_process, cbm_now_ms() + 5000); cbm_watcher_stop(unwatch_watcher); bool unwatch_run_completed = watcher_windows_wait_complete(&unwatch_run.completed, cbm_now_ms() + 5000); int unwatch_join_rc = unwatch_run_completed ? cbm_thread_join(&unwatch_thread) : -1; if (unwatch_process) { (void)CloseHandle(unwatch_process); } cbm_watcher_free(unwatch_watcher); cbm_store_close(store); if (old_path) { (void)cbm_setenv("PATH", old_path, 1); } else { (void)cbm_unsetenv("PATH"); } if (old_marker) { (void)cbm_setenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV, old_marker, 1); } else { (void)cbm_unsetenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); } free(path); free(old_marker); free(old_path); (void)th_rmtree(work); ASSERT_TRUE(marker_ready); ASSERT_TRUE(exact_stop_image); ASSERT_TRUE(stop_cleanup_terminated); ASSERT_TRUE(cleanup_process_gone); ASSERT_TRUE(cleanup_completed); ASSERT_EQ(join_rc, 0); ASSERT_TRUE(stopped_without_backstop); ASSERT_TRUE(unwatch_marker_ready); ASSERT_TRUE(exact_unwatch_image); ASSERT_TRUE(unwatch_cleanup_terminated); ASSERT_TRUE(unwatch_cancelled_without_backstop); ASSERT_TRUE(unwatch_cleanup_gone); ASSERT_TRUE(unwatch_run_completed); ASSERT_EQ(unwatch_join_rc, 0); PASS(); #elif defined(__APPLE__) || defined(__linux__) char work[CBM_SZ_1K] = "/tmp/cbm-watcher-blocked-git-XXXXXX"; ASSERT_NOT_NULL(cbm_mkdtemp(work)); char root[CBM_SZ_1K]; char bin[CBM_SZ_1K]; char fake_git[CBM_SZ_1K]; char marker[CBM_SZ_1K]; char unwatch_marker[CBM_SZ_1K]; char self[CBM_SZ_4K]; ASSERT_TRUE(snprintf(root, sizeof(root), "%s/root", work) > 0); ASSERT_TRUE(snprintf(bin, sizeof(bin), "%s/bin", work) > 0); ASSERT_TRUE(snprintf(fake_git, sizeof(fake_git), "%s/git", bin) > 0); ASSERT_TRUE(snprintf(marker, sizeof(marker), "%s/git.pid", work) > 0); ASSERT_TRUE(snprintf(unwatch_marker, sizeof(unwatch_marker), "%s/unwatch-git.pid", work) > 0); ASSERT_TRUE(cbm_mkdir_p(root, 0700)); ASSERT_TRUE(cbm_mkdir_p(bin, 0700)); ASSERT_TRUE(watcher_test_self_image(self)); ASSERT_EQ(symlink(self, fake_git), 0); const char *old_path_raw = getenv("PATH"); const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); char *path = malloc(path_size); ASSERT_NOT_NULL(path); (void)snprintf(path, path_size, "%s:%s", bin, old_path ? old_path : ""); ASSERT_EQ(cbm_setenv("PATH", path, 1), 0); ASSERT_EQ(cbm_setenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV, marker, 1), 0); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(watcher); cbm_watcher_watch(watcher, "blocked-git", root); watcher_blocked_run_t run = {.watcher = watcher}; atomic_init(&run.completed, false); cbm_thread_t thread; ASSERT_EQ(cbm_thread_create(&thread, 128U * 1024U, watcher_blocked_run_thread, &run), 0); pid_t child = 0; bool marker_ready = watcher_test_wait_pid(marker, cbm_now_ms() + WATCHER_TEST_SPAWN_MARKER_BUDGET_MS, &child); cbm_watcher_stop(watcher); bool stopped_without_backstop = marker_ready && watcher_test_wait_complete(&run.completed, cbm_now_ms() + 2500); if (marker_ready && !stopped_without_backstop) { (void)kill(child, SIGKILL); } bool cleanup_completed = watcher_test_wait_complete(&run.completed, cbm_now_ms() + 5000); int join_rc = cleanup_completed ? cbm_thread_join(&thread) : -1; cbm_watcher_free(watcher); /* A project losing its final daemon-session owner must cancel that * project's in-flight Git tree even while the shared watcher stays live. */ ASSERT_EQ(cbm_setenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV, unwatch_marker, 1), 0); cbm_watcher_t *unwatch_watcher = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(unwatch_watcher); ASSERT_TRUE(cbm_watcher_watch(unwatch_watcher, "blocked-unwatch", root)); watcher_blocked_run_t unwatch_run = {.watcher = unwatch_watcher}; atomic_init(&unwatch_run.completed, false); cbm_thread_t unwatch_thread; ASSERT_EQ( cbm_thread_create(&unwatch_thread, 128U * 1024U, watcher_blocked_run_thread, &unwatch_run), 0); pid_t unwatch_child = 0; bool unwatch_marker_ready = watcher_test_wait_pid( unwatch_marker, cbm_now_ms() + WATCHER_TEST_SPAWN_MARKER_BUDGET_MS, &unwatch_child); cbm_watcher_unwatch(unwatch_watcher, "blocked-unwatch"); bool unwatch_cancelled_without_backstop = unwatch_marker_ready && watcher_test_wait_process_gone(unwatch_child, cbm_now_ms() + 2500); if (unwatch_marker_ready && !unwatch_cancelled_without_backstop) { (void)kill(unwatch_child, SIGKILL); } bool unwatch_cleanup_gone = unwatch_marker_ready && watcher_test_wait_process_gone(unwatch_child, cbm_now_ms() + 5000); cbm_watcher_stop(unwatch_watcher); bool unwatch_run_completed = watcher_test_wait_complete(&unwatch_run.completed, cbm_now_ms() + 5000); int unwatch_join_rc = unwatch_run_completed ? cbm_thread_join(&unwatch_thread) : -1; cbm_watcher_free(unwatch_watcher); cbm_store_close(store); if (old_path) { (void)cbm_setenv("PATH", old_path, 1); } else { (void)cbm_unsetenv("PATH"); } if (old_marker) { (void)cbm_setenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV, old_marker, 1); } else { (void)cbm_unsetenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); } free(path); free(old_marker); free(old_path); (void)th_rmtree(work); ASSERT_TRUE(marker_ready); ASSERT_TRUE(cleanup_completed); ASSERT_EQ(join_rc, 0); ASSERT_TRUE(stopped_without_backstop); ASSERT_TRUE(unwatch_marker_ready); ASSERT_TRUE(unwatch_cancelled_without_backstop); ASSERT_TRUE(unwatch_cleanup_gone); ASSERT_TRUE(unwatch_run_completed); ASSERT_EQ(unwatch_join_rc, 0); PASS(); #else SKIP_PLATFORM("copied test-runner git probe is supported on Windows/macOS/Linux"); #endif } /* ══════════════════════════════════════════════════════════════════ * GIT CHANGE DETECTION (with temp repo) * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_detects_git_commit) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_test_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, "temp-repo", tmpdir); index_call_count = 0; /* First poll: baseline */ cbm_watcher_poll_once(w); 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"); } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m add-world"); /* Touch to bypass interval, then poll */ cbm_watcher_touch(w, "temp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* should detect HEAD change */ /* Poll again without changes → no reindex */ cbm_watcher_touch(w, "temp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* still 1, no new changes */ /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* A plain directory that merely SITS UNDER an unrelated repository is not a git * project. `git rev-parse --git-dir` walks up, so it answers yes for such a * folder, and the watcher then inherited the ancestor's dirty state — which is * permanently non-empty and has nothing to do with this directory — and * reindexed on every single poll forever (#841/#937: reporters measured this in * hundreds of GB of writes per day). */ TEST(watcher_nested_non_git_dir_does_not_inherit_ancestor_dirt) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nested_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, "tracked.txt"), "hello\n"); } wt_git(tmpdir, "add tracked.txt"); wt_git(tmpdir, "commit -q -m init"); /* Leave the ancestor permanently dirty — this is the condition that used to * retrigger indexing on every poll of the nested directory. */ { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "dirty.txt"), "uncommitted\n"); } /* A scratch directory inside it, tracked by nothing. */ char nested[400]; snprintf(nested, sizeof(nested), "%s/scratch", tmpdir); if (!cbm_mkdir_p(nested, 0755)) { th_rmtree(tmpdir); FAIL("mkdir nested failed"); } { char p[500]; th_write_file(wt_path(p, sizeof(p), nested, "notes.md"), "scratch\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "nested-scratch", nested); index_call_count = 0; cbm_watcher_poll_once(w); /* baseline */ int after_baseline = index_call_count; /* Three polls with the ancestor still dirty and the nested dir untouched. * Under the old classification each of these reindexed. */ for (int i = 0; i < 3; i++) { cbm_watcher_touch(w, "nested-scratch"); cbm_watcher_poll_once(w); } ASSERT_EQ(index_call_count, after_baseline); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* A genuine sub-package of a monorepo IS git-managed, and must still be watched * as such — the nested-directory guard above must not disqualify it. It also * must not react to a sibling package's changes: `git status` reports the whole * repository regardless of -C, so without a `-- .` pathspec every package in a * monorepo reindexes whenever any other one is edited. */ TEST(watcher_monorepo_subdir_ignores_sibling_changes) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mono_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } char pkg_a[400]; char pkg_b[400]; snprintf(pkg_a, sizeof(pkg_a), "%s/pkg-a", tmpdir); snprintf(pkg_b, sizeof(pkg_b), "%s/pkg-b", tmpdir); if (!cbm_mkdir_p(pkg_a, 0755) || !cbm_mkdir_p(pkg_b, 0755)) { th_rmtree(tmpdir); FAIL("mkdir packages failed"); } { char p[500]; th_write_file(wt_path(p, sizeof(p), pkg_a, "a.txt"), "a\n"); th_write_file(wt_path(p, sizeof(p), pkg_b, "b.txt"), "b\n"); } wt_git(tmpdir, "add -A"); 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, "pkg-a", pkg_a); index_call_count = 0; cbm_watcher_poll_once(w); /* baseline */ int after_baseline = index_call_count; /* Edit the SIBLING package only. pkg-a is untouched. */ { char p[500]; th_append_file(wt_path(p, sizeof(p), pkg_b, "b.txt"), "sibling edit\n"); } cbm_watcher_touch(w, "pkg-a"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, after_baseline); /* Editing pkg-a itself must still be seen — the scoping must not have * silenced real changes. */ { char p[500]; th_append_file(wt_path(p, sizeof(p), pkg_a, "a.txt"), "own edit\n"); } cbm_watcher_touch(w, "pkg-a"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, after_baseline + 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* SHA-256 repositories emit a 64-hex-character HEAD. The watcher must retain * the complete object ID (plus line terminator/NUL while reading it), otherwise * baseline initialization silently retries forever and auto-refresh never runs. */ TEST(watcher_detects_sha256_git_commit) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sha256_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) { FAIL("cbm_mkdtemp failed"); } if (wt_git(tmpdir, "init -q --object-format=sha256") != 0) { th_rmtree(tmpdir); SKIP_PLATFORM("installed Git does not support SHA-256 repositories"); } char path[300]; th_write_file(wt_path(path, sizeof(path), tmpdir, "file.txt"), "first\n"); ASSERT_EQ(wt_git(tmpdir, "add file.txt"), 0); ASSERT_EQ(wt_git(tmpdir, "commit -q -m first"), 0); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *watcher = cbm_watcher_new(store, index_callback, NULL); ASSERT_NOT_NULL(watcher); ASSERT_TRUE(cbm_watcher_watch(watcher, "sha256-repo", tmpdir)); index_call_count = 0; ASSERT_EQ(cbm_watcher_poll_once(watcher), 0); /* establish baseline */ th_append_file(path, "second\n"); ASSERT_EQ(wt_git(tmpdir, "add file.txt"), 0); ASSERT_EQ(wt_git(tmpdir, "commit -q -m second"), 0); cbm_watcher_touch(watcher, "sha256-repo"); (void)cbm_watcher_poll_once(watcher); int observed = index_call_count; cbm_watcher_free(watcher); cbm_store_close(store); th_rmtree(tmpdir); ASSERT_EQ(observed, 1); PASS(); } TEST(watcher_detects_dirty_worktree) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_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, "dirty-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); /* Make working tree dirty (uncommitted change) */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified\n"); } /* Poll → should detect dirty worktree */ cbm_watcher_touch(w, "dirty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_identical_watch_preserves_dirty_baseline) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_same_root_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, "same-root-repo", tmpdir); index_call_count = 0; /* Establish the clean baseline before making the worktree dirty. */ ASSERT_EQ(cbm_watcher_poll_once(w), 0); ASSERT_EQ(index_call_count, 0); { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "modified\n"); } cbm_watcher_touch(w, "same-root-repo"); /* An identical registration must preserve the established baseline. */ cbm_watcher_watch(w, "same-root-repo", tmpdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ASSERT_EQ(cbm_watcher_poll_once(w), 1); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_detects_new_file) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_newf_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, "newf-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Add a new untracked file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/newfile.go", tmpdir); th_write_file(_p, "new content\n"); } /* Touch to bypass interval, then poll */ cbm_watcher_touch(w, "newf-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* should detect untracked file */ /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_no_change_no_reindex) { /* Create a temporary git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nochg_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, "nochg-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Poll multiple times with no changes — never triggers reindex */ for (int i = 0; i < 5; i++) { cbm_watcher_touch(w, "nochg-repo"); cbm_watcher_poll_once(w); } ASSERT_EQ(index_call_count, 0); /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* #1953: the reindex's OWN OUTPUT must never count as a change. After every * publish the pipeline re-exports /.codebase-memory/graph.db.zst (plus * artifact.json) whenever an artifact already lives there — a `persistence: * true` index leaves one behind, and a linked worktree checks the team's * committed one out. The dirty signature folded that file's (size, mtime) * in, so each successful reindex rewrote it, the next poll saw a "new" dirty * state, and the daemon re-triggered itself forever: `index.supervisor.reap * outcome=clean` immediately followed by `watcher.changed strategy=git`, 100+ * times in 15 minutes with two index workers pinned. Nothing under .git moved * and no source changed, which is why the reporters blamed their long * hyphenated worktree branch names (#1254's retracted theory). The scenario is * built exactly that way to show the branch is irrelevant: the artifact write * is the trigger. Untracked (`??`) and committed (` M`) artifacts both looped. */ static const char *exporting_index_db_path = NULL; static int exporting_index_export_rc = 0; static int exporting_index_callback(const char *name, const char *path, void *ud) { (void)ud; index_call_count++; /* What the daemon's index worker does after publish: export_after_publish * re-exports FAST because cbm_artifact_exists(root). */ int rc = cbm_artifact_export(exporting_index_db_path, path, name, CBM_ARTIFACT_FAST); if (rc != 0) { exporting_index_export_rc = rc; } return 0; } TEST(watcher_own_artifact_export_does_not_retrigger_issue1953) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_1953_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); char main_repo[400]; char worktree[400]; char db_path[400]; snprintf(main_repo, sizeof(main_repo), "%s/main", tmpdir); snprintf(worktree, sizeof(worktree), "%s/4385-auditable-patreon-manual-grants", tmpdir); snprintf(db_path, sizeof(db_path), "%s/graph.db", tmpdir); if (!cbm_mkdir_p(main_repo, 0755) || wt_git(main_repo, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } { char p[500]; th_write_file(wt_path(p, sizeof(p), main_repo, "file.txt"), "hello\n"); } wt_git(main_repo, "add file.txt"); wt_git(main_repo, "commit -q -m init"); /* Linked worktree on the reporters' branch profile (>= 25 chars, hyphens). */ { char args[600]; snprintf(args, sizeof(args), "worktree add -q -b 4385-auditable-patreon-manual-grants \"%s\"", worktree); if (wt_git(main_repo, args) != 0) { th_rmtree(tmpdir); FAIL("git worktree add failed"); } } /* A minimal but valid store standing in for the project's cache DB. */ { cbm_store_t *db = cbm_store_open_path(db_path); if (!db) { th_rmtree(tmpdir); FAIL("cbm_store_open_path failed"); } cbm_store_exec(db, "INSERT OR IGNORE INTO projects(name, indexed_at, root_path) " "VALUES('wt-1953', '2026-01-01', '/tmp/wt-1953');"); cbm_store_close(db); } /* The artifact is already there — as after any persisted index. */ ASSERT_EQ(cbm_artifact_export(db_path, worktree, "wt-1953", CBM_ARTIFACT_FAST), 0); ASSERT_TRUE(cbm_artifact_exists(worktree)); cbm_store_t *store = cbm_store_open_memory(); exporting_index_db_path = db_path; exporting_index_export_rc = 0; cbm_watcher_t *w = cbm_watcher_new(store, exporting_index_callback, NULL); cbm_watcher_watch(w, "wt-1953", worktree); index_call_count = 0; cbm_watcher_poll_once(w); /* baseline */ ASSERT_EQ(index_call_count, 0); /* Idle worktree: nothing but the tool's own artifact directory differs * from HEAD. Every poll used to reindex — and re-export, feeding the next. */ for (int i = 0; i < 4; i++) { cbm_watcher_touch(w, "wt-1953"); cbm_watcher_poll_once(w); } ASSERT_EQ(exporting_index_export_rc, 0); ASSERT_EQ(index_call_count, 0); /* Committed artifact (team sharing): the export now MODIFIES tracked files * instead of leaving untracked ones. The commit is a real HEAD change and * reindexes once; the re-export that reindex performs must not. */ wt_git(worktree, "add -A"); wt_git(worktree, "commit -q -m share-artifact"); cbm_watcher_touch(w, "wt-1953"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); for (int i = 0; i < 4; i++) { cbm_watcher_touch(w, "wt-1953"); cbm_watcher_poll_once(w); } ASSERT_EQ(exporting_index_export_rc, 0); ASSERT_EQ(index_call_count, 1); /* A real edit in the worktree is still seen. */ { char p[500]; th_append_file(wt_path(p, sizeof(p), worktree, "file.txt"), "edit\n"); } cbm_watcher_touch(w, "wt-1953"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); cbm_watcher_free(w); cbm_store_close(store); exporting_index_db_path = NULL; th_rmtree(tmpdir); PASS(); } /* #937: a PERSISTENTLY dirty worktree must reindex ONCE per distinct dirty * state, not on every poll. The watcher used to treat "tree is dirty" as * "tree changed", so an idle repo with one uncommitted file re-triggered a * full reindex (and its DB/artifact rewrite) every poll cycle — the reported * 1 TB/day write amplification. A dirty-state signature (porcelain entries + * per-file size/mtime) must gate the trigger: same signature → no reindex; * editing a dirty file again, or reverting the tree to clean, are NEW states * that must each trigger exactly one reindex. */ TEST(watcher_dirty_state_reindexes_once_issue937) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_amp_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, "amp-repo", tmpdir); index_call_count = 0; /* Baseline (clean tree) */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Dirty the tree once (uncommitted modification). */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified\n"); } cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* new dirty state → one reindex */ /* Idle polls on the SAME dirty state must not re-trigger. */ for (int i = 0; i < 3; i++) { cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); } ASSERT_EQ(index_call_count, 1); /* was 4 before the fix: one per poll */ /* Editing the dirty file AGAIN is a new state (size changes). */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified again\n"); } cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); /* Same-state polls stay quiet again. */ cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); /* Reverting to a clean tree changes on-disk content (back to HEAD) — * that is a new state and must reindex exactly once. */ wt_git(tmpdir, "checkout -- file.txt"); cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 3); /* Stable clean tree: quiet. */ cbm_watcher_touch(w, "amp-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 3); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* #937 companion: a change whose reindex FAILS (or is skipped busy) must be * retried on the next poll. The watcher used to commit the new HEAD at CHECK * time, so a commit observed while the pipeline was busy/failing was recorded * as seen and never indexed — a silent lost update. Baselines (HEAD and dirty * signature) may only be committed after a SUCCESSFUL reindex. */ static int failing_index_calls = 0; static int failing_index_fail_first_n = 0; static int failing_index_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; (void)ud; failing_index_calls++; if (failing_index_calls <= failing_index_fail_first_n) { return -1; /* simulated pipeline failure */ } return 0; } TEST(watcher_failed_reindex_retries_issue937) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rty_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, failing_index_callback, NULL); cbm_watcher_watch(w, "rty-repo", tmpdir); failing_index_calls = 0; failing_index_fail_first_n = 1; /* first reindex attempt fails */ /* Baseline (clean tree) */ cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 0); /* HEAD moves (new commit). */ { 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"); /* First poll: change detected, reindex attempt FAILS. */ cbm_watcher_touch(w, "rty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 1); /* The failed change must NOT have been recorded as seen: the next poll * retries and succeeds. Before the fix, the new HEAD was stored at check * time and the commit was silently lost (calls stayed at 1). */ cbm_watcher_touch(w, "rty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 2); /* Successful reindex commits the baseline: no further triggers. */ cbm_watcher_touch(w, "rty-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 2); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* The failure state machine the backoff feeds (#2015): the streak must count * consecutive HARD failures and reset the moment one reindex succeeds. The * delay it computes is covered by the index_backoff_* unit tests; what is * covered here is the counter that selects which delay applies, because a * streak that never increments — or never resets — silently reverts the * backoff to the unbounded retry it exists to prevent, with every other test * still green. Each poll is preceded by cbm_watcher_touch, which zeroes * next_poll_ns, so this exercises the counter without depending on a clock. */ TEST(watcher_index_failure_streak_increments_and_resets_issue2015) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_strk_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, failing_index_callback, NULL); cbm_watcher_watch(w, "strk-repo", tmpdir); failing_index_calls = 0; failing_index_fail_first_n = 2; /* first TWO reindex attempts fail hard */ /* Baseline (clean tree): no reindex, so no failures yet. */ cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 0); ASSERT_EQ(cbm_watcher_index_failure_count(w, "strk-repo"), 0); /* HEAD moves. */ { 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"); /* First hard failure → streak 1. */ cbm_watcher_touch(w, "strk-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 1); ASSERT_EQ(cbm_watcher_index_failure_count(w, "strk-repo"), 1); /* Second consecutive hard failure → streak 2 (it accumulates; it is not * a boolean, which is what makes the delay grow). */ cbm_watcher_touch(w, "strk-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 2); ASSERT_EQ(cbm_watcher_index_failure_count(w, "strk-repo"), 2); /* Third attempt succeeds → streak resets, so a project that recovers * returns to its normal cadence instead of staying backed off. */ cbm_watcher_touch(w, "strk-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 3); ASSERT_EQ(cbm_watcher_index_failure_count(w, "strk-repo"), 0); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* The delay WIRE-UP (#2015): a hard failure must leave SOME deadline behind, * so the failing project is not re-polled on the very next cycle. Observable * without a clock by simply not calling cbm_watcher_touch between polls — * touch zeroes next_poll_ns, so a wire-up that never assigns one leaves it at * 0 and the callback fires on every poll. * * Be precise about what this does NOT prove, because it is less than it looks. * The code before this change also assigned a deadline here (the plain * adaptive interval), so this test passes against the unfixed tree: it is a * regression guard on the assignment existing, NOT a demonstration of the * backoff. * * Measured, not assumed: replacing the backoff call with the pre-change * `ctx->now + interval_ms * US_PER_MS` leaves this whole suite green at 82/82. * No test in this file fails when the behavioural change is removed — the * index_backoff_* tests still pass because they exercise the pure function * directly, and the streak tests still pass because the counter is unaffected. * What distinguishes backed-off from plain cadence is the delay's MAGNITUDE, * and observing that needs a controllable clock the watcher does not have, or * a further accessor exposing next_poll_ns. Both were judged out of scope; the * gap is recorded here rather than papered over. */ TEST(watcher_index_failure_backoff_gates_repolling_issue2015) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_gate_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, failing_index_callback, NULL); cbm_watcher_watch(w, "gate-repo", tmpdir); failing_index_calls = 0; failing_index_fail_first_n = 100; /* never succeeds */ cbm_watcher_poll_once(w); /* baseline */ { 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"); /* One failure, forced by touch. */ cbm_watcher_touch(w, "gate-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 1); ASSERT_EQ(cbm_watcher_index_failure_count(w, "gate-repo"), 1); /* Now poll repeatedly WITHOUT touching. The change is still pending (the * baseline stays uncommitted per #937), so the ONLY thing that can stop a * re-fork is the deadline the failure just scheduled. Before the backoff * wire-up existed this project was re-forked at the poll cadence; with no * deadline set at all it would fire on every one of these iterations. */ for (int i = 0; i < 5; i++) { cbm_watcher_poll_once(w); } ASSERT_EQ(failing_index_calls, 1); ASSERT_EQ(cbm_watcher_index_failure_count(w, "gate-repo"), 1); /* Control: the watcher is not simply dead — clearing the deadline lets the * retry through, which is what keeps #937's at-least-once guarantee. */ cbm_watcher_touch(w, "gate-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(failing_index_calls, 2); ASSERT_EQ(cbm_watcher_index_failure_count(w, "gate-repo"), 2); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* The sustained-failure line must fire EXACTLY once, at the threshold — the * other single-token mutation round 1 named (`==` widened to `>=`) would emit * it on every failure from the threshold onward and turn a one-shot signal * into the log spam the whole change is meant to avoid. */ static int sustained_log_hits = 0; static void sustained_log_sink(const char *line) { if (line && strstr(line, "watcher.index.sustained_failure") != NULL) { sustained_log_hits++; } } TEST(watcher_sustained_failure_logs_once_issue2015) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sust_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, failing_index_callback, NULL); cbm_watcher_watch(w, "sust-repo", tmpdir); failing_index_calls = 0; failing_index_fail_first_n = 100; /* never succeeds */ /* Baseline poll. Its outcome is not asserted: if a git probe fails here, * the first loop poll takes the baseline instead. */ cbm_watcher_poll_once(w); /* The change is a DIRTY worktree, not a commit. The baseline leaves the * dirty signature at "clean known", so this change is seen whenever the * baseline lands. A commit made before a late (or HEAD-less) baseline * would be adopted as the baseline HEAD and never reindexed. */ { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "world\n"); } sustained_log_hits = 0; cbm_log_set_sink(sustained_log_sink); /* Drive well past the threshold (10): poll until the 14th failed reindex. * A poll does not always reach the index callback: when one of its git * probes fails transiently (spawn refused under runner load) it is a * fail-closed no-op by design (#937) and the next poll retries. So the * loop waits for the attempt count instead of assuming one per poll; * touch clears the backoff deadline each time. Each poll runs at most one * attempt for this single project, so the loop stops at exactly 14. A * watcher that never reindexes hangs here, which the runner reports as a * failure, never a pass. No-op polls cannot add log lines to the count: * sustained_failure is emitted only on a failed index attempt, when the * streak EQUALS the threshold. */ while (failing_index_calls < 14) { cbm_watcher_touch(w, "sust-repo"); cbm_watcher_poll_once(w); } cbm_log_set_sink(NULL); ASSERT_EQ(failing_index_calls, 14); ASSERT_EQ(cbm_watcher_index_failure_count(w, "sust-repo"), 14); /* Exactly one, not four (11..14) and not zero. */ ASSERT_EQ(sustained_log_hits, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_index_failure_count_unknown_project) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); /* Unwatched and NULL inputs report -1, distinct from a watched project * sitting at a legitimate 0. */ ASSERT_EQ(cbm_watcher_index_failure_count(w, "never-watched"), -1); ASSERT_EQ(cbm_watcher_index_failure_count(w, NULL), -1); ASSERT_EQ(cbm_watcher_index_failure_count(NULL, "x"), -1); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_multiple_projects) { /* Create two temporary git repos */ char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_mA_XXXXXX"); char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_mB_XXXXXX"); 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"); } 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"); } wt_git(tmpdirB, "add b.txt"); wt_git(tmpdirB, "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, "projA", tmpdirA); cbm_watcher_watch(w, "projB", tmpdirB); ASSERT_EQ(cbm_watcher_watch_count(w), 2); index_call_count = 0; /* Baseline both */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Modify only A */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/a.txt", tmpdirA); th_append_file(_p, "modified\n"); } /* Poll — only A should trigger */ cbm_watcher_touch(w, "projA"); cbm_watcher_touch(w, "projB"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* only A changed */ /* Cleanup */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdirA); th_rmtree(tmpdirB); PASS(); } /* ══════════════════════════════════════════════════════════════════ * NON-GIT PROJECT * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_non_git_skips) { /* Non-git dir → baseline sets is_git=false → poll never reindexes. * Port of TestProbeStrategyNonGit behavior. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nongit_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); /* Create a file so it's not empty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_write_file(_p, "hello\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "nongit", tmpdir); index_call_count = 0; /* Baseline — should detect non-git and set is_git=false */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Modify file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "modified\n"); } /* Touch + poll — should NOT trigger (non-git projects are skipped) */ cbm_watcher_touch(w, "nongit"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Even add a new file — still no reindex */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/new.txt", tmpdir); th_write_file(_p, "new\n"); } cbm_watcher_touch(w, "nongit"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* ══════════════════════════════════════════════════════════════════ * ADAPTIVE INTERVAL BEHAVIOR * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_interval_blocks_repoll) { /* After baseline, the adaptive interval (5s minimum) should block * immediate re-polling. Without touch(), the next poll is a no-op. * Port of TestWatcherGitNoChanges' interval behavior. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_intv_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, "intv-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make repo dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* Poll WITHOUT touch — interval should block checking */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* blocked by interval */ /* Now touch to bypass interval */ cbm_watcher_touch(w, "intv-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* now detected */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_poll_interval_full_table) { /* Full table-driven test matching Go TestPollInterval exactly */ struct { int files; int expected_ms; } tests[] = { {0, 5000}, {70, 5000}, {499, 5000}, {500, 6000}, {2000, 9000}, {5000, 15000}, {10000, 25000}, {50000, 60000}, {100000, 60000}, }; int n = (int)(sizeof(tests) / sizeof(tests[0])); for (int i = 0; i < n; i++) { int got = cbm_watcher_poll_interval_ms(tests[i].files); if (got != tests[i].expected_ms) { fprintf(stderr, "FAIL pollInterval(%d) = %d, want %d\n", tests[i].files, got, tests[i].expected_ms); return 1; } } PASS(); } /* ══════════════════════════════════════════════════════════════════ * GIT REMOVAL + CONTINUED DIRTY + BASELINE DIRTY * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_git_removed_no_crash) { /* Init git repo, baseline, remove .git, poll → should not crash. * Port of TestStrategyDowngradeGitToDirMtime behavior (C version * doesn't downgrade — just git commands fail silently). */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rmgit_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, "rmgit-repo", tmpdir); index_call_count = 0; /* Baseline — detects git */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Remove .git — git commands will fail */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/.git", tmpdir); th_rmtree(_p); } /* Poll — should not crash, git_head() and git_is_dirty() fail gracefully */ cbm_watcher_touch(w, "rmgit-repo"); cbm_watcher_poll_once(w); /* No assertion on index_call_count — behavior is implementation-defined. * Main assertion: no crash, no ASan violation. */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_continued_dirty) { /* A tree that STAYS dirty re-triggers only when the dirty state itself * changes (#937): repeat polls over the untouched state are quiet, a * further edit re-triggers, and the cleaning commit triggers once more * (HEAD move + tree back to clean). Historically this test asserted one * reindex per poll while dirty — that WAS the #937 write amplification. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cont_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, "cont-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* First detection */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* Still dirty but UNCHANGED — must stay quiet (#937) */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* A further edit is a NEW dirty state — detect again */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirtier\n"); } cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 2); /* Commit to clean up, then poll — should not trigger */ wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m clean"); /* HEAD changed → will trigger one more reindex */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); /* HEAD change from commit → reindex again (count = 3) */ /* Now truly clean — no more reindexes */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); int final_count = index_call_count; /* Touch and poll one more time to verify stability */ cbm_watcher_touch(w, "cont-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, final_count); /* stable */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_baseline_dirty_repo) { /* Baseline on a repo that already has uncommitted changes. * Port of TestGitSentinelDetectsEdit (dirty from the start). */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_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"); /* Make dirty BEFORE baseline */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty from start\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "bld-repo", tmpdir); index_call_count = 0; /* Baseline — captures HEAD but doesn't check for dirty */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ /* First real poll — should detect the pre-existing dirty state */ cbm_watcher_touch(w, "bld-repo"); 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_unwatch_prunes_state) { /* Watch, baseline, unwatch → project state removed. * Port of TestPollAllPrunesUnwatched + TestWatcherPrunesDeletedProjects. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_prune_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, "prune-repo", tmpdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Unwatch — should remove project state immediately */ cbm_watcher_unwatch(w, "prune-repo"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); /* Make dirty + poll — nothing should happen */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* no projects to poll */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_watch_after_unwatch) { /* Re-watching after unwatch should start fresh (new baseline). * Tests lifecycle correctness. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rewatch_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); /* Watch → baseline → unwatch */ cbm_watcher_watch(w, "rewatch-repo", tmpdir); cbm_watcher_poll_once(w); /* baseline */ cbm_watcher_unwatch(w, "rewatch-repo"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); /* Make dirty while unwatched */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* Re-watch — needs fresh baseline */ cbm_watcher_watch(w, "rewatch-repo", tmpdir); ASSERT_EQ(cbm_watcher_watch_count(w), 1); index_call_count = 0; /* Baseline again (first poll after re-watch) */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* baseline never triggers */ /* Second poll — detects dirty */ cbm_watcher_touch(w, "rewatch-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* ══════════════════════════════════════════════════════════════════ * FSNOTIFY PORTS (adapted for git-based change detection) * * The Go watcher has fsnotify/dir-mtime strategies alongside git. * The C watcher is git-only. These tests verify the same SEMANTIC * behaviors (file create, delete, subdir, cleanup) through git. * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_detects_file_delete) { /* Port of TestFSNotifyDetectsFileDelete: * Delete a tracked file → git status shows change → reindex triggered. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_del_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"); } { 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"); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "del-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Delete tracked file → dirty worktree */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/todelete.go", tmpdir); cbm_unlink(_p); } /* Touch + poll → should detect deletion */ cbm_watcher_touch(w, "del-repo"); 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_detects_subdir_file) { /* Port of TestFSNotifyWatchesNewSubdir: * Create new subdir + file in it → git detects untracked → reindex. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sub_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, "main.go"), "hello\n"); } wt_git(tmpdir, "add main.go"); 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, "sub-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Create new subdir and file in it */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/pkg/lib.go", tmpdir); th_write_file(_p, "package pkg\n"); } /* Touch + poll → should detect untracked file in subdir */ cbm_watcher_touch(w, "sub-repo"); 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_free_idempotent) { /* Port of TestFSNotifyCleanup: * Verify that free() properly cleans up, and free(NULL) is safe. * Tests resource cleanup correctness. */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); /* Watch some projects to create internal state */ cbm_watcher_watch(w, "proj-a", "/tmp/a"); cbm_watcher_watch(w, "proj-b", "/tmp/b"); ASSERT_EQ(cbm_watcher_watch_count(w), 2); /* Free the watcher — should clean up all project state */ cbm_watcher_free(w); /* Free(NULL) should be safe (already tested in null_safety, * but repeated here for parity with Go's close() test) */ cbm_watcher_free(NULL); cbm_store_close(store); PASS(); } TEST(watcher_full_flow_new_file) { /* Port of TestWatcherFSNotifyDetectsNewFile: * Full lifecycle: watch → baseline → add file → detect change. * This is a more thorough version of watcher_detects_new_file * that mirrors the Go test's structure exactly. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ffnf_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, "main.go"), "package main\n"); } wt_git(tmpdir, "add main.go"); 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, "ffnf-repo", tmpdir); index_call_count = 0; /* Baseline — sets up git strategy, captures HEAD */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Poll again immediately — should be blocked by interval */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Create a new file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/util.go", tmpdir); th_write_file(_p, "package main\n"); } /* Touch to bypass interval, then poll — should detect */ cbm_watcher_touch(w, "ffnf-repo"); 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_fallback_still_detects) { /* Port of TestFSNotifyFallbackToDirMtime: * Even when the "primary" strategy has issues, the watcher * still detects changes. In C, we test that after removing .git * and re-creating it, changes are still detected on re-watch. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_fb_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, "main.go"), "hello\n"); } wt_git(tmpdir, "add main.go"); 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, "fb-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); 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")); } wt_git(tmpdir, "init -q"); wt_git(tmpdir, "add -A"); wt_git(tmpdir, "commit -q -m reinit"); /* Re-watch with fresh state */ cbm_watcher_unwatch(w, "fb-repo"); cbm_watcher_watch(w, "fb-repo", tmpdir); cbm_watcher_poll_once(w); /* new baseline */ /* Add new file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/new.go", tmpdir); th_write_file(_p, "package main\n"); } /* Detect change with fresh git strategy */ cbm_watcher_touch(w, "fb-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* Reindex counts for watcher_poll_only_watched_projects, split by project: * the watched one, and anything else. */ static int ow_watched_calls = 0; static int ow_other_calls = 0; static int ow_index_callback(const char *name, const char *path, void *ud) { (void)path; (void)ud; if (name && strcmp(name, "projA-ow") == 0) { ow_watched_calls++; } else { ow_other_calls++; } return 0; } TEST(watcher_poll_only_watched_projects) { /* Port of TestPollAllOnlyWatched (#49: the watcher polls its explicit * watch list, never every project the store knows). Two repos exist and * BOTH are registered in the store, as in the Go original; only A is * watched. Contract: B never gets watcher state and is never reindexed, * and A's change is reindexed. * * No verdict depends on one git probe succeeding. The earlier version * asserted that ONE poll after the change reindexed exactly once, and it * never registered B anywhere the watcher could see, so its B half could * not fail. A poll whose git probe fails transiently (spawn refused under * runner load) is a fail-closed no-op by design (#937: the baseline stays * uncommitted and the next poll retries), so that assert read * `index_call_count == 0, expected 1` on a loaded macOS runner. Holding * one exhausted spawn with cbm_subprocess_force_spawn_eagain_for_testing * at that poll reproduces the CI line exactly. The test now polls until * A's reindex has happened, and after every poll asserts the states that * hold whatever git answers: B has no watcher state and no reindex. A * watcher that never reindexes A hangs here, which the runner reports as * a failure, never a pass. */ char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_owA_XXXXXX"); char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_owB_XXXXXX"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) 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"); } 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"); } wt_git(tmpdirB, "add b.txt"); wt_git(tmpdirB, "commit -q -m init"); /* Both projects are known to the store. A watcher that polled stored * projects instead of its watch list (the pre-#49 scan-all) adopts B. */ cbm_store_t *store = cbm_store_open_memory(); ASSERT_EQ(cbm_store_upsert_project(store, "projA-ow", tmpdirA), CBM_STORE_OK); ASSERT_EQ(cbm_store_upsert_project(store, "projB-ow", tmpdirB), CBM_STORE_OK); cbm_watcher_t *w = cbm_watcher_new(store, ow_index_callback, NULL); /* Only watch A — B is NOT watched */ cbm_watcher_watch(w, "projA-ow", tmpdirA); ASSERT_EQ(cbm_watcher_watch_count(w), 1); ow_watched_calls = 0; ow_other_calls = 0; /* Baseline poll. Its git outcome is not asserted: if a probe fails here, * a later poll takes the baseline instead. A baseline poll never indexes, * whatever git answers, and it must not adopt B. */ ASSERT_EQ(cbm_watcher_poll_once(w), 0); ASSERT_EQ(ow_watched_calls + ow_other_calls, 0); ASSERT_EQ(cbm_watcher_index_failure_count(w, "projB-ow"), -1); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Make BOTH repos dirty. A dirty worktree, not a commit: the baseline * keeps the dirty signature at "clean known", so A's change is seen * whenever the baseline lands, even if the baseline poll above failed. A * commit made before a late baseline would be adopted as the baseline * HEAD and never reindexed. */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/a.txt", tmpdirA); th_append_file(_p, "dirty\n"); } { char _p[1024]; snprintf(_p, sizeof(_p), "%s/b.txt", tmpdirB); th_append_file(_p, "dirty\n"); } /* Poll until A's reindex. touch clears the adaptive interval so every * poll checks A; each poll runs at most one attempt for A, so the loop * stops at exactly one. After EVERY poll, B has no watcher state and no * reindex, whether or not that poll's git probes succeeded. */ while (ow_watched_calls == 0) { cbm_watcher_touch(w, "projA-ow"); cbm_watcher_poll_once(w); ASSERT_EQ(ow_other_calls, 0); ASSERT_EQ(cbm_watcher_index_failure_count(w, "projB-ow"), -1); ASSERT_EQ(cbm_watcher_watch_count(w), 1); } ASSERT_EQ(ow_watched_calls, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdirA); th_rmtree(tmpdirB); PASS(); } TEST(watcher_touch_resets_immediate) { /* Port of TestTouchProjectUpdatesTimestamp: * Verify that touch() resets the adaptive backoff so the next * poll actually checks for changes immediately. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tch_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, "tch-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Make dirty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } /* Without touch: interval blocks poll */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* blocked */ /* With touch: poll proceeds */ cbm_watcher_touch(w, "tch-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); /* detected */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_modify_tracked_file) { /* Port of TestWatcherTriggersOnChange / TestWatcherGitDetectsEdit: * Modify tracked file content (not just create/delete) → detected. * Similar to watcher_detects_dirty_worktree but modifies specific * tracked file content rather than appending. */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mod_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, "main.go"), "package main\n"); } wt_git(tmpdir, "add main.go"); 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, "mod-repo", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* No-change poll */ cbm_watcher_touch(w, "mod-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 0); /* Overwrite file with new content */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/main.go", tmpdir); th_write_file(_p, "package main\n\nfunc main() {}\n"); } /* Touch + poll → should detect modification */ cbm_watcher_touch(w, "mod-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } /* ══════════════════════════════════════════════════════════════════ * RESOURCE MANAGEMENT & AUTO-INDEXING BEHAVIOR * ══════════════════════════════════════════════════════════════════ */ TEST(watcher_null_store_handling) { /* watcher_new with NULL store — verify behavior */ cbm_watcher_t *w = cbm_watcher_new(NULL, NULL, NULL); /* Implementation may return NULL or a valid watcher. * Either is acceptable — key is no crash. */ if (w) { ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); } PASS(); } TEST(watcher_free_null_safe) { /* Explicit test: free(NULL) must not crash */ cbm_watcher_free(NULL); cbm_watcher_free(NULL); PASS(); } TEST(watcher_empty_count) { /* Fresh watcher with no projects → count 0 */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); ASSERT_NOT_NULL(w); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_multiple_verify_count) { /* Watch 5 projects, verify count at each step */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); for (int i = 0; i < 5; i++) { char name[32], path[64]; snprintf(name, sizeof(name), "proj-%d", i); snprintf(path, sizeof(path), "/tmp/proj-%d", i); cbm_watcher_watch(w, name, path); ASSERT_EQ(cbm_watcher_watch_count(w), i + 1); } /* Unwatch all */ for (int i = 0; i < 5; i++) { char name[32]; snprintf(name, sizeof(name), "proj-%d", i); cbm_watcher_unwatch(w, name); } ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_same_project_idempotent) { /* Watching the same project twice updates the path, count stays 1 */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_watch(w, "proj", "/tmp/path-a"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_watch(w, "proj", "/tmp/path-b"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_watch(w, "proj", "/tmp/path-c"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_unwatch_nonexistent_safe) { /* Unwatch a project that was never watched — no crash */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_unwatch(w, "never-existed"); cbm_watcher_unwatch(w, "also-never-existed"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_touch_nonexistent_project) { /* touch() on a project not in the watch list — no crash */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_touch(w, "nonexistent-project"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_poll_interval_zero_files) { /* 0 files → base interval (5000ms) */ int ms = cbm_watcher_poll_interval_ms(0); ASSERT_EQ(ms, 5000); PASS(); } TEST(watcher_poll_interval_small_files) { /* 100 files → should be close to base (5000ms) */ int ms = cbm_watcher_poll_interval_ms(100); ASSERT_GTE(ms, 5000); /* 100 files / 500 = 0 extra seconds of scaling → 5000ms */ ASSERT_EQ(ms, 5000); PASS(); } TEST(watcher_poll_interval_medium_files) { /* 10000 files → 5000 + 20*1000 = 25000ms */ int ms = cbm_watcher_poll_interval_ms(10000); ASSERT_EQ(ms, 25000); PASS(); } TEST(watcher_poll_interval_capped) { /* 100000 files → capped at 60000ms */ int ms = cbm_watcher_poll_interval_ms(100000); ASSERT_EQ(ms, 60000); /* Even larger → still capped */ ms = cbm_watcher_poll_interval_ms(500000); ASSERT_EQ(ms, 60000); PASS(); } TEST(watcher_poll_interval_negative) { /* Negative file count → should handle gracefully (no crash) */ int ms = cbm_watcher_poll_interval_ms(-1); /* Result should be at least the base interval or 0 — just no crash */ ASSERT_GTE(ms, 0); PASS(); } TEST(watcher_poll_empty_returns_zero) { /* poll_once with empty watch list → 0 reindexed */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); index_call_count = 0; int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_poll_non_git_dir) { /* poll_once with a non-git directory → 0 changes detected */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng2_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); /* Create a regular file so directory is not empty */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_write_file(_p, "hello\n"); } cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_watcher_watch(w, "nongit2", tmpdir); index_call_count = 0; /* Baseline */ cbm_watcher_poll_once(w); /* Modify file */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "world\n"); } /* Poll — non-git directory, should not trigger reindex */ cbm_watcher_touch(w, "nongit2"); int reindexed = cbm_watcher_poll_once(w); ASSERT_EQ(reindexed, 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_stop_prevents_run) { /* Setting stop before run → run returns immediately */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); cbm_watcher_stop(w); int rc = cbm_watcher_run(w, 60000); ASSERT_EQ(rc, 0); cbm_watcher_free(w); cbm_store_close(store); PASS(); } TEST(watcher_watch_unwatch_rapid_cycle) { /* Rapid watch/unwatch cycles — stress lifecycle management */ cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, NULL, NULL); for (int i = 0; i < 20; i++) { cbm_watcher_watch(w, "rapid", "/tmp/rapid"); ASSERT_EQ(cbm_watcher_watch_count(w), 1); cbm_watcher_unwatch(w, "rapid"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); } cbm_watcher_free(w); cbm_store_close(store); PASS(); } /* Callback and state for watcher_callback_data_passed test */ static int g_cbdata_value = 42; static int *g_cbdata_received = NULL; static int capture_data_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; g_cbdata_received = (int *)ud; return 0; } TEST(watcher_callback_data_passed) { /* Verify that user_data pointer is accessible in the callback */ g_cbdata_received = NULL; /* Create a temp git repo */ char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cbdata_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, capture_data_callback, &g_cbdata_value); cbm_watcher_watch(w, "cbdata-repo", tmpdir); /* Baseline */ cbm_watcher_poll_once(w); /* Make dirty to trigger callback */ { char _p[1024]; snprintf(_p, sizeof(_p), "%s/file.txt", tmpdir); th_append_file(_p, "dirty\n"); } cbm_watcher_touch(w, "cbdata-repo"); cbm_watcher_poll_once(w); /* If callback was invoked, g_cbdata_received should point to g_cbdata_value */ if (g_cbdata_received) { ASSERT_EQ(*g_cbdata_received, 42); } cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } TEST(watcher_unwatch_drains_pending_free) { /* Contract: unwatch parks the project's state on the deferred-free list * (an in-flight poll snapshot may still hold it), and the NEXT poll_once * frees it, exactly once, without admitting index work for the unwatched * project. * * No assertion here depends on a git probe succeeding. The earlier * version asserted as a precondition that one dirty poll reindexed * exactly once, which needs every git subprocess of that poll to * succeed. A transient probe failure (spawn refused under runner load, a * non-zero exit) makes that poll a fail-closed no-op by design (#937: * the baseline stays uncommitted and the next poll retries), so the * precondition read 0 on loaded macos-14 runners. Holding a spawn * failure with cbm_subprocess_force_spawn_eagain_for_testing reproduces * that CI log exactly. Dirty-poll-reindexes stays covered by * watcher_detects_dirty_worktree and watcher_modify_tracked_file; this * test pins the drain itself, through the pending-free seam. */ 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); ASSERT_EQ(cbm_watcher_test_pending_free_count(w), 0); index_call_count = 0; /* Baseline: the state goes through a poll snapshot. Its outcome is not * asserted; a baseline poll never indexes, whatever git answers. */ cbm_watcher_poll_once(w); /* Leave a new dirty state behind: if the unwatched state were still * polled, the next poll would index it. */ { char p[300]; th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "dirty\n"); } cbm_watcher_touch(w, "df-repo"); /* Unwatch: the state leaves the table and is parked, not freed. */ cbm_watcher_unwatch(w, "df-repo"); ASSERT_EQ(cbm_watcher_watch_count(w), 0); ASSERT_EQ(cbm_watcher_test_pending_free_count(w), 1); /* The next poll drains it and admits no index work. */ ASSERT_EQ(cbm_watcher_poll_once(w), 0); ASSERT_EQ(cbm_watcher_test_pending_free_count(w), 0); ASSERT_EQ(index_call_count, 0); /* A further poll finds nothing left to free (no double free). */ ASSERT_EQ(cbm_watcher_poll_once(w), 0); ASSERT_EQ(cbm_watcher_test_pending_free_count(w), 0); ASSERT_EQ(index_call_count, 0); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(tmpdir); PASS(); } typedef struct { cbm_watcher_t *watcher; const char *first_project; const char *second_project; int calls; } unwatch_snapshot_ctx_t; static int unwatch_snapshot_callback(const char *name, const char *path, void *ud) { (void)name; (void)path; unwatch_snapshot_ctx_t *ctx = ud; ctx->calls++; if (ctx->calls == 1) { /* Both states are already present in poll_once's pointer snapshot. * Removing them must invalidate the not-yet-admitted callback. */ cbm_watcher_unwatch(ctx->watcher, ctx->first_project); cbm_watcher_unwatch(ctx->watcher, ctx->second_project); } return 0; } TEST(watcher_unwatch_invalidates_remaining_poll_snapshot) { char first[256]; char second[256]; snprintf(first, sizeof(first), "/tmp/cbm_watcher_unwatch_snap_a_XXXXXX"); snprintf(second, sizeof(second), "/tmp/cbm_watcher_unwatch_snap_b_XXXXXX"); if (!cbm_mkdtemp(first) || !cbm_mkdtemp(second)) { th_rmtree(first); th_rmtree(second); FAIL("cbm_mkdtemp failed"); } if (wt_git(first, "init -q") != 0 || wt_git(second, "init -q") != 0) { th_rmtree(first); th_rmtree(second); FAIL("git init failed"); } char path[300]; th_write_file(wt_path(path, sizeof(path), first, "file.txt"), "first\n"); th_write_file(wt_path(path, sizeof(path), second, "file.txt"), "second\n"); wt_git(first, "add file.txt"); wt_git(first, "commit -q -m init"); wt_git(second, "add file.txt"); wt_git(second, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); unwatch_snapshot_ctx_t ctx = { .first_project = "unwatch-snapshot-a", .second_project = "unwatch-snapshot-b", }; cbm_watcher_t *w = cbm_watcher_new(store, unwatch_snapshot_callback, &ctx); ctx.watcher = w; cbm_watcher_watch(w, ctx.first_project, first); cbm_watcher_watch(w, ctx.second_project, second); (void)cbm_watcher_poll_once(w); th_append_file(wt_path(path, sizeof(path), first, "file.txt"), "dirty\n"); th_append_file(wt_path(path, sizeof(path), second, "file.txt"), "dirty\n"); cbm_watcher_touch(w, ctx.first_project); cbm_watcher_touch(w, ctx.second_project); int reindexed = cbm_watcher_poll_once(w); int watches_after_callback = cbm_watcher_watch_count(w); (void)cbm_watcher_poll_once(w); /* drain the two deferred states */ cbm_watcher_free(w); cbm_store_close(store); th_rmtree(first); th_rmtree(second); ASSERT_EQ(ctx.calls, 1); ASSERT_EQ(reindexed, 1); ASSERT_EQ(watches_after_callback, 0); PASS(); } typedef struct { cbm_watcher_t *watcher; const char *replacement_root; int calls; } replace_during_poll_ctx_t; static int replace_during_poll_callback(const char *name, const char *path, void *ud) { (void)path; replace_during_poll_ctx_t *ctx = ud; ctx->calls++; /* Re-registering can happen when a second daemon session initializes while * this project's watcher snapshot is being polled. The old state must stay * alive until the snapshot finishes using it. */ cbm_watcher_watch(ctx->watcher, name, ctx->replacement_root); return 0; } TEST(watcher_replace_during_poll_defers_old_state_free) { char original[256]; char replacement[256]; snprintf(original, sizeof(original), "/tmp/cbm_watcher_replace_old_XXXXXX"); snprintf(replacement, sizeof(replacement), "/tmp/cbm_watcher_replace_new_XXXXXX"); if (!cbm_mkdtemp(original) || !cbm_mkdtemp(replacement)) { th_rmtree(original); th_rmtree(replacement); FAIL("cbm_mkdtemp failed"); } if (wt_git(original, "init -q") != 0) { th_rmtree(original); th_rmtree(replacement); FAIL("git init failed"); } { char p[300]; th_write_file(wt_path(p, sizeof(p), original, "file.txt"), "hello\n"); } wt_git(original, "add file.txt"); wt_git(original, "commit -q -m init"); cbm_store_t *store = cbm_store_open_memory(); replace_during_poll_ctx_t ctx = {.replacement_root = replacement}; cbm_watcher_t *w = cbm_watcher_new(store, replace_during_poll_callback, &ctx); ASSERT_NOT_NULL(w); ctx.watcher = w; cbm_watcher_watch(w, "replace-repo", original); cbm_watcher_poll_once(w); /* baseline */ { char p[300]; th_append_file(wt_path(p, sizeof(p), original, "file.txt"), "dirty\n"); } cbm_watcher_touch(w, "replace-repo"); ASSERT_EQ(cbm_watcher_poll_once(w), 1); ASSERT_EQ(ctx.calls, 1); ASSERT_EQ(cbm_watcher_watch_count(w), 1); /* Drains the deferred old snapshot state after the prior poll completed. */ (void)cbm_watcher_poll_once(w); cbm_watcher_free(w); cbm_store_close(store); th_rmtree(original); th_rmtree(replacement); PASS(); } TEST(watcher_null_poll_once) { /* poll_once(NULL) → 0 */ int reindexed = cbm_watcher_poll_once(NULL); ASSERT_EQ(reindexed, 0); PASS(); } TEST(watcher_null_watch_count) { /* watch_count(NULL) → 0 */ int count = cbm_watcher_watch_count(NULL); ASSERT_EQ(count, 0); PASS(); } /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ SUITE(watcher) { /* Adaptive interval */ RUN_TEST(poll_interval_base); RUN_TEST(poll_interval_scaling); RUN_TEST(poll_interval_cap); RUN_TEST(poll_interval_small); RUN_TEST(index_backoff_no_failures_keeps_interval); RUN_TEST(index_backoff_doubles_per_failure); RUN_TEST(index_backoff_reaches_ceiling_and_stays); RUN_TEST(index_backoff_is_monotonic_and_bounded); RUN_TEST(index_backoff_degenerate_inputs); RUN_TEST(index_backoff_never_schedules_sooner_than_the_interval); RUN_TEST(watcher_index_failure_streak_increments_and_resets_issue2015); RUN_TEST(watcher_index_failure_backoff_gates_repolling_issue2015); RUN_TEST(watcher_sustained_failure_logs_once_issue2015); RUN_TEST(watcher_index_failure_count_unknown_project); /* Lifecycle */ RUN_TEST(watcher_create_free); RUN_TEST(watcher_watch_unwatch); RUN_TEST(watcher_unwatch_nonexistent); RUN_TEST(watcher_watch_replace); RUN_TEST(watcher_stopped_rejects_new_registration); RUN_TEST(watcher_null_safety); /* Polling */ RUN_TEST(watcher_poll_no_projects); RUN_TEST(watcher_poll_nonexistent_path); RUN_TEST(watcher_prunes_sustained_missing_root); RUN_TEST(watcher_prune_waits_for_daemon_project_mutation); RUN_TEST(watcher_prune_guard_denial_and_success_are_balanced); RUN_TEST(watcher_prune_restats_root_after_guard_acquisition); RUN_TEST(watcher_prune_delete_failure_retains_watch_for_retry); RUN_TEST(watcher_grace_window_blocks_prune); RUN_TEST(watcher_root_missing_errno_classification); RUN_TEST(watcher_root_restore_resets_prune_streak); RUN_TEST(watcher_poll_this_repo); RUN_TEST(watcher_stop_flag); RUN_TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop); /* Git change detection */ RUN_TEST(watcher_detects_git_commit); RUN_TEST(watcher_nested_non_git_dir_does_not_inherit_ancestor_dirt); RUN_TEST(watcher_monorepo_subdir_ignores_sibling_changes); RUN_TEST(watcher_detects_sha256_git_commit); RUN_TEST(watcher_detects_dirty_worktree); RUN_TEST(watcher_identical_watch_preserves_dirty_baseline); RUN_TEST(watcher_detects_new_file); RUN_TEST(watcher_no_change_no_reindex); RUN_TEST(watcher_own_artifact_export_does_not_retrigger_issue1953); RUN_TEST(watcher_dirty_state_reindexes_once_issue937); RUN_TEST(watcher_failed_reindex_retries_issue937); RUN_TEST(watcher_multiple_projects); /* Non-git project */ RUN_TEST(watcher_non_git_skips); /* Adaptive interval behavior */ RUN_TEST(watcher_interval_blocks_repoll); RUN_TEST(watcher_poll_interval_full_table); /* Git removal + continued dirty + baseline dirty */ RUN_TEST(watcher_git_removed_no_crash); RUN_TEST(watcher_continued_dirty); RUN_TEST(watcher_baseline_dirty_repo); RUN_TEST(watcher_unwatch_prunes_state); RUN_TEST(watcher_watch_after_unwatch); /* FSNotify ports (adapted for git-based detection) */ RUN_TEST(watcher_detects_file_delete); RUN_TEST(watcher_detects_subdir_file); RUN_TEST(watcher_free_idempotent); RUN_TEST(watcher_full_flow_new_file); RUN_TEST(watcher_fallback_still_detects); RUN_TEST(watcher_poll_only_watched_projects); RUN_TEST(watcher_touch_resets_immediate); RUN_TEST(watcher_modify_tracked_file); /* Resource management & auto-indexing behavior */ RUN_TEST(watcher_null_store_handling); RUN_TEST(watcher_free_null_safe); RUN_TEST(watcher_empty_count); RUN_TEST(watcher_watch_multiple_verify_count); RUN_TEST(watcher_watch_same_project_idempotent); RUN_TEST(watcher_unwatch_nonexistent_safe); RUN_TEST(watcher_touch_nonexistent_project); /* Poll interval edge cases */ RUN_TEST(watcher_poll_interval_zero_files); RUN_TEST(watcher_poll_interval_small_files); RUN_TEST(watcher_poll_interval_medium_files); RUN_TEST(watcher_poll_interval_capped); RUN_TEST(watcher_poll_interval_negative); /* Poll edge cases */ RUN_TEST(watcher_poll_empty_returns_zero); 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_unwatch_invalidates_remaining_poll_snapshot); RUN_TEST(watcher_replace_during_poll_defers_old_state_free); RUN_TEST(watcher_callback_data_passed); RUN_TEST(watcher_null_poll_once); RUN_TEST(watcher_null_watch_count); }