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>
1812 lines
71 KiB
C
1812 lines
71 KiB
C
/*
|
|
* watcher.c — Git-based file change watcher.
|
|
*
|
|
* Strategy: git status + HEAD tracking (the most reliable approach).
|
|
* Non-git projects are NOT polled by default. With the opt-in
|
|
* cbm_watcher_set_poll_non_git(w, true) (config key `watch_non_git`, #1948)
|
|
* they are polled with a tree signature instead: the indexer's own discovery
|
|
* walk (same ignore rules), folded over (relative path, size, mtime).
|
|
*
|
|
*
|
|
* Per-project state tracks:
|
|
* - Last git HEAD hash (detects commits, checkout, pull)
|
|
* - Dirty-state signature (#937): porcelain entries + per-file size/mtime,
|
|
* so a persistently dirty tree reindexes once per distinct state instead
|
|
* of on every poll (write amplification)
|
|
* - Last poll time + adaptive interval
|
|
* - Whether the project is a git repo
|
|
*
|
|
* Baselines are committed only after a successful reindex; busy-skips and
|
|
* failed runs leave them untouched so the change is retried, never lost.
|
|
*
|
|
* Adaptive interval: 5s base + 1s per 500 files, capped at 60s.
|
|
* Matches the Go watcher's `pollInterval()` logic.
|
|
*/
|
|
#include <stdint.h>
|
|
#include "watcher/watcher.h"
|
|
#include "store/store.h"
|
|
#include "foundation/constants.h"
|
|
#include "foundation/log.h"
|
|
#include "foundation/hash_table.h"
|
|
#include "foundation/compat.h"
|
|
#include "foundation/compat_thread.h"
|
|
#include "foundation/compat_fs.h"
|
|
#include "foundation/platform.h"
|
|
#include "foundation/str_util.h"
|
|
#include "foundation/subprocess.h"
|
|
#include "pipeline/artifact.h" /* CBM_ARTIFACT_DIR: the indexer's own output directory */
|
|
#include "discover/discover.h" /* cbm_discover: the non-git tree signature (#1948) */
|
|
#ifdef _WIN32
|
|
#include "foundation/win_utf8.h"
|
|
#define WIN32_LEAN_AND_MEAN
|
|
#include <windows.h>
|
|
#endif
|
|
|
|
#include <errno.h>
|
|
#include <limits.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
#include <stdatomic.h>
|
|
#include <sys/stat.h>
|
|
|
|
/* ── Per-project state ──────────────────────────────────────────── */
|
|
|
|
typedef struct {
|
|
char *project_name;
|
|
char *root_path;
|
|
/* poll_once snapshots retain state pointers after projects_lock is
|
|
* released. Unwatch/replacement tombstones a removed state so a later
|
|
* snapshot entry cannot admit new indexing work. */
|
|
atomic_bool registered;
|
|
/* Published only while one supervised Git command is active. Access is
|
|
* serialized by watcher->projects_lock; cancellation itself is atomic. */
|
|
cbm_subprocess_t *active_git;
|
|
/* Room for Git's 64-hex SHA-256 object ID plus newline/NUL while reading. */
|
|
char last_head[CBM_SZ_128]; /* git HEAD hash (committed baseline) */
|
|
bool is_git; /* false → tree-polled if tree_poll, else skipped */
|
|
bool tree_poll; /* non-git root polled by tree signature (#1948) */
|
|
bool baseline_done; /* true after first poll */
|
|
int missing_root_count; /* consecutive polls where root was missing (ENOENT/ENOTDIR) */
|
|
uint64_t first_missing_ms; /* cbm_now_ms() of the streak's first miss (0 = no streak) */
|
|
int file_count; /* approximate, for interval calc */
|
|
int interval_ms; /* adaptive poll interval */
|
|
int64_t next_poll_ns; /* next poll time (monotonic ns) */
|
|
/* Dirty-state signature (#937): a persistently dirty worktree must
|
|
* reindex once per DISTINCT dirty state, not on every poll. Baselines
|
|
* are committed only after a SUCCESSFUL reindex (busy-skips and failed
|
|
* runs retry); check_changes stages its observations in the pending_*
|
|
* fields. 0 = clean tree. */
|
|
uint64_t last_dirty_sig; /* committed dirty-state signature */
|
|
uint64_t pending_dirty_sig; /* observed at check time */
|
|
/* Non-git tree signature (#1948), same commit discipline as the dirty
|
|
* signature. 0 = unknown: the baseline never adopts the tree as already
|
|
* indexed, so the first poll reindexes once (at-least-once, like a tree
|
|
* that is already dirty at a git baseline). Signatures are never 0. */
|
|
uint64_t last_tree_sig;
|
|
uint64_t pending_tree_sig;
|
|
char pending_head[CBM_SZ_128]; /* HEAD observed at check time */
|
|
/* Consecutive hard index failures (index_fn < 0). A hard error is
|
|
* usually persistent — a poisoned coordination endpoint, an unreadable
|
|
* DB — so retrying it at the plain poll interval re-forks a worker that
|
|
* fails identically, for as long as the daemon lives. #937 deliberately
|
|
* leaves the baseline uncommitted so the change is never lost; this
|
|
* decays the retry cadence so "never lost" does not also mean "retried
|
|
* forever". Reset to 0 by any successful reindex. */
|
|
int index_failures;
|
|
/* Hop from root_path up to the repository root ("" when they are the same),
|
|
* from `rev-parse --show-cdup`. Porcelain paths are repository-relative, so
|
|
* the signature needs this to stat them. Resolved once at baseline. */
|
|
char repo_cdup[CBM_SZ_4K];
|
|
/* Published status for cbm_watcher_project_info (#2167). The poll path
|
|
* writes these while index_status reads them from a session thread, so
|
|
* they are atomics: the diagnostic read is never a data race. */
|
|
atomic_int status_strategy; /* cbm_watcher_strategy_t */
|
|
atomic_int status_interval_ms; /* mirrors interval_ms */
|
|
_Atomic int64_t status_last_scan_s; /* wall-clock seconds; 0 = none yet */
|
|
} project_state_t;
|
|
|
|
/* ── Watcher struct ─────────────────────────────────────────────── */
|
|
|
|
struct cbm_watcher {
|
|
cbm_store_t *store;
|
|
cbm_index_fn index_fn;
|
|
void *user_data;
|
|
CBMHashTable *projects; /* name → project_state_t* */
|
|
cbm_mutex_t projects_lock;
|
|
/* Serializes callback replacement with the entire destructive prune
|
|
* transaction so a borrowed daemon context cannot be freed mid-callback. */
|
|
cbm_mutex_t coordination_lock;
|
|
cbm_watcher_project_mutation_begin_fn mutation_begin;
|
|
cbm_watcher_project_mutation_end_fn mutation_end;
|
|
cbm_watcher_project_pruned_fn project_pruned;
|
|
void *mutation_context;
|
|
atomic_int stopped;
|
|
/* Opt-in (#1948): poll non-git roots by tree signature. Default false —
|
|
* non-git roots are not watched. Read at each project's baseline. */
|
|
atomic_bool poll_non_git;
|
|
/* Deferred-free list: freed after the next poll_once. */
|
|
project_state_t **pending_free;
|
|
int pending_free_count;
|
|
int pending_free_cap;
|
|
};
|
|
|
|
/* ── Constants ─────────────────────────────────────────────────── */
|
|
|
|
/* Time unit conversions */
|
|
#define NS_PER_SEC 1000000000LL
|
|
#define US_PER_MS 1000000LL
|
|
|
|
/* Adaptive poll interval parameters (ms) */
|
|
#define POLL_BASE_MS 5000
|
|
#define POLL_FILE_STEP 500 /* add 1s per this many files */
|
|
#define POLL_MAX_MS 60000
|
|
|
|
/* Hard index-failure backoff. Doubling per consecutive failure, capped, so a
|
|
* persistently failing project costs ~12 attempts/hour instead of ~480 while
|
|
* still recovering on its own within the ceiling once the cause clears. The
|
|
* shift cap keeps the intermediate value well inside int64 for any interval. */
|
|
#define INDEX_FAIL_SHIFT_MAX 6
|
|
#define INDEX_FAIL_CEILING_MS 300000 /* 5 min */
|
|
/* Log a distinct line once the failures are clearly not transient, so the
|
|
* daemon log names the stuck project instead of only repeating the warning. */
|
|
#define INDEX_FAIL_SUSTAINED 10
|
|
|
|
/* Stale-root pruning (#286): a watched project whose root directory stays
|
|
* missing is pruned — its cached DB is deleted and the watch entry removed.
|
|
* Deletion is destructive (the DB can hold user-authored data such as the
|
|
* ADR), so it requires BOTH a streak of consecutive missing polls AND a
|
|
* sustained-absence grace window measured from the streak's first miss. */
|
|
#define MISSING_ROOT_DELETE_AFTER 3
|
|
#define PRUNE_GRACE_DEFAULT_S 600 /* 10 min; override: CBM_WATCHER_PRUNE_GRACE_S */
|
|
|
|
/* Sleep chunk for responsive shutdown (ms) */
|
|
#define SLEEP_CHUNK_MS 500
|
|
|
|
/* Git is external and repository-controlled configuration may activate slow
|
|
* helpers (for example fsmonitor). Every invocation therefore has both a hard
|
|
* wall-clock deadline and a finite capture budget. */
|
|
#define WATCHER_GIT_DEADLINE_MS 30000
|
|
#define WATCHER_GIT_OUTPUT_MAX (64U * 1024U * 1024U)
|
|
#define WATCHER_GIT_HEAD_MAX 4096U
|
|
#define WATCHER_GIT_POLL_US 10000U
|
|
|
|
/* ── Time helper ────────────────────────────────────────────────── */
|
|
|
|
static int64_t now_ns(void) {
|
|
struct timespec ts;
|
|
cbm_clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
return ((int64_t)ts.tv_sec * NS_PER_SEC) + ts.tv_nsec;
|
|
}
|
|
|
|
/* ── Adaptive interval ──────────────────────────────────────────── */
|
|
|
|
int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures) {
|
|
if (interval_ms < 0) {
|
|
interval_ms = 0;
|
|
}
|
|
if (consecutive_failures <= 0) {
|
|
return interval_ms;
|
|
}
|
|
int shift =
|
|
consecutive_failures < INDEX_FAIL_SHIFT_MAX ? consecutive_failures : INDEX_FAIL_SHIFT_MAX;
|
|
int64_t delay = (int64_t)interval_ms << shift;
|
|
if (delay > INDEX_FAIL_CEILING_MS) {
|
|
delay = INDEX_FAIL_CEILING_MS;
|
|
}
|
|
/* Backing off must never schedule SOONER than the project's own cadence.
|
|
* Unreachable today (POLL_MAX_MS < the ceiling), but clamping here keeps
|
|
* the function monotonic for every input rather than only for the inputs
|
|
* the current constants can produce — the caller's contract is "a delay
|
|
* that never shrinks as failures accumulate". */
|
|
return (int)(delay < interval_ms ? interval_ms : delay);
|
|
}
|
|
|
|
int cbm_watcher_poll_interval_ms(int file_count) {
|
|
int ms = POLL_BASE_MS + ((file_count / POLL_FILE_STEP) * CBM_MSEC_PER_SEC);
|
|
if (ms > POLL_MAX_MS) {
|
|
ms = POLL_MAX_MS;
|
|
}
|
|
return ms;
|
|
}
|
|
|
|
/* ── Git helpers ────────────────────────────────────────────────── */
|
|
|
|
typedef struct {
|
|
char path[CBM_SZ_4K];
|
|
size_t size;
|
|
} watcher_git_output_t;
|
|
|
|
typedef enum {
|
|
WATCHER_GIT_OK = 0,
|
|
WATCHER_GIT_COMMAND_FAILED,
|
|
WATCHER_GIT_CANCELLED,
|
|
WATCHER_GIT_DEADLINE,
|
|
WATCHER_GIT_OUTPUT_LIMIT,
|
|
WATCHER_GIT_SUPERVISION_FAILED,
|
|
} watcher_git_status_t;
|
|
|
|
static void watcher_git_output_cleanup(watcher_git_output_t *output) {
|
|
if (output && output->path[0]) {
|
|
(void)cbm_unlink(output->path);
|
|
output->path[0] = '\0';
|
|
output->size = 0;
|
|
}
|
|
}
|
|
|
|
static bool watcher_git_output_create(watcher_git_output_t *output) {
|
|
if (!output) {
|
|
return false;
|
|
}
|
|
memset(output, 0, sizeof(*output));
|
|
int written =
|
|
snprintf(output->path, sizeof(output->path), "%s/cbm-watcher-git-XXXXXX", cbm_tmpdir());
|
|
if (written <= 0 || written >= (int)sizeof(output->path)) {
|
|
output->path[0] = '\0';
|
|
return false;
|
|
}
|
|
int descriptor = cbm_mkstemp(output->path);
|
|
if (descriptor < 0) {
|
|
output->path[0] = '\0';
|
|
return false;
|
|
}
|
|
#ifdef _WIN32
|
|
bool closed = _close(descriptor) == 0;
|
|
#else
|
|
bool closed = close(descriptor) == 0;
|
|
#endif
|
|
if (!closed) {
|
|
watcher_git_output_cleanup(output);
|
|
}
|
|
return closed;
|
|
}
|
|
|
|
#ifdef _WIN32
|
|
/* CreateProcessW does not search PATH when lpApplicationName is non-NULL.
|
|
* Resolve Git ourselves, and deliberately accept only absolute PATH entries:
|
|
* empty/relative entries would reintroduce Windows' current-directory search. */
|
|
static bool watcher_windows_path_absolute(const wchar_t *path) {
|
|
if (!path || wcslen(path) < 3U) {
|
|
return false;
|
|
}
|
|
bool drive = ((path[0] >= L'A' && path[0] <= L'Z') || (path[0] >= L'a' && path[0] <= L'z')) &&
|
|
path[1] == L':' && (path[2] == L'\\' || path[2] == L'/');
|
|
bool unc = (path[0] == L'\\' || path[0] == L'/') && (path[1] == L'\\' || path[1] == L'/') &&
|
|
path[2] != L'\0' && path[2] != L'\\' && path[2] != L'/';
|
|
return drive || unc;
|
|
}
|
|
|
|
static bool watcher_windows_git_candidate(const wchar_t *entry, size_t entry_length,
|
|
char output[CBM_SZ_4K]) {
|
|
while (entry_length > 0U && (entry[0] == L' ' || entry[0] == L'\t')) {
|
|
entry++;
|
|
entry_length--;
|
|
}
|
|
while (entry_length > 0U &&
|
|
(entry[entry_length - 1U] == L' ' || entry[entry_length - 1U] == L'\t')) {
|
|
entry_length--;
|
|
}
|
|
if (entry_length >= 2U && entry[0] == L'"' && entry[entry_length - 1U] == L'"') {
|
|
entry++;
|
|
entry_length -= 2U;
|
|
}
|
|
while (entry_length > 0U && (entry[0] == L' ' || entry[0] == L'\t')) {
|
|
entry++;
|
|
entry_length--;
|
|
}
|
|
while (entry_length > 0U &&
|
|
(entry[entry_length - 1U] == L' ' || entry[entry_length - 1U] == L'\t')) {
|
|
entry_length--;
|
|
}
|
|
if (entry_length == 0U || entry_length >= CBM_SZ_4K) {
|
|
return false;
|
|
}
|
|
wchar_t directory[CBM_SZ_4K];
|
|
memcpy(directory, entry, entry_length * sizeof(*directory));
|
|
directory[entry_length] = L'\0';
|
|
if (!watcher_windows_path_absolute(directory) || wcschr(directory, L'"') != NULL) {
|
|
return false;
|
|
}
|
|
|
|
bool separator = directory[entry_length - 1U] == L'\\' || directory[entry_length - 1U] == L'/';
|
|
wchar_t candidate[CBM_SZ_4K];
|
|
int written =
|
|
swprintf(candidate, CBM_SZ_4K, separator ? L"%lsgit.exe" : L"%ls\\git.exe", directory);
|
|
if (written <= 0 || written >= CBM_SZ_4K) {
|
|
return false;
|
|
}
|
|
DWORD required = GetFullPathNameW(candidate, 0U, NULL, NULL);
|
|
wchar_t *normalized =
|
|
required > 0U ? malloc(((size_t)required + 1U) * sizeof(*normalized)) : NULL;
|
|
DWORD normalized_length =
|
|
normalized ? GetFullPathNameW(candidate, required + 1U, normalized, NULL) : 0U;
|
|
if (!normalized || normalized_length == 0U || normalized_length > required ||
|
|
!watcher_windows_path_absolute(normalized)) {
|
|
free(normalized);
|
|
return false;
|
|
}
|
|
HANDLE file = CreateFileW(normalized, GENERIC_READ | FILE_READ_ATTRIBUTES,
|
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL,
|
|
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL);
|
|
BY_HANDLE_FILE_INFORMATION information;
|
|
bool regular = file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK &&
|
|
GetFileInformationByHandle(file, &information) != 0 &&
|
|
(information.dwFileAttributes &
|
|
(FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0;
|
|
if (file != INVALID_HANDLE_VALUE) {
|
|
(void)CloseHandle(file);
|
|
}
|
|
char *utf8 = regular ? cbm_wide_to_utf8(normalized) : NULL;
|
|
size_t utf8_length = utf8 ? strlen(utf8) : 0U;
|
|
bool valid = utf8 && utf8_length > 0U && utf8_length < CBM_SZ_4K;
|
|
if (valid) {
|
|
memcpy(output, utf8, utf8_length + 1U);
|
|
}
|
|
free(utf8);
|
|
free(normalized);
|
|
return valid;
|
|
}
|
|
|
|
static bool watcher_resolve_git_executable(char output[CBM_SZ_4K]) {
|
|
output[0] = '\0';
|
|
DWORD required = GetEnvironmentVariableW(L"PATH", NULL, 0U);
|
|
wchar_t *path = required > 0U ? malloc((size_t)required * sizeof(*path)) : NULL;
|
|
DWORD length = path ? GetEnvironmentVariableW(L"PATH", path, required) : 0U;
|
|
if (!path || length == 0U || length >= required) {
|
|
free(path);
|
|
return false;
|
|
}
|
|
const wchar_t *entry = path;
|
|
for (const wchar_t *cursor = path;; cursor++) {
|
|
if (*cursor != L';' && *cursor != L'\0') {
|
|
continue;
|
|
}
|
|
if (watcher_windows_git_candidate(entry, (size_t)(cursor - entry), output)) {
|
|
free(path);
|
|
return true;
|
|
}
|
|
if (*cursor == L'\0') {
|
|
break;
|
|
}
|
|
entry = cursor + 1;
|
|
}
|
|
free(path);
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
/* Run one literal argv vector in a contained process tree. active_git is
|
|
* published under projects_lock before supervision starts, so stop/unwatch can
|
|
* request cancellation without racing destruction of the handle. */
|
|
static watcher_git_status_t watcher_git_run(cbm_watcher_t *w, project_state_t *state,
|
|
const char *const *argv, size_t output_limit,
|
|
watcher_git_output_t *output) {
|
|
if (!w || !state || !argv || !argv[0]) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
if (output) {
|
|
memset(output, 0, sizeof(*output));
|
|
}
|
|
if (output_limit > 0 && (!output || !watcher_git_output_create(output))) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
|
|
#ifdef _WIN32
|
|
char git_executable[CBM_SZ_4K];
|
|
if (!watcher_resolve_git_executable(git_executable)) {
|
|
watcher_git_output_cleanup(output);
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
#endif
|
|
|
|
cbm_proc_opts_t options = {
|
|
#ifdef _WIN32
|
|
.bin = git_executable,
|
|
#else
|
|
.bin = argv[0],
|
|
#endif
|
|
.argv = argv,
|
|
.log_file = output_limit > 0 ? output->path : NULL,
|
|
/* A wall deadline below is authoritative. Binary `status -z` output
|
|
* deliberately has no line-based quiet-timeout semantics. */
|
|
.quiet_timeout_ms = 0,
|
|
.cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS,
|
|
.delete_log_on_exit = false,
|
|
.strip_git_repo_env = true, /* #2003: an inherited GIT_DIR must not redirect -C */
|
|
};
|
|
cbm_subprocess_t *process = NULL;
|
|
if (cbm_subprocess_spawn(&options, &process) != 0) {
|
|
watcher_git_output_cleanup(output);
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
|
|
bool cancelled = false;
|
|
bool deadline_expired = false;
|
|
bool output_exceeded = false;
|
|
bool publication_conflict = false;
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
if (state->active_git) {
|
|
publication_conflict = true;
|
|
cancelled = cbm_subprocess_request_cancel(process);
|
|
} else {
|
|
state->active_git = process;
|
|
}
|
|
if (!cancelled && (atomic_load_explicit(&w->stopped, memory_order_acquire) ||
|
|
!atomic_load_explicit(&state->registered, memory_order_acquire))) {
|
|
cancelled = cbm_subprocess_request_cancel(process);
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
|
|
uint64_t started_at = cbm_now_ms();
|
|
cbm_proc_result_t result = {0};
|
|
for (;;) {
|
|
uint64_t now = cbm_now_ms();
|
|
if (!cancelled && (atomic_load_explicit(&w->stopped, memory_order_acquire) ||
|
|
!atomic_load_explicit(&state->registered, memory_order_acquire))) {
|
|
cancelled = cbm_subprocess_request_cancel(process);
|
|
}
|
|
if (!cancelled && now - started_at >= WATCHER_GIT_DEADLINE_MS) {
|
|
deadline_expired = true;
|
|
cancelled = cbm_subprocess_request_cancel(process);
|
|
}
|
|
if (!cancelled && output_limit > 0) {
|
|
int64_t observed_size = cbm_file_size(output->path);
|
|
if (observed_size > 0 && (uint64_t)observed_size > output_limit) {
|
|
output_exceeded = true;
|
|
cancelled = cbm_subprocess_request_cancel(process);
|
|
}
|
|
}
|
|
cbm_proc_poll_t polled = cbm_subprocess_poll(process, &result);
|
|
if (polled == CBM_PROC_POLL_TERMINAL) {
|
|
break;
|
|
}
|
|
if (polled == CBM_PROC_POLL_ERROR) {
|
|
/* Valid owned handles have no ERROR transition. Fail closed, but
|
|
* continue polling so the contained tree is never abandoned. */
|
|
cancelled = cbm_subprocess_request_cancel(process) || cancelled;
|
|
}
|
|
cbm_usleep(WATCHER_GIT_POLL_US);
|
|
}
|
|
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
if (state->active_git == process) {
|
|
state->active_git = NULL;
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
cbm_subprocess_destroy(process);
|
|
|
|
watcher_git_status_t status = WATCHER_GIT_OK;
|
|
if (publication_conflict || !result.tree_quiesced || result.supervision_failed) {
|
|
status = WATCHER_GIT_SUPERVISION_FAILED;
|
|
} else if (output_exceeded) {
|
|
status = WATCHER_GIT_OUTPUT_LIMIT;
|
|
} else if (deadline_expired) {
|
|
status = WATCHER_GIT_DEADLINE;
|
|
} else if (result.cancellation_requested || cancelled) {
|
|
status = WATCHER_GIT_CANCELLED;
|
|
} else if (result.outcome != CBM_PROC_CLEAN) {
|
|
status = WATCHER_GIT_COMMAND_FAILED;
|
|
}
|
|
|
|
if (status == WATCHER_GIT_OK && output_limit > 0) {
|
|
int64_t final_size = cbm_file_size(output->path);
|
|
if (final_size < 0) {
|
|
status = WATCHER_GIT_SUPERVISION_FAILED;
|
|
} else if ((uint64_t)final_size > output_limit) {
|
|
status = WATCHER_GIT_OUTPUT_LIMIT;
|
|
} else {
|
|
output->size = (size_t)final_size;
|
|
}
|
|
}
|
|
if (status != WATCHER_GIT_OK) {
|
|
watcher_git_output_cleanup(output);
|
|
}
|
|
if (status == WATCHER_GIT_DEADLINE || status == WATCHER_GIT_OUTPUT_LIMIT ||
|
|
status == WATCHER_GIT_SUPERVISION_FAILED) {
|
|
cbm_log_warn("watcher.git.failed", "project", state->project_name, "reason",
|
|
status == WATCHER_GIT_DEADLINE
|
|
? "deadline"
|
|
: (status == WATCHER_GIT_OUTPUT_LIMIT ? "output_limit" : "supervision"));
|
|
}
|
|
return status;
|
|
}
|
|
|
|
static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *state) {
|
|
const char *argv[] = {"git", "-C", state->root_path, "rev-parse", "--git-dir", NULL};
|
|
return watcher_git_run(w, state, argv, 0, NULL);
|
|
}
|
|
|
|
/* True when root_path carries its OWN repository marker: a `.git` directory, or
|
|
* a `.git` file (the gitlink form used by linked worktrees and initialized
|
|
* submodules). Deliberately a filesystem check, not a git invocation — the whole
|
|
* point is to learn something `rev-parse` cannot tell us, because it walks up. */
|
|
static bool git_has_own_dot_git(const char *root_path) {
|
|
char path[CBM_SZ_4K];
|
|
int written = snprintf(path, sizeof(path), "%s/.git", root_path);
|
|
if (written <= 0 || (size_t)written >= sizeof(path)) {
|
|
return false;
|
|
}
|
|
struct stat st;
|
|
return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode));
|
|
}
|
|
|
|
/* Does the ancestor repository actually track anything inside this directory?
|
|
* Emptiness distinguishes "a scratch folder that merely sits under a repo" from
|
|
* "a genuine subdirectory of one". Output is capped: we only care whether the
|
|
* first byte exists, never what it is. */
|
|
static watcher_git_status_t git_tracks_anything_here(cbm_watcher_t *w, project_state_t *state,
|
|
bool *tracked_out) {
|
|
*tracked_out = false;
|
|
const char *argv[] = {"git", "-C", state->root_path, "ls-files", "-z", "--", ".", NULL};
|
|
watcher_git_output_t output;
|
|
watcher_git_status_t status = watcher_git_run(w, state, argv, WATCHER_GIT_HEAD_MAX, &output);
|
|
if (status != WATCHER_GIT_OK) {
|
|
return status;
|
|
}
|
|
FILE *fp = cbm_fopen(output.path, "rb");
|
|
if (fp) {
|
|
*tracked_out = fgetc(fp) != EOF;
|
|
(void)fclose(fp);
|
|
}
|
|
watcher_git_output_cleanup(&output);
|
|
return fp ? WATCHER_GIT_OK : WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
|
|
/* Relative hop from root_path up to the repository root, as `git rev-parse
|
|
* --show-cdup` reports it ("" at the root, "../" one level down, and so on).
|
|
*
|
|
* This matters because `git status --porcelain` prints paths relative to the
|
|
* REPOSITORY root, while the signature stats them relative to root_path. For a
|
|
* project watched at the repository root the two coincide and the bug is
|
|
* invisible; for a subdirectory project every stat silently misses, and the
|
|
* signature quietly degrades to text-only — losing the size/mtime component
|
|
* that makes an edit to an already-dirty file detectable.
|
|
*
|
|
* --show-cdup rather than --show-toplevel: under MSYS/Cygwin git, --show-toplevel
|
|
* returns a translated absolute path (/c/... or a drive-letter form) that does
|
|
* not join cleanly onto the native root_path we hold. A relative hop composes
|
|
* correctly on every platform because it never leaves our own path space. */
|
|
static watcher_git_status_t git_repo_cdup(cbm_watcher_t *w, project_state_t *state, char *out,
|
|
size_t out_size) {
|
|
if (!out || out_size < 2) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
out[0] = '\0';
|
|
const char *argv[] = {"git", "-C", state->root_path, "rev-parse", "--show-cdup", NULL};
|
|
watcher_git_output_t output;
|
|
watcher_git_status_t status = watcher_git_run(w, state, argv, WATCHER_GIT_HEAD_MAX, &output);
|
|
if (status != WATCHER_GIT_OK) {
|
|
return status;
|
|
}
|
|
FILE *file = cbm_fopen(output.path, "rb");
|
|
bool read = file && fgets(out, (int)out_size, file) != NULL;
|
|
if (file) {
|
|
(void)fclose(file);
|
|
}
|
|
watcher_git_output_cleanup(&output);
|
|
if (!read) {
|
|
/* At the repository root git prints an empty line; that is success. */
|
|
out[0] = '\0';
|
|
return WATCHER_GIT_OK;
|
|
}
|
|
size_t len = strlen(out);
|
|
while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r')) {
|
|
out[--len] = '\0';
|
|
}
|
|
return WATCHER_GIT_OK;
|
|
}
|
|
|
|
static watcher_git_status_t git_head(cbm_watcher_t *w, project_state_t *state, char *out,
|
|
size_t out_size) {
|
|
if (!out || out_size < 2) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
out[0] = '\0';
|
|
const char *argv[] = {"git", "-C", state->root_path, "rev-parse", "HEAD", NULL};
|
|
watcher_git_output_t output;
|
|
watcher_git_status_t status = watcher_git_run(w, state, argv, WATCHER_GIT_HEAD_MAX, &output);
|
|
if (status != WATCHER_GIT_OK) {
|
|
return status;
|
|
}
|
|
FILE *file = cbm_fopen(output.path, "rb");
|
|
bool read = file && fgets(out, (int)out_size, file) != NULL;
|
|
int extra = file ? fgetc(file) : EOF;
|
|
if (file) {
|
|
read = fclose(file) == 0 && read;
|
|
}
|
|
watcher_git_output_cleanup(&output);
|
|
if (!read || extra != EOF) {
|
|
out[0] = '\0';
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
size_t len = strlen(out);
|
|
while (len > 0 && (out[len - SKIP_ONE] == '\n' || out[len - SKIP_ONE] == '\r')) {
|
|
out[--len] = '\0';
|
|
}
|
|
return len > 0 ? WATCHER_GIT_OK : WATCHER_GIT_COMMAND_FAILED;
|
|
}
|
|
|
|
/* ── Dirty-state signature (#937) ───────────────────────────────── */
|
|
|
|
#define SIG_FNV_OFFSET 1469598103934665603ULL
|
|
#define SIG_FNV_PRIME 1099511628211ULL
|
|
|
|
static uint64_t sig_fold(uint64_t h, const void *data, size_t len) {
|
|
const unsigned char *p = data;
|
|
for (size_t i = 0; i < len; i++) {
|
|
h ^= p[i];
|
|
h *= SIG_FNV_PRIME;
|
|
}
|
|
return h;
|
|
}
|
|
|
|
/* Platform-portable mtime_ns (mirrors pipeline_incremental.c). */
|
|
static int64_t sig_stat_mtime_ns(const struct stat *st) {
|
|
#ifdef __APPLE__
|
|
return ((int64_t)st->st_mtimespec.tv_sec * NS_PER_SEC) + (int64_t)st->st_mtimespec.tv_nsec;
|
|
#elif defined(_WIN32)
|
|
return (int64_t)st->st_mtime * NS_PER_SEC;
|
|
#else
|
|
return ((int64_t)st->st_mtim.tv_sec * NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec;
|
|
#endif
|
|
}
|
|
|
|
/* Fold a listed path's (size, mtime) into the signature so an in-place edit
|
|
* of an already-dirty file still produces a new signature. A failed stat
|
|
* (deleted file, quoting artifact) degrades to the entry text alone — the
|
|
* deletion itself is represented by the porcelain status. */
|
|
/* `rel` is repository-relative (that is what porcelain prints), so it is joined
|
|
* through `cdup` — the hop from root_path up to the repository root — rather than
|
|
* onto root_path directly. cdup is "" when the project IS the repository root,
|
|
* which reduces this to the original join. */
|
|
static uint64_t sig_fold_path_stat(uint64_t h, const char *root_path, const char *cdup,
|
|
const char *rel) {
|
|
char abs[CBM_SZ_4K];
|
|
snprintf(abs, sizeof(abs), "%s/%s%s", root_path, cdup ? cdup : "", rel);
|
|
struct stat st;
|
|
if (stat(abs, &st) == 0) {
|
|
int64_t mt = sig_stat_mtime_ns(&st);
|
|
int64_t sz = (int64_t)st.st_size;
|
|
h = sig_fold(h, &mt, sizeof(mt));
|
|
h = sig_fold(h, &sz, sizeof(sz));
|
|
}
|
|
return h;
|
|
}
|
|
|
|
/* Signature of the current dirty state: FNV-1a over the entries of
|
|
* `git status --porcelain -uall -z` plus each listed path's (size, mtime).
|
|
* Returns 0 for a clean tree. Two polls over an untouched dirty tree yield
|
|
* the same value; editing a dirty file, adding/removing one, or reverting
|
|
* the tree to clean each yield a new one. -uall lists files inside
|
|
* untracked directories individually (a nested addition under `?? dir/`
|
|
* would otherwise be invisible); -z gives unquoted NUL-separated paths that
|
|
* hash identically across polls and stat cleanly. */
|
|
static watcher_git_status_t git_dirty_signature(cbm_watcher_t *w, project_state_t *state,
|
|
uint64_t *signature_out) {
|
|
if (!signature_out) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
*signature_out = 0;
|
|
/* `-- .` scopes the report to the watched directory. Without it a project
|
|
* watched at a sub-package of a monorepo reindexes whenever any SIBLING
|
|
* package changes, because git reports the whole repository's dirty state
|
|
* regardless of -C. Paths stay repository-relative either way, which is
|
|
* what repo_cdup is for.
|
|
*
|
|
* `:(exclude).codebase-memory` drops the indexer's OWN output. After every
|
|
* publish the pipeline re-exports <root>/.codebase-memory/ whenever an
|
|
* artifact already lives there (once persisted, or committed by the team
|
|
* and checked out into every worktree). Folding that write into the
|
|
* signature made each successful reindex look like a NEW dirty state on
|
|
* the next poll, and the daemon re-triggered itself forever (#1953: reap
|
|
* clean -> watcher.changed, 100+ times in 15 minutes, index workers
|
|
* pinned). Nothing under that directory is ever an index input (discovery
|
|
* skips it), so a change there can never require a reindex. The pathspec
|
|
* is CWD-relative, so a project watched at a monorepo sub-package excludes
|
|
* its own artifact directory; it is the same pathspec the exporter's
|
|
* clean-tree probe uses, for the same reason. */
|
|
const char *status_argv[] = {"git",
|
|
"--no-optional-locks",
|
|
"-C",
|
|
state->root_path,
|
|
"status",
|
|
"--porcelain",
|
|
"-uall",
|
|
"-z",
|
|
"--",
|
|
".",
|
|
":(exclude)" CBM_ARTIFACT_DIR,
|
|
NULL};
|
|
watcher_git_output_t output;
|
|
watcher_git_status_t status =
|
|
watcher_git_run(w, state, status_argv, WATCHER_GIT_OUTPUT_MAX, &output);
|
|
if (status != WATCHER_GIT_OK) {
|
|
return status;
|
|
}
|
|
FILE *fp = cbm_fopen(output.path, "rb");
|
|
if (!fp) {
|
|
watcher_git_output_cleanup(&output);
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
|
|
uint64_t h = SIG_FNV_OFFSET;
|
|
bool any = false;
|
|
char entry[CBM_SZ_4K];
|
|
size_t elen = 0;
|
|
bool overflow = false;
|
|
/* Rename/copy entries are followed by a second NUL token (the origin
|
|
* path) that carries no XY prefix — fold it as text, don't stat it. */
|
|
bool origin_token = false;
|
|
int c;
|
|
for (;;) {
|
|
c = fgetc(fp);
|
|
if (c != EOF && c != '\0') {
|
|
if (elen + 1 < sizeof(entry)) {
|
|
entry[elen] = (char)c;
|
|
} else {
|
|
overflow = true; /* keep hashing prefix; skip stat for entry */
|
|
}
|
|
elen++;
|
|
continue;
|
|
}
|
|
size_t stored = elen < sizeof(entry) ? elen : sizeof(entry) - 1;
|
|
entry[stored] = '\0';
|
|
if (stored > 0) {
|
|
any = true;
|
|
h = sig_fold(h, entry, stored);
|
|
h = sig_fold(h, "", 1); /* token separator */
|
|
if (origin_token) {
|
|
origin_token = false;
|
|
} else if (!overflow && stored > 3 && entry[2] == ' ') {
|
|
if (entry[0] == 'R' || entry[0] == 'C') {
|
|
origin_token = true;
|
|
}
|
|
h = sig_fold_path_stat(h, state->root_path, state->repo_cdup, entry + 3);
|
|
}
|
|
}
|
|
elen = 0;
|
|
overflow = false;
|
|
if (c == EOF) {
|
|
break;
|
|
}
|
|
}
|
|
bool parsed = !ferror(fp) && fclose(fp) == 0;
|
|
watcher_git_output_cleanup(&output);
|
|
if (!parsed) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
|
|
#if !defined(_WIN32)
|
|
/* `submodule foreach` necessarily evaluates its final fixed argument in
|
|
* Git's documented POSIX-shell environment. No repository/user text is
|
|
* interpolated into that argument; the outer process is still argv-spawned
|
|
* and its complete descendant tree remains supervised. */
|
|
const char *submodule_argv[] = {
|
|
"git",
|
|
"--no-optional-locks",
|
|
"-C",
|
|
state->root_path,
|
|
"submodule",
|
|
"foreach",
|
|
"--quiet",
|
|
"--recursive",
|
|
"git status --porcelain -uall 2>/dev/null | sed -e \"s@^\\(..\\) @\\1 $displaypath/@\"",
|
|
NULL,
|
|
};
|
|
watcher_git_status_t submodule_status =
|
|
watcher_git_run(w, state, submodule_argv, WATCHER_GIT_OUTPUT_MAX, &output);
|
|
if (submodule_status == WATCHER_GIT_OK) {
|
|
fp = cbm_fopen(output.path, "rb");
|
|
if (!fp) {
|
|
watcher_git_output_cleanup(&output);
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
char line[CBM_SZ_4K];
|
|
while (fgets(line, sizeof(line), fp)) {
|
|
size_t len = strlen(line);
|
|
while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) {
|
|
line[--len] = '\0';
|
|
}
|
|
if (len == 0) {
|
|
continue;
|
|
}
|
|
any = true;
|
|
h = sig_fold(h, line, len);
|
|
h = sig_fold(h, "", 1);
|
|
if (len > 3 && line[2] == ' ') {
|
|
h = sig_fold_path_stat(h, state->root_path, state->repo_cdup, line + 3);
|
|
}
|
|
}
|
|
parsed = !ferror(fp) && fclose(fp) == 0;
|
|
watcher_git_output_cleanup(&output);
|
|
if (!parsed) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
} else if (submodule_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
return submodule_status;
|
|
}
|
|
#endif
|
|
|
|
*signature_out = any ? (h ? h : 1) : 0; /* reserve 0 for "clean" */
|
|
return WATCHER_GIT_OK;
|
|
}
|
|
|
|
/* Count tracked files via git ls-files */
|
|
static watcher_git_status_t git_file_count(cbm_watcher_t *w, project_state_t *state,
|
|
int *count_out) {
|
|
if (!count_out) {
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
*count_out = 0;
|
|
const char *argv[] = {"git", "-C", state->root_path, "ls-files", "-z", NULL};
|
|
watcher_git_output_t output;
|
|
watcher_git_status_t status = watcher_git_run(w, state, argv, WATCHER_GIT_OUTPUT_MAX, &output);
|
|
if (status != WATCHER_GIT_OK) {
|
|
return status;
|
|
}
|
|
FILE *fp = cbm_fopen(output.path, "rb");
|
|
if (!fp) {
|
|
watcher_git_output_cleanup(&output);
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
|
|
/* NUL mode is unambiguous even for tracked filenames containing newlines. */
|
|
int count = 0;
|
|
char buf[CBM_SZ_1K];
|
|
size_t n;
|
|
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (buf[i] == '\0' && count < INT_MAX) {
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
if (ferror(fp) || fclose(fp) != 0) {
|
|
watcher_git_output_cleanup(&output);
|
|
return WATCHER_GIT_SUPERVISION_FAILED;
|
|
}
|
|
watcher_git_output_cleanup(&output);
|
|
*count_out = count;
|
|
return WATCHER_GIT_OK;
|
|
}
|
|
|
|
/* ── Non-git tree signature (#1948) ─────────────────────────────── */
|
|
|
|
static int tree_entry_cmp(const void *a, const void *b) {
|
|
const cbm_file_info_t *fa = a;
|
|
const cbm_file_info_t *fb = b;
|
|
return strcmp(fa->rel_path, fb->rel_path);
|
|
}
|
|
|
|
/* Signature of a non-git tree: FNV-1a over every file the INDEXER would
|
|
* discover (cbm_discover, full mode — the same skip lists, .gitignore stack
|
|
* and .cbmignore), folding each (relative path, size, mtime) in path order so
|
|
* readdir order cannot change the value. Adding, deleting, renaming or editing
|
|
* an indexable file yields a new signature; touching anything discovery skips
|
|
* does not. That includes everything cbm writes itself: the .codebase-memory
|
|
* artifact directory is a built-in skip and the cache directory is pruned by
|
|
* absolute path, so a successful reindex can never look like a new change
|
|
* (the runaway history in init_baseline and #1953). Never returns 0 in
|
|
* *sig_out, which stays reserved for "unknown". */
|
|
static bool tree_signature(const char *root_path, uint64_t *sig_out, int *count_out) {
|
|
cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
|
|
cbm_file_info_t *files = NULL;
|
|
int count = 0;
|
|
if (cbm_discover(root_path, &opts, &files, &count) != 0) {
|
|
return false;
|
|
}
|
|
if (count > 1) {
|
|
qsort(files, (size_t)count, sizeof(*files), tree_entry_cmp);
|
|
}
|
|
uint64_t h = SIG_FNV_OFFSET;
|
|
for (int i = 0; i < count; i++) {
|
|
h = sig_fold(h, files[i].rel_path, strlen(files[i].rel_path) + SKIP_ONE);
|
|
h = sig_fold(h, &files[i].size, sizeof(files[i].size));
|
|
h = sig_fold(h, &files[i].mtime_ns, sizeof(files[i].mtime_ns));
|
|
}
|
|
cbm_discover_free(files, count);
|
|
*sig_out = h ? h : 1;
|
|
*count_out = count;
|
|
return true;
|
|
}
|
|
|
|
/* ── Project state lifecycle ────────────────────────────────────── */
|
|
|
|
static void state_free(project_state_t *s);
|
|
|
|
static project_state_t *state_new(const char *name, const char *root_path) {
|
|
project_state_t *s = calloc(CBM_ALLOC_ONE, sizeof(*s));
|
|
if (!s) {
|
|
return NULL;
|
|
}
|
|
s->project_name = strdup(name);
|
|
s->root_path = strdup(root_path);
|
|
if (!s->project_name || !s->root_path) {
|
|
state_free(s);
|
|
return NULL;
|
|
}
|
|
atomic_init(&s->registered, true);
|
|
s->interval_ms = POLL_BASE_MS;
|
|
atomic_init(&s->status_strategy, CBM_WATCHER_STRATEGY_PENDING);
|
|
atomic_init(&s->status_interval_ms, POLL_BASE_MS);
|
|
atomic_init(&s->status_last_scan_s, 0);
|
|
return s;
|
|
}
|
|
|
|
static void state_free(project_state_t *s) {
|
|
if (!s) {
|
|
return;
|
|
}
|
|
free(s->project_name);
|
|
free(s->root_path);
|
|
free(s);
|
|
}
|
|
|
|
/* Move a state onto the deferred-free list (caller holds projects_lock).
|
|
* The state may still be referenced by a poll_once snapshot; poll_once
|
|
* drains the list at the start of its next cycle. Returns false when
|
|
* growing the list fails (OOM): the state is left untouched and the
|
|
* caller must keep it registered — freeing it immediately here could be
|
|
* a use-after-free against an in-flight poll snapshot. */
|
|
static bool defer_state_free(cbm_watcher_t *w, project_state_t *s) {
|
|
if (w->pending_free_count >= w->pending_free_cap) {
|
|
int new_cap = w->pending_free_cap ? w->pending_free_cap * 2 : 8;
|
|
project_state_t **tmp =
|
|
realloc(w->pending_free, (size_t)new_cap * sizeof(project_state_t *));
|
|
if (!tmp) {
|
|
cbm_log_warn("watcher.unwatch.oom", "project", s->project_name);
|
|
return false;
|
|
}
|
|
w->pending_free = tmp;
|
|
w->pending_free_cap = new_cap;
|
|
}
|
|
w->pending_free[w->pending_free_count++] = s;
|
|
return true;
|
|
}
|
|
|
|
/* ── Stale-root pruning (#286) ──────────────────────────────────── */
|
|
|
|
bool cbm_watcher_root_missing_errno(int err) {
|
|
/* Only ENOENT/ENOTDIR mean the root itself is gone. Anything else
|
|
* (EACCES, EIO, ELOOP, a transient network mount, macOS TCC permission
|
|
* revocation) is uncertainty: the directory may still exist even though
|
|
* we cannot see it right now — never treat it as a deletion signal.
|
|
* Windows (mingw/UCRT) maps ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND
|
|
* to ENOENT, so the same check holds there (same convention as
|
|
* find_deleted_files in pipeline_incremental.c). */
|
|
return err == ENOENT || err == ENOTDIR;
|
|
}
|
|
|
|
typedef enum {
|
|
ROOT_PRESENT = 0, /* stat succeeded and the root is a directory */
|
|
ROOT_MISSING, /* genuinely gone: ENOENT/ENOTDIR (or replaced by a non-directory) */
|
|
ROOT_UNCERTAIN, /* any other stat failure — must NOT count toward pruning */
|
|
} root_status_t;
|
|
|
|
static root_status_t root_status(const char *root_path, int *out_errno) {
|
|
*out_errno = 0;
|
|
if (!root_path) {
|
|
return ROOT_UNCERTAIN;
|
|
}
|
|
struct stat st;
|
|
if (stat(root_path, &st) == 0) {
|
|
/* Exists but is no longer a directory → the root directory is gone. */
|
|
return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING;
|
|
}
|
|
*out_errno = errno;
|
|
return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN;
|
|
}
|
|
|
|
/* Sustained-absence window (seconds) before a missing root may be pruned.
|
|
* Generous default: 10 minutes. Override with CBM_WATCHER_PRUNE_GRACE_S
|
|
* (>= 0; 0 prunes as soon as the missing-poll streak is reached). Read on
|
|
* each call so tests/operators can adjust via setenv without a restart —
|
|
* same convention as cbm_max_file_bytes in limits.c. */
|
|
static long prune_grace_s(void) {
|
|
const char *raw = getenv("CBM_WATCHER_PRUNE_GRACE_S");
|
|
if (raw && raw[0]) {
|
|
errno = 0;
|
|
char *end = NULL;
|
|
long v = strtol(raw, &end, 10);
|
|
if (errno == 0 && end != raw && *end == '\0' && v >= 0) {
|
|
return v;
|
|
}
|
|
/* Unparseable / negative → fall through to the safe default. */
|
|
}
|
|
return PRUNE_GRACE_DEFAULT_S;
|
|
}
|
|
|
|
/* Format int to string for logging (poll thread only, one use per call). */
|
|
static const char *itoa_buf(int v) {
|
|
static CBM_TLS char buf[CBM_SZ_32];
|
|
snprintf(buf, sizeof(buf), "%d", v);
|
|
return buf;
|
|
}
|
|
|
|
static bool watcher_unlink_cached_file(const char *project_name, const char *path,
|
|
const char *artifact) {
|
|
if (cbm_unlink(path) == 0 || errno == ENOENT) {
|
|
return true;
|
|
}
|
|
int unlink_errno = errno;
|
|
char errno_text[CBM_SZ_32];
|
|
snprintf(errno_text, sizeof(errno_text), "%d", unlink_errno);
|
|
cbm_log_warn("watcher.root_prune_delete_failed", "project", project_name, "artifact", artifact,
|
|
"path", path, "errno", errno_text);
|
|
return false;
|
|
}
|
|
|
|
static bool delete_cached_project_db(const char *project_name) {
|
|
if (!cbm_validate_project_name(project_name)) {
|
|
return false;
|
|
}
|
|
|
|
const char *cache_dir = cbm_resolve_cache_dir();
|
|
if (!cache_dir) {
|
|
return false;
|
|
}
|
|
|
|
size_t cache_length = strlen(cache_dir);
|
|
size_t project_length = strlen(project_name);
|
|
if (cache_length > SIZE_MAX - project_length - sizeof("/.db")) {
|
|
return false;
|
|
}
|
|
size_t path_capacity = cache_length + project_length + sizeof("/.db");
|
|
char *path = malloc(path_capacity);
|
|
char *sidecar = malloc(path_capacity + sizeof("-wal"));
|
|
if (!path || !sidecar) {
|
|
free(path);
|
|
free(sidecar);
|
|
return false;
|
|
}
|
|
int written = snprintf(path, path_capacity, "%s/%s.db", cache_dir, project_name);
|
|
bool formatted = written > 0 && (size_t)written < path_capacity;
|
|
bool removed = formatted && watcher_unlink_cached_file(project_name, path, "database");
|
|
/* If the main DB could not be removed, preserve WAL/SHM: together they
|
|
* may be the only recoverable committed generation. */
|
|
if (removed) {
|
|
written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-wal", path);
|
|
removed = written > 0 && (size_t)written < path_capacity + sizeof("-wal") &&
|
|
watcher_unlink_cached_file(project_name, sidecar, "wal");
|
|
}
|
|
if (removed) {
|
|
written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-shm", path);
|
|
removed = written > 0 && (size_t)written < path_capacity + sizeof("-wal") &&
|
|
watcher_unlink_cached_file(project_name, sidecar, "shm");
|
|
}
|
|
free(path);
|
|
free(sidecar);
|
|
return removed;
|
|
}
|
|
|
|
/* Hash table foreach callback to free state entries */
|
|
static void free_state_entry(const char *key, void *val, void *ud) {
|
|
(void)key;
|
|
(void)ud;
|
|
state_free(val);
|
|
}
|
|
|
|
/* ── Watcher lifecycle ──────────────────────────────────────────── */
|
|
|
|
cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void *user_data) {
|
|
cbm_watcher_t *w = calloc(CBM_ALLOC_ONE, sizeof(*w));
|
|
if (!w) {
|
|
return NULL;
|
|
}
|
|
w->store = store;
|
|
w->index_fn = index_fn;
|
|
w->user_data = user_data;
|
|
w->projects = cbm_ht_create(CBM_SZ_32);
|
|
if (!w->projects) {
|
|
free(w);
|
|
return NULL;
|
|
}
|
|
cbm_mutex_init(&w->projects_lock);
|
|
cbm_mutex_init(&w->coordination_lock);
|
|
atomic_init(&w->stopped, 0);
|
|
atomic_init(&w->poll_non_git, false);
|
|
return w;
|
|
}
|
|
|
|
void cbm_watcher_set_poll_non_git(cbm_watcher_t *w, bool enabled) {
|
|
if (w) {
|
|
atomic_store_explicit(&w->poll_non_git, enabled, memory_order_release);
|
|
}
|
|
}
|
|
|
|
void cbm_watcher_free(cbm_watcher_t *w) {
|
|
if (!w) {
|
|
return;
|
|
}
|
|
/* Safety net: ensure stopped is set before draining pending_free.
|
|
* In production the caller should cbm_watcher_stop() + join first. */
|
|
atomic_store(&w->stopped, 1);
|
|
cbm_mutex_lock(&w->coordination_lock);
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
cbm_ht_foreach(w->projects, free_state_entry, NULL);
|
|
cbm_ht_free(w->projects);
|
|
for (int i = 0; i < w->pending_free_count; i++) {
|
|
state_free(w->pending_free[i]);
|
|
}
|
|
free(w->pending_free);
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
cbm_mutex_unlock(&w->coordination_lock);
|
|
cbm_mutex_destroy(&w->projects_lock);
|
|
cbm_mutex_destroy(&w->coordination_lock);
|
|
free(w);
|
|
}
|
|
|
|
void cbm_watcher_set_project_mutation_guard(cbm_watcher_t *w,
|
|
cbm_watcher_project_mutation_begin_fn begin,
|
|
cbm_watcher_project_mutation_end_fn end,
|
|
cbm_watcher_project_pruned_fn pruned, void *context) {
|
|
if (!w) {
|
|
return;
|
|
}
|
|
cbm_mutex_lock(&w->coordination_lock);
|
|
bool valid = begin && end;
|
|
w->mutation_begin = valid ? begin : NULL;
|
|
w->mutation_end = valid ? end : NULL;
|
|
w->project_pruned = valid ? pruned : NULL;
|
|
w->mutation_context = valid ? context : NULL;
|
|
cbm_mutex_unlock(&w->coordination_lock);
|
|
}
|
|
|
|
/* ── Watch list management ──────────────────────────────────────── */
|
|
|
|
bool cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *root_path) {
|
|
if (!w || !project_name || !project_name[0] || !root_path || !root_path[0] ||
|
|
atomic_load_explicit(&w->stopped, memory_order_acquire)) {
|
|
return false;
|
|
}
|
|
|
|
project_state_t *s = state_new(project_name, root_path);
|
|
if (!s) {
|
|
cbm_log_warn("watcher.watch.oom", "project", project_name, "path", root_path);
|
|
return false;
|
|
}
|
|
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
if (atomic_load_explicit(&w->stopped, memory_order_acquire)) {
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
state_free(s);
|
|
return false;
|
|
}
|
|
project_state_t *old = cbm_ht_get(w->projects, project_name);
|
|
if (old && strcmp(old->root_path, s->root_path) == 0) {
|
|
/* Multiple daemon sessions may subscribe to the same canonical
|
|
* project/root. Re-registering that identical watch must not discard
|
|
* its git baseline, pending dirty signature, or immediate-touch state. */
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
state_free(s);
|
|
return true;
|
|
}
|
|
if (old) {
|
|
/* A poll snapshot may still be using the old state, including while
|
|
* its index callback calls back into this function. Queue it before
|
|
* removing the table entry; on OOM, preserve the existing watch. */
|
|
if (!defer_state_free(w, old)) {
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
state_free(s);
|
|
return false;
|
|
}
|
|
cbm_ht_delete(w->projects, project_name);
|
|
}
|
|
cbm_ht_set(w->projects, s->project_name, s);
|
|
bool registered = cbm_ht_get(w->projects, project_name) == s;
|
|
if (!registered && old) {
|
|
/* Restore the original table entry and remove the deferred-free slot.
|
|
* The lock keeps this rollback invisible to poll/watch callers. */
|
|
cbm_ht_set(w->projects, old->project_name, old);
|
|
if (cbm_ht_get(w->projects, project_name) == old) {
|
|
w->pending_free[--w->pending_free_count] = NULL;
|
|
} else {
|
|
atomic_store_explicit(&old->registered, false, memory_order_release);
|
|
if (old->active_git) {
|
|
(void)cbm_subprocess_request_cancel(old->active_git);
|
|
}
|
|
}
|
|
}
|
|
if (registered && old) {
|
|
atomic_store_explicit(&old->registered, false, memory_order_release);
|
|
if (old->active_git) {
|
|
(void)cbm_subprocess_request_cancel(old->active_git);
|
|
}
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
if (!registered) {
|
|
state_free(s);
|
|
cbm_log_warn("watcher.watch.failed", "project", project_name, "reason", "registration");
|
|
return false;
|
|
}
|
|
cbm_log_info("watcher.watch", "project", project_name, "path", root_path);
|
|
return true;
|
|
}
|
|
|
|
void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) {
|
|
if (!w || !project_name) {
|
|
return;
|
|
}
|
|
bool removed = false;
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
project_state_t *s = cbm_ht_get(w->projects, project_name);
|
|
if (s && defer_state_free(w, s)) {
|
|
/* The entry leaves the table only once its state is safely on
|
|
* the deferred-free list; on OOM the watch stays registered. */
|
|
atomic_store_explicit(&s->registered, false, memory_order_release);
|
|
if (s->active_git) {
|
|
(void)cbm_subprocess_request_cancel(s->active_git);
|
|
}
|
|
cbm_ht_delete(w->projects, project_name);
|
|
removed = true;
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
if (removed) {
|
|
cbm_log_info("watcher.unwatch", "project", project_name);
|
|
}
|
|
}
|
|
|
|
void cbm_watcher_touch(cbm_watcher_t *w, const char *project_name) {
|
|
if (!w || !project_name) {
|
|
return;
|
|
}
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
project_state_t *s = cbm_ht_get(w->projects, project_name);
|
|
if (s) {
|
|
/* Reset backoff — poll immediately on next cycle */
|
|
s->next_poll_ns = 0;
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
}
|
|
|
|
int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name) {
|
|
if (!w || !project_name) {
|
|
return -1;
|
|
}
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
project_state_t *s = cbm_ht_get(w->projects, project_name);
|
|
int failures = s ? s->index_failures : -1;
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
return failures;
|
|
}
|
|
|
|
bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name,
|
|
cbm_watcher_project_info_t *out) {
|
|
if (out) {
|
|
memset(out, 0, sizeof(*out));
|
|
}
|
|
if (!w || !project_name || !out) {
|
|
return false;
|
|
}
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
project_state_t *s = cbm_ht_get(w->projects, project_name);
|
|
if (s) {
|
|
out->strategy = (cbm_watcher_strategy_t)atomic_load(&s->status_strategy);
|
|
out->poll_interval_ms = atomic_load(&s->status_interval_ms);
|
|
out->last_scan_unix_s = atomic_load(&s->status_last_scan_s);
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
return s != NULL;
|
|
}
|
|
|
|
int cbm_watcher_watch_count(cbm_watcher_t *w) {
|
|
if (!w) {
|
|
return 0;
|
|
}
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
int count = (int)cbm_ht_count(w->projects);
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
return count;
|
|
}
|
|
|
|
/* ── Single poll cycle ──────────────────────────────────────────── */
|
|
|
|
/* Publish what index_status reports: the strategy, the current cadence and,
|
|
* when a check just completed, the wall-clock time of that scan. */
|
|
static void publish_status(project_state_t *s, bool scanned) {
|
|
atomic_store(&s->status_strategy,
|
|
s->is_git ? CBM_WATCHER_STRATEGY_GIT : CBM_WATCHER_STRATEGY_NONE);
|
|
atomic_store(&s->status_interval_ms, s->interval_ms);
|
|
if (scanned) {
|
|
atomic_store(&s->status_last_scan_s, (int64_t)time(NULL));
|
|
}
|
|
}
|
|
|
|
/* Init baseline for a project: check if git, get HEAD, count files */
|
|
static bool init_baseline(cbm_watcher_t *w, project_state_t *s) {
|
|
struct stat st;
|
|
if (stat(s->root_path, &st) != 0) {
|
|
cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path);
|
|
s->baseline_done = true;
|
|
s->is_git = false;
|
|
publish_status(s, false);
|
|
return true;
|
|
}
|
|
|
|
watcher_git_status_t repository_status = git_repo_status(w, s);
|
|
if (repository_status != WATCHER_GIT_OK && repository_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
return false;
|
|
}
|
|
s->is_git = repository_status == WATCHER_GIT_OK;
|
|
|
|
/* `rev-parse --git-dir` walks UP, so an ordinary folder that merely happens
|
|
* to live under some unrelated repository answers yes. Treating it as a git
|
|
* project is what produced the runaway churn: it inherits the ancestor's
|
|
* dirty state, which is permanently non-empty and has nothing to do with
|
|
* this directory, so every poll looked like a change.
|
|
*
|
|
* A directory is only really git-managed here if it carries its own .git,
|
|
* or the ancestor repository actually tracks something inside it. A
|
|
* genuine sub-package of a monorepo passes the second test; a scratch or
|
|
* gitignored folder sitting under a repo fails both and is treated as a
|
|
* non-git root: NOT polled by default, or polled by its own tree signature
|
|
* when the watch_non_git opt-in is on (#1948). Either way the ancestor's
|
|
* dirty state never reaches it. */
|
|
if (s->is_git && !git_has_own_dot_git(s->root_path)) {
|
|
bool tracked = false;
|
|
watcher_git_status_t tracked_status = git_tracks_anything_here(w, s, &tracked);
|
|
if (tracked_status != WATCHER_GIT_OK && tracked_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
return false;
|
|
}
|
|
if (!tracked) {
|
|
s->is_git = false;
|
|
cbm_log_info("watcher.nested_non_git", "project", s->project_name, "path",
|
|
s->root_path);
|
|
}
|
|
}
|
|
|
|
s->baseline_done = true;
|
|
|
|
if (s->is_git) {
|
|
watcher_git_status_t cdup_status = git_repo_cdup(w, s, s->repo_cdup, sizeof(s->repo_cdup));
|
|
if (cdup_status != WATCHER_GIT_OK && cdup_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
s->baseline_done = false;
|
|
return false;
|
|
}
|
|
watcher_git_status_t head_status = git_head(w, s, s->last_head, sizeof(s->last_head));
|
|
if (head_status != WATCHER_GIT_OK && head_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
s->baseline_done = false;
|
|
return false;
|
|
}
|
|
/* last_dirty_sig stays 0 ("clean known"): a tree that is ALREADY
|
|
* dirty at baseline reindexes once on the first poll — the watcher
|
|
* cannot know whether that state made it into the DB (e.g. server
|
|
* restart with a stale artifact). At-least-once, then the signature
|
|
* gates further polls (#937). */
|
|
int file_count = 0;
|
|
watcher_git_status_t file_count_status = git_file_count(w, s, &file_count);
|
|
if (file_count_status != WATCHER_GIT_OK &&
|
|
file_count_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
s->baseline_done = false;
|
|
return false;
|
|
}
|
|
s->file_count = file_count;
|
|
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
|
|
cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "git", "files",
|
|
s->file_count > 0 ? "yes" : "0");
|
|
} else if (atomic_load_explicit(&w->poll_non_git, memory_order_acquire)) {
|
|
/* #1948 opt-in. last_tree_sig stays 0 ("unknown"): nothing records
|
|
* which tree state the DB holds (edits made while the daemon was down
|
|
* are exactly the silent-staleness case), so the first poll reindexes
|
|
* once and the signature gates every poll after it. The interval is
|
|
* sized from the file count of each signature walk. */
|
|
s->tree_poll = true;
|
|
cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "tree");
|
|
} else {
|
|
cbm_log_info("watcher.baseline", "project", s->project_name, "strategy", "none");
|
|
}
|
|
|
|
s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS);
|
|
publish_status(s, true);
|
|
return true;
|
|
}
|
|
|
|
/* Check if a project has changes. Returns true if reindex needed.
|
|
* Must NOT mutate the committed baselines (last_head, last_dirty_sig):
|
|
* poll_project commits them only after a SUCCESSFUL reindex so that
|
|
* busy-skips and failed runs retry instead of silently losing the change
|
|
* (#937). Observations are staged in the pending_* fields. */
|
|
/* Non-git leg of check_changes (#1948). A failed walk reports "no verdict"
|
|
* (false) so the committed baseline is kept and a later poll retries. */
|
|
static bool check_tree_changes(project_state_t *s, bool *changed_out) {
|
|
uint64_t sig = 0;
|
|
int file_count = 0;
|
|
if (!tree_signature(s->root_path, &sig, &file_count)) {
|
|
cbm_log_warn("watcher.tree_walk_failed", "project", s->project_name, "path", s->root_path);
|
|
/* Retry at the regular cadence, not on every run-loop tick. */
|
|
s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS);
|
|
return false;
|
|
}
|
|
s->pending_tree_sig = sig;
|
|
s->file_count = file_count;
|
|
s->interval_ms = cbm_watcher_poll_interval_ms(file_count);
|
|
*changed_out = sig != s->last_tree_sig;
|
|
return true;
|
|
}
|
|
|
|
static bool check_changes(cbm_watcher_t *w, project_state_t *s, bool *changed_out) {
|
|
if (!changed_out) {
|
|
return false;
|
|
}
|
|
*changed_out = false;
|
|
if (!s->is_git) {
|
|
return s->tree_poll ? check_tree_changes(s, changed_out) : true;
|
|
}
|
|
|
|
bool changed = false;
|
|
|
|
/* Check HEAD movement (commit, checkout, pull) */
|
|
s->pending_head[0] = '\0';
|
|
char head[CBM_SZ_128] = {0};
|
|
watcher_git_status_t head_status = git_head(w, s, head, sizeof(head));
|
|
if (head_status == WATCHER_GIT_OK) {
|
|
if (s->last_head[0] == '\0') {
|
|
/* First observed HEAD: adopt as baseline, not a change. */
|
|
snprintf(s->last_head, sizeof(s->last_head), "%s", head);
|
|
} else if (strcmp(head, s->last_head) != 0) {
|
|
changed = true;
|
|
}
|
|
snprintf(s->pending_head, sizeof(s->pending_head), "%s", head);
|
|
} else if (head_status != WATCHER_GIT_COMMAND_FAILED) {
|
|
return false;
|
|
}
|
|
|
|
/* Working tree: reindex only when the DIRTY STATE ITSELF changed —
|
|
* a persistently dirty tree polled while idle must not re-trigger
|
|
* full reindex/write cycles (#937 write amplification). */
|
|
uint64_t sig = 0;
|
|
watcher_git_status_t signature_status = git_dirty_signature(w, s, &sig);
|
|
if (signature_status == WATCHER_GIT_COMMAND_FAILED) {
|
|
/* The repository may have been removed between baseline and poll.
|
|
* Preserve committed observations and wait for a later clean probe. */
|
|
return true;
|
|
}
|
|
if (signature_status != WATCHER_GIT_OK) {
|
|
return false;
|
|
}
|
|
if (sig != s->last_dirty_sig) {
|
|
changed = true;
|
|
}
|
|
s->pending_dirty_sig = sig;
|
|
|
|
*changed_out = changed;
|
|
return true;
|
|
}
|
|
|
|
/* Context for poll_once foreach callback */
|
|
typedef struct {
|
|
cbm_watcher_t *w;
|
|
int64_t now;
|
|
int reindexed;
|
|
} poll_ctx_t;
|
|
|
|
static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) {
|
|
if (!w || !s || !s->project_name) {
|
|
return;
|
|
}
|
|
|
|
char *project_name = cbm_strdup(s->project_name);
|
|
char *root_path = cbm_strdup(s->root_path);
|
|
if (!project_name || !root_path) {
|
|
free(project_name);
|
|
free(root_path);
|
|
return;
|
|
}
|
|
|
|
bool removed = false;
|
|
cbm_mutex_lock(&w->coordination_lock);
|
|
bool mutation_acquired =
|
|
!w->mutation_begin || w->mutation_begin(w->mutation_context, project_name);
|
|
if (!mutation_acquired) {
|
|
cbm_mutex_unlock(&w->coordination_lock);
|
|
free(project_name);
|
|
free(root_path);
|
|
return;
|
|
}
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
project_state_t *current = cbm_ht_get(w->projects, project_name);
|
|
/* Deferred free (same discipline as cbm_watcher_unwatch): this state
|
|
* is referenced by the poll_once snapshot iterating us. On OOM the
|
|
* watch stays registered and pruning retries on the next cycle. Re-stat
|
|
* under the mutation lease so a root restored after the poll snapshot is
|
|
* never pruned. */
|
|
int stat_errno = 0;
|
|
uint64_t now_ms = cbm_now_ms();
|
|
bool still_eligible =
|
|
current == s && strcmp(current->root_path, root_path) == 0 &&
|
|
current->missing_root_count >= MISSING_ROOT_DELETE_AFTER && current->first_missing_ms > 0 &&
|
|
now_ms - current->first_missing_ms >= (uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC &&
|
|
root_status(root_path, &stat_errno) == ROOT_MISSING;
|
|
if (still_eligible && defer_state_free(w, s)) {
|
|
if (delete_cached_project_db(project_name)) {
|
|
atomic_store_explicit(&s->registered, false, memory_order_release);
|
|
cbm_ht_delete(w->projects, project_name);
|
|
removed = true;
|
|
} else {
|
|
/* The state stays registered; undo the just-added deferred-free
|
|
* entry so the next poll can safely retry the failed deletion. */
|
|
w->pending_free[--w->pending_free_count] = NULL;
|
|
}
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
|
|
if (removed && w->project_pruned) {
|
|
w->project_pruned(w->mutation_context, project_name);
|
|
}
|
|
if (w->mutation_begin) {
|
|
w->mutation_end(w->mutation_context, project_name);
|
|
}
|
|
cbm_mutex_unlock(&w->coordination_lock);
|
|
|
|
if (removed) {
|
|
cbm_log_info("watcher.root_pruned", "project", project_name);
|
|
}
|
|
free(project_name);
|
|
free(root_path);
|
|
}
|
|
|
|
/* After a SUCCESSFUL reindex, commit the baselines OBSERVED AT CHECK TIME —
|
|
* the state whose reindex just succeeded. A commit/edit landing during the
|
|
* reindex is deliberately not absorbed: the next poll sees it as a new delta
|
|
* (at-least-once, never lost). */
|
|
static void commit_baselines(cbm_watcher_t *w, project_state_t *s) {
|
|
if (!s->is_git) {
|
|
/* Tree strategy (#1948): the walk already refreshed the file count. */
|
|
s->last_tree_sig = s->pending_tree_sig;
|
|
return;
|
|
}
|
|
if (s->pending_head[0] != '\0') {
|
|
snprintf(s->last_head, sizeof(s->last_head), "%s", s->pending_head);
|
|
}
|
|
s->last_dirty_sig = s->pending_dirty_sig;
|
|
/* Refresh file count for interval */
|
|
int file_count = 0;
|
|
if (git_file_count(w, s, &file_count) == WATCHER_GIT_OK) {
|
|
s->file_count = file_count;
|
|
s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count);
|
|
publish_status(s, false);
|
|
}
|
|
}
|
|
|
|
static void poll_project(const char *key, void *val, void *ud) {
|
|
(void)key;
|
|
poll_ctx_t *ctx = ud;
|
|
project_state_t *s = val;
|
|
if (!s || !atomic_load_explicit(&s->registered, memory_order_acquire)) {
|
|
return;
|
|
}
|
|
|
|
/* Stale-root pruning (#286): classify the root BEFORE the baseline /
|
|
* is_git / interval gates so vanished roots are noticed even for
|
|
* non-git projects and regardless of adaptive backoff. */
|
|
int stat_errno = 0;
|
|
root_status_t rs = root_status(s->root_path, &stat_errno);
|
|
if (rs == ROOT_UNCERTAIN) {
|
|
/* EACCES / EIO / network blip / TCC revocation — the root may still
|
|
* exist. Never count toward pruning; restart the streak so only an
|
|
* uninterrupted run of genuine ENOENT/ENOTDIR observations can
|
|
* delete user data. */
|
|
if (s->missing_root_count > 0) {
|
|
s->missing_root_count = 0;
|
|
s->first_missing_ms = 0;
|
|
}
|
|
cbm_log_warn("watcher.root_stat_error", "project", s->project_name, "path", s->root_path,
|
|
"errno", itoa_buf(stat_errno));
|
|
return;
|
|
}
|
|
if (rs == ROOT_MISSING) {
|
|
uint64_t now_ms = cbm_now_ms();
|
|
if (s->missing_root_count == 0) {
|
|
s->first_missing_ms = now_ms;
|
|
}
|
|
s->missing_root_count++;
|
|
cbm_log_warn("watcher.root_missing", "project", s->project_name, "path", s->root_path,
|
|
"polls", itoa_buf(s->missing_root_count));
|
|
if (s->missing_root_count >= MISSING_ROOT_DELETE_AFTER &&
|
|
now_ms - s->first_missing_ms >= (uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC) {
|
|
prune_missing_project(ctx->w, s);
|
|
}
|
|
return;
|
|
}
|
|
if (s->missing_root_count > 0) {
|
|
cbm_log_info("watcher.root_restored", "project", s->project_name, "path", s->root_path);
|
|
s->missing_root_count = 0;
|
|
s->first_missing_ms = 0;
|
|
}
|
|
|
|
/* Initialize baseline on first poll */
|
|
if (!s->baseline_done) {
|
|
(void)init_baseline(ctx->w, s);
|
|
return;
|
|
}
|
|
|
|
/* Non-git projects are skipped unless the watch_non_git opt-in turned on
|
|
* tree polling for them at baseline (#1948). */
|
|
if (!s->is_git && !s->tree_poll) {
|
|
return;
|
|
}
|
|
|
|
/* Respect adaptive interval */
|
|
if (ctx->now < s->next_poll_ns) {
|
|
return;
|
|
}
|
|
|
|
/* Check for changes */
|
|
bool changed = false;
|
|
if (!check_changes(ctx->w, s, &changed)) {
|
|
return;
|
|
}
|
|
publish_status(s, true);
|
|
if (!changed) {
|
|
s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS);
|
|
return;
|
|
}
|
|
|
|
/* Trigger reindex */
|
|
if (!atomic_load_explicit(&s->registered, memory_order_acquire)) {
|
|
/* Git inspection can take long enough for the final owning daemon
|
|
* session to disconnect. Unwatch tombstones the snapshot state; do
|
|
* not admit an index callback after that ownership boundary. */
|
|
return;
|
|
}
|
|
cbm_log_info("watcher.changed", "project", s->project_name, "strategy",
|
|
s->is_git ? "git" : "tree");
|
|
if (ctx->w->index_fn) {
|
|
int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data);
|
|
if (rc == 0) {
|
|
ctx->reindexed++;
|
|
s->index_failures = 0;
|
|
commit_baselines(ctx->w, s);
|
|
} else if (rc > 0) {
|
|
/* Busy-skip: baseline stays uncommitted, next poll retries. */
|
|
cbm_log_info("watcher.index.retry", "project", s->project_name);
|
|
} else {
|
|
/* itoa_buf returns one shared per-thread buffer, so two of them
|
|
* in one call would print the same value twice. */
|
|
char rc_text[CBM_SZ_32];
|
|
char streak_text[CBM_SZ_32];
|
|
if (s->index_failures < INT_MAX) {
|
|
s->index_failures++;
|
|
}
|
|
snprintf(rc_text, sizeof(rc_text), "%d", rc);
|
|
snprintf(streak_text, sizeof(streak_text), "%d", s->index_failures);
|
|
cbm_log_warn("watcher.index.err", "project", s->project_name, "rc", rc_text,
|
|
"consecutive", streak_text);
|
|
if (s->index_failures == INDEX_FAIL_SUSTAINED) {
|
|
cbm_log_warn("watcher.index.sustained_failure", "project", s->project_name,
|
|
"consecutive", streak_text);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Failures back off; success and busy-skip keep the adaptive cadence. */
|
|
s->next_poll_ns =
|
|
ctx->now +
|
|
((int64_t)cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures) * US_PER_MS);
|
|
}
|
|
|
|
/* Callback to snapshot project state pointers into an array. */
|
|
typedef struct {
|
|
project_state_t **items;
|
|
int count;
|
|
int cap;
|
|
} snapshot_ctx_t;
|
|
|
|
static void snapshot_project(const char *key, void *val, void *ud) {
|
|
(void)key;
|
|
snapshot_ctx_t *sc = ud;
|
|
if (val && sc->count < sc->cap) {
|
|
sc->items[sc->count++] = val;
|
|
}
|
|
}
|
|
|
|
int cbm_watcher_poll_once(cbm_watcher_t *w) {
|
|
if (!w) {
|
|
return 0;
|
|
}
|
|
|
|
/* Snapshot project pointers under lock, then poll without holding it.
|
|
* This keeps the critical section small — poll_project does git I/O
|
|
* and may invoke index_fn which runs the full pipeline. */
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
|
|
/* Free deferred entries from the previous cycle. */
|
|
for (int i = 0; i < w->pending_free_count; i++) {
|
|
state_free(w->pending_free[i]);
|
|
}
|
|
w->pending_free_count = 0;
|
|
|
|
int n = cbm_ht_count(w->projects);
|
|
if (n == 0) {
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
return 0;
|
|
}
|
|
project_state_t **snap = malloc(n * sizeof(project_state_t *));
|
|
if (!snap) {
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
return 0;
|
|
}
|
|
snapshot_ctx_t sc = {.items = snap, .count = 0, .cap = n};
|
|
cbm_ht_foreach(w->projects, snapshot_project, &sc);
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
|
|
poll_ctx_t ctx = {
|
|
.w = w,
|
|
.now = now_ns(),
|
|
.reindexed = 0,
|
|
};
|
|
for (int i = 0; i < sc.count; i++) {
|
|
poll_project(NULL, snap[i], &ctx);
|
|
}
|
|
free(snap);
|
|
return ctx.reindexed;
|
|
}
|
|
|
|
/* ── Blocking run loop ──────────────────────────────────────────── */
|
|
|
|
static void cancel_active_git_entry(const char *key, void *value, void *user_data) {
|
|
(void)key;
|
|
(void)user_data;
|
|
project_state_t *state = value;
|
|
if (state && state->active_git) {
|
|
(void)cbm_subprocess_request_cancel(state->active_git);
|
|
}
|
|
}
|
|
|
|
void cbm_watcher_stop(cbm_watcher_t *w) {
|
|
if (w) {
|
|
atomic_store_explicit(&w->stopped, 1, memory_order_release);
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
cbm_ht_foreach(w->projects, cancel_active_git_entry, NULL);
|
|
for (int i = 0; i < w->pending_free_count; i++) {
|
|
project_state_t *state = w->pending_free[i];
|
|
if (state && state->active_git) {
|
|
(void)cbm_subprocess_request_cancel(state->active_git);
|
|
}
|
|
}
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
}
|
|
}
|
|
|
|
int cbm_watcher_run(cbm_watcher_t *w, int base_interval_ms) {
|
|
if (!w) {
|
|
return CBM_NOT_FOUND;
|
|
}
|
|
if (base_interval_ms <= 0) {
|
|
base_interval_ms = POLL_BASE_MS;
|
|
}
|
|
|
|
cbm_log_info("watcher.start", "interval_ms", base_interval_ms > 999 ? "multi-sec" : "fast");
|
|
|
|
while (!atomic_load(&w->stopped)) {
|
|
cbm_watcher_poll_once(w);
|
|
|
|
/* Sleep in small increments to allow responsive shutdown */
|
|
int slept = 0;
|
|
while (slept < base_interval_ms && !atomic_load(&w->stopped)) {
|
|
int chunk = base_interval_ms - slept;
|
|
if (chunk > SLEEP_CHUNK_MS) {
|
|
chunk = SLEEP_CHUNK_MS;
|
|
}
|
|
cbm_usleep((unsigned)chunk * CBM_MSEC_PER_SEC);
|
|
slept += chunk;
|
|
}
|
|
}
|
|
|
|
cbm_log_info("watcher.stop");
|
|
return 0;
|
|
}
|
|
|
|
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
|
|
int cbm_watcher_test_pending_free_count(cbm_watcher_t *w) {
|
|
if (!w) {
|
|
return -1;
|
|
}
|
|
cbm_mutex_lock(&w->projects_lock);
|
|
int count = w->pending_free_count;
|
|
cbm_mutex_unlock(&w->projects_lock);
|
|
return count;
|
|
}
|
|
#endif
|