mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
The background watcher only watches the project an open MCP session is
rooted in, and drops the watch when the last session for that project
closes. A repository indexed with `cli index_repository` (or from a
session rooted elsewhere) is never watched, but README promised
"Auto-sync keeps it fresh after that" for every index_repository target
and the MCP instructions said "Indexes auto-refresh". Nothing in the
status output said which projects were actually watched, so a stale
CLI-indexed repo looked like a watcher bug.
What gets watched is unchanged. Instead:
- index_status gains a `watch` object: `watched`, plus `strategy`,
`poll_interval_ms` and `last_scan_at` when watched, or a `reason`
(not_session_project, cli_session, auto_watch_off, watcher_disabled,
not_registered, no_watcher) and a re-index hint when not. It does not
touch the #1561 `freshness` key.
- The watcher publishes per-project strategy, cadence and last completed
scan time through atomics and exposes them via
cbm_watcher_project_info(); the daemon answers index_status through a
session-scoped provider.
- README, docs/CONFIGURATION.md, docs/index.html, the index_status tool
description and the MCP instructions now state the real scope.
`watcher_enabled` already shows in `config list` since v0.11.0 (680bc72e).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
4006 lines
143 KiB
C
4006 lines
143 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();
|
|
}
|
|
|
|
/* #2167: index_status reads the watch state through cbm_watcher_project_info.
|
|
* A registered project starts "pending" at the base cadence with no scan;
|
|
* the first poll completes its baseline and publishes the strategy plus a
|
|
* wall-clock scan time. Unwatched projects report false with *out zeroed. */
|
|
TEST(watcher_project_info_publishes_scan_issue2167) {
|
|
char root[256];
|
|
snprintf(root, sizeof(root), "%s/cbm_watcher_info_XXXXXX", cbm_tmpdir());
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(root));
|
|
cbm_store_t *store = cbm_store_open_memory();
|
|
cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL);
|
|
|
|
cbm_watcher_project_info_t info;
|
|
ASSERT_FALSE(cbm_watcher_project_info(w, "never-watched", &info));
|
|
ASSERT_EQ(info.poll_interval_ms, 0);
|
|
ASSERT_FALSE(cbm_watcher_project_info(NULL, "x", &info));
|
|
|
|
ASSERT_TRUE(cbm_watcher_watch(w, "info-proj", root));
|
|
ASSERT_TRUE(cbm_watcher_project_info(w, "info-proj", &info));
|
|
ASSERT_EQ(info.strategy, CBM_WATCHER_STRATEGY_PENDING);
|
|
ASSERT_EQ(info.poll_interval_ms, cbm_watcher_poll_interval_ms(0));
|
|
ASSERT_EQ(info.last_scan_unix_s, 0);
|
|
|
|
/* A plain directory (no git): the baseline decides it is never polled. */
|
|
int64_t before = (int64_t)time(NULL);
|
|
(void)cbm_watcher_poll_once(w);
|
|
ASSERT_TRUE(cbm_watcher_project_info(w, "info-proj", &info));
|
|
ASSERT_EQ(info.strategy, CBM_WATCHER_STRATEGY_NONE);
|
|
ASSERT_TRUE(info.last_scan_unix_s >= before);
|
|
|
|
cbm_watcher_unwatch(w, "info-proj");
|
|
ASSERT_FALSE(cbm_watcher_project_info(w, "info-proj", &info));
|
|
|
|
cbm_watcher_free(w);
|
|
cbm_store_close(store);
|
|
(void)cbm_rmdir(root);
|
|
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();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* NON-GIT TREE POLLING (#1948, opt-in)
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
/* Bypass the adaptive interval and run one poll cycle; returns the number of
|
|
* reindexes that cycle performed. */
|
|
static int nongit_poll(cbm_watcher_t *w, const char *project) {
|
|
cbm_watcher_touch(w, project);
|
|
return cbm_watcher_poll_once(w);
|
|
}
|
|
|
|
/* Control: with the opt-in OFF (the default) a non-git root is never
|
|
* refreshed, however its indexable files change. watcher_non_git_skips covers
|
|
* this with .txt files discovery ignores anyway; this uses a real source file
|
|
* so the ON tests below differ from it ONLY in the opt-in. */
|
|
TEST(watcher_non_git_opt_in_off_never_polls_issue1948) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng_off_XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
th_write_file(TH_PATH(tmpdir, "app.py"), "def alpha():\n return 1\n");
|
|
|
|
cbm_store_t *store = cbm_store_open_memory();
|
|
cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL);
|
|
cbm_watcher_watch(w, "ng-off", tmpdir);
|
|
index_call_count = 0;
|
|
|
|
cbm_watcher_poll_once(w); /* baseline */
|
|
ASSERT_EQ(nongit_poll(w, "ng-off"), 0);
|
|
th_append_file(TH_PATH(tmpdir, "app.py"), "\ndef gamma():\n return 3\n");
|
|
ASSERT_EQ(nongit_poll(w, "ng-off"), 0);
|
|
th_write_file(TH_PATH(tmpdir, "new.py"), "def beta():\n return 2\n");
|
|
ASSERT_EQ(nongit_poll(w, "ng-off"), 0);
|
|
ASSERT_EQ(remove(TH_PATH(tmpdir, "new.py")), 0);
|
|
ASSERT_EQ(nongit_poll(w, "ng-off"), 0);
|
|
ASSERT_EQ(index_call_count, 0);
|
|
|
|
cbm_watcher_free(w);
|
|
cbm_store_close(store);
|
|
th_rmtree(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* #1948: a project indexed from a plain directory stayed frozen at its first
|
|
* index forever. With watch_non_git on, the watcher polls it by tree
|
|
* signature: the first poll after baseline catches up once (nothing records
|
|
* which tree state the index holds), then every edit, addition and deletion
|
|
* of an indexable file triggers exactly one reindex, and an untouched tree
|
|
* triggers none. */
|
|
TEST(watcher_non_git_opt_in_detects_edit_add_delete_issue1948) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng_on_XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
th_write_file(TH_PATH(tmpdir, "app.py"), "def alpha():\n return 1\n");
|
|
th_write_file(TH_PATH(tmpdir, "pkg/util.py"), "def helper():\n return 0\n");
|
|
|
|
cbm_store_t *store = cbm_store_open_memory();
|
|
cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL);
|
|
cbm_watcher_set_poll_non_git(w, true);
|
|
cbm_watcher_watch(w, "ng-on", tmpdir);
|
|
index_call_count = 0;
|
|
|
|
cbm_watcher_poll_once(w); /* baseline: classification only */
|
|
ASSERT_EQ(index_call_count, 0);
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 1); /* at-least-once catch-up */
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 0); /* untouched tree: stable */
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 0);
|
|
|
|
/* Edit (the reporter's repro: append a function). */
|
|
th_append_file(TH_PATH(tmpdir, "app.py"), "\ndef gamma_uniquename():\n return 2\n");
|
|
/* The adaptive interval still gates polling: no touch, no check. */
|
|
ASSERT_EQ(cbm_watcher_poll_once(w), 0);
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 1);
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 0);
|
|
|
|
/* Add, in a subdirectory. */
|
|
th_write_file(TH_PATH(tmpdir, "pkg/extra.py"), "def extra():\n return 4\n");
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 1);
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 0);
|
|
|
|
/* Delete. */
|
|
ASSERT_EQ(remove(TH_PATH(tmpdir, "pkg/extra.py")), 0);
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 1);
|
|
ASSERT_EQ(nongit_poll(w, "ng-on"), 0);
|
|
|
|
ASSERT_EQ(index_call_count, 4);
|
|
|
|
cbm_watcher_free(w);
|
|
cbm_store_close(store);
|
|
th_rmtree(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* Simulates the daemon's index worker writing cbm's own output under the root
|
|
* on every successful reindex (the artifact re-export of #1953). */
|
|
static int own_output_index_callback(const char *name, const char *path, void *ud) {
|
|
(void)name;
|
|
(void)ud;
|
|
index_call_count++;
|
|
th_append_file(TH_PATH(path, CBM_ARTIFACT_DIR "/graph.py"), "# rewritten by index\n");
|
|
return 0;
|
|
}
|
|
|
|
/* The signature must only see what the indexer would index. Paths discovery
|
|
* skips — cbm's own .codebase-memory output, built-in skip dirs, .cbmignore'd
|
|
* paths, non-source files — never trigger, and in particular a reindex that
|
|
* rewrites cbm's own output must not look like a new change on the next poll
|
|
* (the runaway-reindex history of #841/#937/#1953). */
|
|
TEST(watcher_non_git_opt_in_ignores_skipped_paths_issue1948) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng_ign_XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
th_write_file(TH_PATH(tmpdir, "app.py"), "def alpha():\n return 1\n");
|
|
th_write_file(TH_PATH(tmpdir, ".cbmignore"), "ignored/\n");
|
|
th_write_file(TH_PATH(tmpdir, CBM_ARTIFACT_DIR "/graph.py"), "# artifact\n");
|
|
|
|
cbm_store_t *store = cbm_store_open_memory();
|
|
cbm_watcher_t *w = cbm_watcher_new(store, own_output_index_callback, NULL);
|
|
cbm_watcher_set_poll_non_git(w, true);
|
|
cbm_watcher_watch(w, "ng-ign", tmpdir);
|
|
index_call_count = 0;
|
|
|
|
cbm_watcher_poll_once(w); /* baseline */
|
|
ASSERT_EQ(nongit_poll(w, "ng-ign"), 1); /* catch-up; rewrites own output */
|
|
/* The reindex's own write must not retrigger. */
|
|
ASSERT_EQ(nongit_poll(w, "ng-ign"), 0);
|
|
ASSERT_EQ(nongit_poll(w, "ng-ign"), 0);
|
|
|
|
th_write_file(TH_PATH(tmpdir, "ignored/skip.py"), "def skipped():\n return 0\n");
|
|
th_write_file(TH_PATH(tmpdir, "node_modules/dep/index.js"), "module.exports = 1;\n");
|
|
th_write_file(TH_PATH(tmpdir, "logo.png"), "not really a png\n");
|
|
th_append_file(TH_PATH(tmpdir, CBM_ARTIFACT_DIR "/graph.py"), "# touched\n");
|
|
ASSERT_EQ(nongit_poll(w, "ng-ign"), 0);
|
|
ASSERT_EQ(index_call_count, 1);
|
|
|
|
/* Positive control in the same tree: an indexable edit still triggers. */
|
|
th_append_file(TH_PATH(tmpdir, "app.py"), "\ndef beta():\n return 2\n");
|
|
ASSERT_EQ(nongit_poll(w, "ng-ign"), 1);
|
|
ASSERT_EQ(nongit_poll(w, "ng-ign"), 0);
|
|
|
|
cbm_watcher_free(w);
|
|
cbm_store_close(store);
|
|
th_rmtree(tmpdir);
|
|
PASS();
|
|
}
|
|
|
|
/* The opt-in changes nothing for git roots: a clean repository does not get
|
|
* the tree strategy's catch-up reindex and still reacts to commits only. A
|
|
* plain folder under an unrelated dirty repository is tree-polled on its OWN
|
|
* files and never inherits the ancestor's dirty state. */
|
|
TEST(watcher_non_git_opt_in_leaves_git_roots_unchanged_issue1948) {
|
|
char tmpdir[256];
|
|
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ng_git_XXXXXX");
|
|
if (!cbm_mkdtemp(tmpdir))
|
|
FAIL("cbm_mkdtemp failed");
|
|
if (wt_git(tmpdir, "init -q") != 0) {
|
|
th_rmtree(tmpdir);
|
|
FAIL("git init failed");
|
|
}
|
|
th_write_file(TH_PATH(tmpdir, "app.py"), "def alpha():\n return 1\n");
|
|
wt_git(tmpdir, "add app.py");
|
|
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_set_poll_non_git(w, true);
|
|
cbm_watcher_watch(w, "ng-git", tmpdir);
|
|
index_call_count = 0;
|
|
|
|
cbm_watcher_poll_once(w); /* baseline */
|
|
ASSERT_EQ(nongit_poll(w, "ng-git"), 0);
|
|
ASSERT_EQ(nongit_poll(w, "ng-git"), 0);
|
|
th_append_file(TH_PATH(tmpdir, "app.py"), "\ndef beta():\n return 2\n");
|
|
wt_git(tmpdir, "commit -q -am second");
|
|
ASSERT_EQ(nongit_poll(w, "ng-git"), 1);
|
|
ASSERT_EQ(nongit_poll(w, "ng-git"), 0);
|
|
|
|
/* Now a scratch folder under the (permanently dirty) repository. */
|
|
th_write_file(TH_PATH(tmpdir, "dirty.txt"), "uncommitted\n");
|
|
char nested[400];
|
|
snprintf(nested, sizeof(nested), "%s/scratch", tmpdir);
|
|
th_write_file(TH_PATH(nested, "notes.py"), "def note():\n return 0\n");
|
|
cbm_watcher_unwatch(w, "ng-git");
|
|
cbm_watcher_watch(w, "ng-nested", nested);
|
|
index_call_count = 0;
|
|
cbm_watcher_poll_once(w); /* baseline: nested non-git */
|
|
ASSERT_EQ(nongit_poll(w, "ng-nested"), 1); /* tree catch-up */
|
|
for (int i = 0; i < 3; i++) {
|
|
ASSERT_EQ(nongit_poll(w, "ng-nested"), 0); /* ancestor dirt ignored */
|
|
}
|
|
th_append_file(TH_PATH(nested, "notes.py"), "\ndef more():\n return 1\n");
|
|
ASSERT_EQ(nongit_poll(w, "ng-nested"), 1);
|
|
|
|
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);
|
|
RUN_TEST(watcher_project_info_publishes_scan_issue2167);
|
|
|
|
/* 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);
|
|
RUN_TEST(watcher_non_git_opt_in_off_never_polls_issue1948);
|
|
RUN_TEST(watcher_non_git_opt_in_detects_edit_add_delete_issue1948);
|
|
RUN_TEST(watcher_non_git_opt_in_ignores_skipped_paths_issue1948);
|
|
RUN_TEST(watcher_non_git_opt_in_leaves_git_roots_unchanged_issue1948);
|
|
|
|
/* 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);
|
|
}
|