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 */
249362static 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+
440566static 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 );
0 commit comments