mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-21 05:59:35 +08:00
watcher_poll_only_watched_projects failed on the macOS LSan job of PR #2295, whose diff was Java-only, with `FAIL tests/test_watcher.c:2971: index_call_count == 0, expected 1` (8211 passed, 1 failed). The LSan report that followed points into the aborted test body (watcher and store never freed after the early FAIL): a consequence, not a separate leak. The assert expected ONE touch+poll after a dirty change to reindex exactly once. That needs every git probe of the poll to succeed. A poll whose git spawn is refused under runner load is a fail-closed no-op by design (#937: the baseline stays uncommitted and the next poll retries), and that path returns without a log line. This is the same test race that #2376 fixed in watcher_unwatch_drains_pending_free and watcher_sustained_failure_logs_once_issue2015, not a production defect. Holding one exhausted spawn with cbm_subprocess_force_spawn_eagain_for_testing at that poll reproduces the CI output exactly: watch, baseline, then `FAIL tests/test_watcher.c:2971: index_call_count == 0, expected 1 == 1`, with no watcher.changed and no watcher.git.failed. The old test was also partly inert. The Go original (TestPollAllOnlyWatched, from the #49 fix that replaced scan-all with an explicit watch list) registered BOTH projects in the store and asserted that the unwatched one gets no watcher state. The C port dropped the store registration, so nothing let the watcher learn about B, and the B half could not fail. The test now: - registers A and B in the store, and watches only A; - asserts that the baseline poll indexes nothing and does not adopt B; - makes both worktrees dirty (a dirty change, not a commit, so a late baseline still sees it); - polls until A's reindex has happened, asserting after EVERY poll that B has no watcher state (cbm_watcher_index_failure_count == -1), was never reindexed, and that the watch count is still 1; - asserts that A was reindexed exactly once. No timer or deadline decides the verdict. A watcher that never reindexes A hangs, which the runner reports as a failure, never a pass. Evidence (macOS arm64, ASan/UBSan test-runner, CBM_SANITIZED, so one exhausted spawn = 10 injected refusals), with temporary injection that has since been removed: - CI-shaped (baseline OK, first change poll refused): consumed=10 at loop poll 1, watcher.changed at loop poll 2, PASS. - Heavy (baseline poll plus loop polls 1-3 refused): loop poll 4 takes the baseline, poll 5 reindexes A; loop polls=5 watched=1 other=0, PASS. - Mutation, the pre-#49 scan-all (poll_once adopts every stored project): `FAIL tests/test_watcher.c:3001: cbm_watcher_index_failure_count(w, "projB-ow") == 0, expected -1 == -1`, both unforced and with the heavy refusals (A's and B's baseline spawns both refused). The other 82 watcher tests stay green under that mutation, so this is the only test guarding #49. Final tree: build/c/test-runner watcher, 5/5 runs, 83 passed each. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
3762 lines
132 KiB
C
3762 lines
132 KiB
C
/*
|
|
* 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 <daemon/application.h>
|
|
#include <watcher/watcher.h>
|
|
#include <pipeline/artifact.h>
|
|
#include <store/store.h>
|
|
#include <errno.h>
|
|
#include <limits.h>
|
|
#include <stdatomic.h>
|
|
#include <signal.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <unistd.h>
|
|
#include <sys/stat.h>
|
|
#ifdef _WIN32
|
|
#include "../src/foundation/win_utf8.h"
|
|
#endif
|
|
#ifdef __APPLE__
|
|
#include <libproc.h>
|
|
#endif
|
|
|
|
/* Portable git: `git -C "<dir>" <args>` 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 "<dir>/<rel>" 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 <root>/.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);
|
|
}
|