Skip to content

Commit e5bb749

Browse files
DeusDatapcristin
andcommitted
feat(watcher): prune projects whose root stays missing (ENOENT-only, grace window)
Distilled from #738: the watcher now prunes a watched project whose root directory has genuinely disappeared - deleting the cached DB (+wal/shm, validated name, cache-dir-only paths) and removing the watch entry - so vanished worktrees stop being watched forever (#286). Hardened beyond the original PR to remove a data-loss hazard (the cached DB can hold user-authored data such as the ADR, unrecoverable once deleted): - Only ENOENT/ENOTDIR stat failures count as missing. Any other failure (EACCES, EIO, transient mounts, macOS TCC revocation) resets the streak and logs watcher.root_stat_error with the errno; Windows (mingw/UCRT) maps not-found to ENOENT so the check holds there. - Pruning requires BOTH >=3 consecutive missing polls AND a sustained- absence grace window since the streak's first miss (default 600s, override via CBM_WATCHER_PRUNE_GRACE_S), tracked with monotonic cbm_now_ms. - Root reappearance resets streak + timestamp (watcher.root_restored). - The pruned entry is released via the deferred-free list poll_once drains (one freeing model shared with cbm_watcher_unwatch). Limitation: only currently-watched projects are pruned; stale DBs left by older sessions are out of scope. Co-authored-by: pcristin <xxxokzxxx@protonmail.com> Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 09148ab commit e5bb749

3 files changed

Lines changed: 399 additions & 14 deletions

File tree

src/watcher/watcher.c

Lines changed: 178 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
#include "foundation/compat.h"
2323
#include "foundation/compat_thread.h"
2424
#include "foundation/compat_fs.h"
25+
#include "foundation/platform.h"
2526
#include "foundation/str_util.h"
2627

28+
#include <errno.h>
2729
#include <stdio.h>
2830
#include <stdlib.h>
2931
#include <string.h>
@@ -39,6 +41,8 @@ typedef struct {
3941
char last_head[CBM_SZ_64]; /* git HEAD hash */
4042
bool is_git; /* false → skip polling */
4143
bool baseline_done; /* true after first poll */
44+
int missing_root_count; /* consecutive polls where root was missing (ENOENT/ENOTDIR) */
45+
uint64_t first_missing_ms; /* cbm_now_ms() of the streak's first miss (0 = no streak) */
4246
int file_count; /* approximate, for interval calc */
4347
int interval_ms; /* adaptive poll interval */
4448
int64_t next_poll_ns; /* next poll time (monotonic ns) */
@@ -70,6 +74,14 @@ struct cbm_watcher {
7074
#define POLL_FILE_STEP 500 /* add 1s per this many files */
7175
#define POLL_MAX_MS 60000
7276

77+
/* Stale-root pruning (#286): a watched project whose root directory stays
78+
* missing is pruned — its cached DB is deleted and the watch entry removed.
79+
* Deletion is destructive (the DB can hold user-authored data such as the
80+
* ADR), so it requires BOTH a streak of consecutive missing polls AND a
81+
* sustained-absence grace window measured from the streak's first miss. */
82+
#define MISSING_ROOT_DELETE_AFTER 3
83+
#define PRUNE_GRACE_DEFAULT_S 600 /* 10 min; override: CBM_WATCHER_PRUNE_GRACE_S */
84+
7385
/* Sleep chunk for responsive shutdown (ms) */
7486
#define SLEEP_CHUNK_MS 500
7587

@@ -245,6 +257,107 @@ static void state_free(project_state_t *s) {
245257
free(s);
246258
}
247259

260+
/* Move a state onto the deferred-free list (caller holds projects_lock).
261+
* The state may still be referenced by a poll_once snapshot; poll_once
262+
* drains the list at the start of its next cycle. Falls back to an
263+
* immediate free only if growing the list fails. */
264+
static void defer_state_free(cbm_watcher_t *w, project_state_t *s) {
265+
if (w->pending_free_count >= w->pending_free_cap) {
266+
int new_cap = w->pending_free_cap ? w->pending_free_cap * 2 : 8;
267+
project_state_t **tmp =
268+
realloc(w->pending_free, (size_t)new_cap * sizeof(project_state_t *));
269+
if (tmp) {
270+
w->pending_free = tmp;
271+
w->pending_free_cap = new_cap;
272+
}
273+
}
274+
if (w->pending_free_count < w->pending_free_cap) {
275+
w->pending_free[w->pending_free_count++] = s;
276+
} else {
277+
state_free(s); /* realloc failed — fall back to immediate free */
278+
}
279+
}
280+
281+
/* ── Stale-root pruning (#286) ──────────────────────────────────── */
282+
283+
bool cbm_watcher_root_missing_errno(int err) {
284+
/* Only ENOENT/ENOTDIR mean the root itself is gone. Anything else
285+
* (EACCES, EIO, ELOOP, a transient network mount, macOS TCC permission
286+
* revocation) is uncertainty: the directory may still exist even though
287+
* we cannot see it right now — never treat it as a deletion signal.
288+
* Windows (mingw/UCRT) maps ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND
289+
* to ENOENT, so the same check holds there (same convention as
290+
* find_deleted_files in pipeline_incremental.c). */
291+
return err == ENOENT || err == ENOTDIR;
292+
}
293+
294+
typedef enum {
295+
ROOT_PRESENT = 0, /* stat succeeded and the root is a directory */
296+
ROOT_MISSING, /* genuinely gone: ENOENT/ENOTDIR (or replaced by a non-directory) */
297+
ROOT_UNCERTAIN, /* any other stat failure — must NOT count toward pruning */
298+
} root_status_t;
299+
300+
static root_status_t root_status(const char *root_path, int *out_errno) {
301+
*out_errno = 0;
302+
if (!root_path) {
303+
return ROOT_UNCERTAIN;
304+
}
305+
struct stat st;
306+
if (stat(root_path, &st) == 0) {
307+
/* Exists but is no longer a directory → the root directory is gone. */
308+
return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING;
309+
}
310+
*out_errno = errno;
311+
return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN;
312+
}
313+
314+
/* Sustained-absence window (seconds) before a missing root may be pruned.
315+
* Generous default: 10 minutes. Override with CBM_WATCHER_PRUNE_GRACE_S
316+
* (>= 0; 0 prunes as soon as the missing-poll streak is reached). Read on
317+
* each call so tests/operators can adjust via setenv without a restart —
318+
* same convention as cbm_max_file_bytes in limits.c. */
319+
static long prune_grace_s(void) {
320+
const char *raw = getenv("CBM_WATCHER_PRUNE_GRACE_S");
321+
if (raw && raw[0]) {
322+
errno = 0;
323+
char *end = NULL;
324+
long v = strtol(raw, &end, 10);
325+
if (errno == 0 && end != raw && *end == '\0' && v >= 0) {
326+
return v;
327+
}
328+
/* Unparseable / negative → fall through to the safe default. */
329+
}
330+
return PRUNE_GRACE_DEFAULT_S;
331+
}
332+
333+
/* Format int to string for logging (poll thread only, one use per call). */
334+
static const char *itoa_buf(int v) {
335+
static CBM_TLS char buf[CBM_SZ_32];
336+
snprintf(buf, sizeof(buf), "%d", v);
337+
return buf;
338+
}
339+
340+
static void delete_cached_project_db(const char *project_name) {
341+
if (!cbm_validate_project_name(project_name)) {
342+
return;
343+
}
344+
345+
const char *cache_dir = cbm_resolve_cache_dir();
346+
if (!cache_dir) {
347+
return;
348+
}
349+
350+
char path[CBM_SZ_1K];
351+
char wal[CBM_SZ_1K];
352+
char shm[CBM_SZ_1K];
353+
snprintf(path, sizeof(path), "%s/%s.db", cache_dir, project_name);
354+
snprintf(wal, sizeof(wal), "%s-wal", path);
355+
snprintf(shm, sizeof(shm), "%s-shm", path);
356+
(void)cbm_unlink(path);
357+
(void)cbm_unlink(wal);
358+
(void)cbm_unlink(shm);
359+
}
360+
248361
/* Hash table foreach callback to free state entries */
249362
static void free_state_entry(const char *key, void *val, void *ud) {
250363
(void)key;
@@ -336,20 +449,7 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) {
336449
/* Defer free: the state may still be referenced by a poll_once
337450
* snapshot taken before we acquired the lock. poll_once will
338451
* drain this list at the start of its next cycle. */
339-
if (w->pending_free_count >= w->pending_free_cap) {
340-
int new_cap = w->pending_free_cap ? w->pending_free_cap * 2 : 8;
341-
project_state_t **tmp =
342-
realloc(w->pending_free, (size_t)new_cap * sizeof(project_state_t *));
343-
if (tmp) {
344-
w->pending_free = tmp;
345-
w->pending_free_cap = new_cap;
346-
}
347-
}
348-
if (w->pending_free_count < w->pending_free_cap) {
349-
w->pending_free[w->pending_free_count++] = s;
350-
} else {
351-
state_free(s); /* realloc failed — fall back to immediate free */
352-
}
452+
defer_state_free(w, s);
353453
removed = true;
354454
}
355455
cbm_mutex_unlock(&w->projects_lock);
@@ -437,6 +537,32 @@ typedef struct {
437537
int reindexed;
438538
} poll_ctx_t;
439539

540+
static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) {
541+
if (!w || !s || !s->project_name) {
542+
return;
543+
}
544+
545+
char project_name[CBM_SZ_1K];
546+
snprintf(project_name, sizeof(project_name), "%s", s->project_name);
547+
548+
bool removed = false;
549+
cbm_mutex_lock(&w->projects_lock);
550+
project_state_t *current = cbm_ht_get(w->projects, project_name);
551+
if (current == s) {
552+
delete_cached_project_db(project_name);
553+
cbm_ht_delete(w->projects, project_name);
554+
/* Deferred free (same discipline as cbm_watcher_unwatch): this
555+
* state is referenced by the poll_once snapshot iterating us. */
556+
defer_state_free(w, s);
557+
removed = true;
558+
}
559+
cbm_mutex_unlock(&w->projects_lock);
560+
561+
if (removed) {
562+
cbm_log_info("watcher.root_pruned", "project", project_name);
563+
}
564+
}
565+
440566
static void poll_project(const char *key, void *val, void *ud) {
441567
(void)key;
442568
poll_ctx_t *ctx = ud;
@@ -445,6 +571,44 @@ static void poll_project(const char *key, void *val, void *ud) {
445571
return;
446572
}
447573

574+
/* Stale-root pruning (#286): classify the root BEFORE the baseline /
575+
* is_git / interval gates so vanished roots are noticed even for
576+
* non-git projects and regardless of adaptive backoff. */
577+
int stat_errno = 0;
578+
root_status_t rs = root_status(s->root_path, &stat_errno);
579+
if (rs == ROOT_UNCERTAIN) {
580+
/* EACCES / EIO / network blip / TCC revocation — the root may still
581+
* exist. Never count toward pruning; restart the streak so only an
582+
* uninterrupted run of genuine ENOENT/ENOTDIR observations can
583+
* delete user data. */
584+
if (s->missing_root_count > 0) {
585+
s->missing_root_count = 0;
586+
s->first_missing_ms = 0;
587+
}
588+
cbm_log_warn("watcher.root_stat_error", "project", s->project_name, "path", s->root_path,
589+
"errno", itoa_buf(stat_errno));
590+
return;
591+
}
592+
if (rs == ROOT_MISSING) {
593+
uint64_t now_ms = cbm_now_ms();
594+
if (s->missing_root_count == 0) {
595+
s->first_missing_ms = now_ms;
596+
}
597+
s->missing_root_count++;
598+
cbm_log_warn("watcher.root_missing", "project", s->project_name, "path", s->root_path,
599+
"polls", itoa_buf(s->missing_root_count));
600+
if (s->missing_root_count >= MISSING_ROOT_DELETE_AFTER &&
601+
now_ms - s->first_missing_ms >= (uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC) {
602+
prune_missing_project(ctx->w, s);
603+
}
604+
return;
605+
}
606+
if (s->missing_root_count > 0) {
607+
cbm_log_info("watcher.root_restored", "project", s->project_name, "path", s->root_path);
608+
s->missing_root_count = 0;
609+
s->first_missing_ms = 0;
610+
}
611+
448612
/* Initialize baseline on first poll */
449613
if (!s->baseline_done) {
450614
init_baseline(s);

src/watcher/watcher.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,12 @@ int cbm_watcher_watch_count(cbm_watcher_t *w);
7070
/* Return the adaptive poll interval (ms) for a given file count. */
7171
int cbm_watcher_poll_interval_ms(int file_count);
7272

73+
/* Classify a stat() errno observed on a watched project root: returns true
74+
* only for values that mean the root itself is gone (ENOENT, ENOTDIR) and
75+
* may count toward stale-root pruning (#286). Any other failure (EACCES,
76+
* EIO, transient mounts, macOS TCC revocation) must NOT count — the cached
77+
* DB holds user-authored data and is unrecoverable once pruned. Exposed
78+
* for direct unit testing with injected errno values. */
79+
bool cbm_watcher_root_missing_errno(int err);
80+
7381
#endif /* CBM_WATCHER_H */

0 commit comments

Comments
 (0)