|
| 1 | +#!/usr/bin/env bash |
| 2 | +# test_watcher_disabled.sh — process-level regression for the watcher_enabled |
| 3 | +# kill-switch (#335). Requested by the maintainer on PR #1105: the unit test |
| 4 | +# (cli_config_watcher_enabled_default_and_persist) only proves the config |
| 5 | +# predicate and would still pass if the daemon-side gate were deleted. This |
| 6 | +# drives the REAL binary against an isolated cache and proves, at the process |
| 7 | +# level, that watcher_enabled=false actually prevents the watcher subsystem from |
| 8 | +# initializing: |
| 9 | +# - `watcher.disabled reason=config` IS emitted, |
| 10 | +# - `watcher.start` is ABSENT (the poll thread never runs), |
| 11 | +# - no project registration occurs (`watcher.watch` ABSENT), and |
| 12 | +# - manual index_repository remains available (the MCP tool still serves and |
| 13 | +# indexes with the watcher off). |
| 14 | +# A positive control (watcher_enabled=true) proves those signals are real — |
| 15 | +# watcher.start + watcher.watch DO appear and watcher.disabled does not — so |
| 16 | +# this test fails if the gate is removed. |
| 17 | +# |
| 18 | +# NO ASSERTION IS DECIDED BY A TIMEOUT. The watcher lives in the daemon |
| 19 | +# (src/daemon/host.c), which logs to $CBM_CACHE_DIR/logs/cbm-daemon.log in a |
| 20 | +# fixed order: watcher.disabled (host_state_prepare) -> daemon.start -> the |
| 21 | +# watcher thread's own watcher.start -> ... -> watcher thread joined -> |
| 22 | +# daemon.stop. Each run therefore retires its daemon and waits for that CLOSED |
| 23 | +# lifecycle (daemon.start ... daemon.stop) before asserting. Absence over a |
| 24 | +# closed lifecycle log is a fact, not a race: if watcher.start is not in a log |
| 25 | +# that already contains daemon.stop, the thread never ran. Every wait is a |
| 26 | +# bounded poll on an asserted state (the soak harness's wait_for_daemon_stop |
| 27 | +# doctrine, scripts/soak-test.sh), and exhausting a poll is a FAILURE with the |
| 28 | +# reason printed — never a silently-passing sleep. |
| 29 | +# |
| 30 | +# Skipped on Windows-like shells (uses POSIX process control + git fixture). |
| 31 | +set -euo pipefail |
| 32 | + |
| 33 | +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| 34 | +BINARY="${CBM_TEST_BINARY:-${ROOT}/build/c/codebase-memory-mcp}" |
| 35 | + |
| 36 | +case "$(uname -s)" in |
| 37 | + MINGW*|MSYS*|CYGWIN*) echo "skipping watcher_disabled test on Windows"; exit 0 ;; |
| 38 | +esac |
| 39 | +[[ -x "${BINARY}" ]] || { echo "missing binary: ${BINARY}" >&2; exit 2; } |
| 40 | +command -v git >/dev/null 2>&1 || { echo "git required for fixture" >&2; exit 2; } |
| 41 | + |
| 42 | +work="$(mktemp -d)" |
| 43 | + |
| 44 | +# Every run gets its own CBM_CACHE_DIR, and the daemon endpoint is derived from |
| 45 | +# it, so these daemons are private to the test and can never touch a developer's |
| 46 | +# real one. Retire them on any exit path regardless. |
| 47 | +cleanup() { |
| 48 | + local cache |
| 49 | + for cache in "${work}"/cache-*; do |
| 50 | + [[ -d "${cache}" ]] || continue |
| 51 | + CBM_CACHE_DIR="${cache}" "${BINARY}" daemon stop >/dev/null 2>&1 || true |
| 52 | + done |
| 53 | + rm -rf "${work}" |
| 54 | +} |
| 55 | +trap cleanup EXIT |
| 56 | + |
| 57 | +# --- tiny git fixture so indexing is fast + deterministic --------------------- |
| 58 | +repo="${work}/repo" |
| 59 | +mkdir -p "${repo}" |
| 60 | +cat >"${repo}/sample.c" <<'EOF' |
| 61 | +int add(int a, int b) { return a + b; } |
| 62 | +int main(void) { return add(1, 2); } |
| 63 | +EOF |
| 64 | +git -C "${repo}" init -q |
| 65 | +git -C "${repo}" -c user.email=t@example.com -c user.name=t add -A |
| 66 | +git -C "${repo}" -c user.email=t@example.com -c user.name=t commit -q -m init |
| 67 | + |
| 68 | +INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"watcher-test","version":"1.0"}}}' |
| 69 | +INITED='{"jsonrpc":"2.0","method":"notifications/initialized"}' |
| 70 | + |
| 71 | +CURRENT_RUN="(setup)" |
| 72 | + |
| 73 | +fail() { |
| 74 | + echo "FAIL [${CURRENT_RUN}]: $*" >&2 |
| 75 | + local log |
| 76 | + for log in "${work}"/cache-*/logs/cbm-daemon.log; do |
| 77 | + [[ -f "${log}" ]] || continue |
| 78 | + echo "----- ${log} (watcher/daemon lines) -----" >&2 |
| 79 | + grep -E 'msg=(watcher|daemon)\.' "${log}" >&2 || true |
| 80 | + done |
| 81 | + exit 1 |
| 82 | +} |
| 83 | + |
| 84 | +# wait_for <file> <extended-regex> <label> |
| 85 | +# Bounded poll on an asserted state: returns as soon as the state is observed, |
| 86 | +# and a exhausted budget is a failure with the reason — never a fixed sleep that |
| 87 | +# lets the assertion proceed on an unobserved state. 30s ceiling mirrors |
| 88 | +# scripts/soak-test.sh (attempts=300 x 0.1s): the wait sits above the worst case |
| 89 | +# so a slow, loaded CI runner is not mistaken for a broken gate. |
| 90 | +wait_for() { |
| 91 | + local file="$1" pattern="$2" label="$3" |
| 92 | + local attempts=300 |
| 93 | + while [ "${attempts}" -gt 0 ]; do |
| 94 | + if [ -f "${file}" ] && grep -qE "${pattern}" "${file}"; then |
| 95 | + return 0 |
| 96 | + fi |
| 97 | + attempts=$((attempts - 1)) |
| 98 | + sleep 0.1 |
| 99 | + done |
| 100 | + fail "timed out after 30s waiting for ${label} in ${file}" |
| 101 | +} |
| 102 | + |
| 103 | +log_has() { grep -qE "$2" "$1"; } |
| 104 | + |
| 105 | +# wait_for_project_db <cache_dir> |
| 106 | +# Registration is not reachable until the project DB exists: the daemon only |
| 107 | +# refreshes a session's watch once application_regular_db_exists(project) is |
| 108 | +# true (application.c application_refresh_watch_locked). Waiting on the DB is |
| 109 | +# therefore waiting on registration's own precondition — after this returns, a |
| 110 | +# missing watcher.watch is the gate's doing, not a session that ended too soon. |
| 111 | +wait_for_project_db() { |
| 112 | + local cache="$1" attempts=300 db |
| 113 | + while [ "${attempts}" -gt 0 ]; do |
| 114 | + for db in "${cache}"/*.db; do |
| 115 | + # An unmatched glob stays literal, and _config.db is the settings store, |
| 116 | + # not a project graph. |
| 117 | + case "${db}" in |
| 118 | + */_config.db) continue ;; |
| 119 | + *'*.db') continue ;; |
| 120 | + esac |
| 121 | + if [ -f "${db}" ]; then |
| 122 | + return 0 |
| 123 | + fi |
| 124 | + done |
| 125 | + attempts=$((attempts - 1)) |
| 126 | + sleep 0.1 |
| 127 | + done |
| 128 | + fail "timed out after 30s waiting for the project DB under ${cache}" |
| 129 | +} |
| 130 | + |
| 131 | +# run_session <cache_dir> <outfile> [extra rpc line ...] |
| 132 | +# Drives the real binary in MCP stdio mode with cwd = the fixture repo (so the |
| 133 | +# session root is derived from it), holding stdin open via a FIFO until the LAST |
| 134 | +# request has been answered on stdout — an asserted state, not an interval — |
| 135 | +# then closing it (EOF -> clean shutdown). Sets DAEMON_LOG for the caller. |
| 136 | +# |
| 137 | +# Registration happens asynchronously, after indexing, so a closed lifecycle |
| 138 | +# alone would not carry a NEGATIVE registration assertion — the session could |
| 139 | +# simply have ended before registration was ever reachable. Two optional |
| 140 | +# pre-close waits close that gap, both observed while the session is still live: |
| 141 | +# PRE_CLOSE_DB=1 wait for the project DB, which is registration's own |
| 142 | +# precondition (see wait_for_project_db). |
| 143 | +# PRE_CLOSE_WAIT=<re> wait for a daemon-log pattern (with PRE_CLOSE_LABEL), |
| 144 | +# used by the positive control to observe registration |
| 145 | +# actually happening in this session shape. |
| 146 | +run_session() { |
| 147 | + local cache="$1" outf="$2"; shift 2 |
| 148 | + local fifo="${work}/stdin.fifo" |
| 149 | + rm -f "${fifo}"; mkfifo "${fifo}" |
| 150 | + DAEMON_LOG="${cache}/logs/cbm-daemon.log" |
| 151 | + |
| 152 | + ( cd "${repo}" && CBM_CACHE_DIR="${cache}" "${BINARY}" <"${fifo}" \ |
| 153 | + >"${outf}" 2>"${cache}.client.err" ) & |
| 154 | + local server_pid=$! |
| 155 | + |
| 156 | + exec 3>"${fifo}" |
| 157 | + printf '%s\n' "${INIT}" >&3 |
| 158 | + printf '%s\n' "${INITED}" >&3 |
| 159 | + local line last_id=1 |
| 160 | + for line in "$@"; do |
| 161 | + printf '%s\n' "${line}" >&3 |
| 162 | + last_id=$((last_id + 1)) |
| 163 | + done |
| 164 | + # Wait for the final response before closing stdin, so shutdown never races |
| 165 | + # the work under test. |
| 166 | + wait_for "${outf}" "\"id\":${last_id}[,}]" "the id=${last_id} JSON-RPC response" |
| 167 | + if [ -n "${PRE_CLOSE_DB:-}" ]; then |
| 168 | + wait_for_project_db "${cache}" |
| 169 | + fi |
| 170 | + if [ -n "${PRE_CLOSE_WAIT:-}" ]; then |
| 171 | + wait_for "${DAEMON_LOG}" "${PRE_CLOSE_WAIT}" "${PRE_CLOSE_LABEL:-${PRE_CLOSE_WAIT}}" |
| 172 | + fi |
| 173 | + exec 3>&- |
| 174 | + wait "${server_pid}" || true |
| 175 | + |
| 176 | + # Retire the daemon and wait for the lifecycle to close, so the assertions |
| 177 | + # below read a complete log rather than a snapshot of one still being written. |
| 178 | + CBM_CACHE_DIR="${cache}" "${BINARY}" daemon stop >/dev/null 2>&1 || true |
| 179 | + wait_for "${DAEMON_LOG}" 'msg=daemon\.start( |$)' "the daemon to record its start" |
| 180 | + wait_for "${DAEMON_LOG}" 'msg=daemon\.stop( |$)' "the daemon lifecycle to close" |
| 181 | +} |
| 182 | + |
| 183 | +# ============================================================================= |
| 184 | +# Run 1 — DISABLED + manual index_repository (auto_index off, no contention). |
| 185 | +# Proves: gate fires, watcher never starts, and the manual MCP tool still |
| 186 | +# serves + indexes with the watcher off. |
| 187 | +# ============================================================================= |
| 188 | +CURRENT_RUN="run 1: disabled + manual index_repository" |
| 189 | +c_offm="${work}/cache-offm" |
| 190 | +CBM_CACHE_DIR="${c_offm}" "${BINARY}" config set watcher_enabled false >/dev/null |
| 191 | +CBM_CACHE_DIR="${c_offm}" "${BINARY}" config set auto_index false >/dev/null |
| 192 | +run_session "${c_offm}" "${work}/offm.out" \ |
| 193 | + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"index_repository\",\"arguments\":{\"repo_path\":\"${repo}\",\"mode\":\"fast\"}}}" |
| 194 | + |
| 195 | +log_has "${DAEMON_LOG}" 'msg=watcher\.disabled( |$)' \ |
| 196 | + || fail "watcher.disabled not emitted when disabled" |
| 197 | +log_has "${DAEMON_LOG}" 'msg=watcher\.disabled .*reason=config' \ |
| 198 | + || fail "watcher.disabled emitted without reason=config" |
| 199 | +! log_has "${DAEMON_LOG}" 'msg=watcher\.start( |$)' \ |
| 200 | + || fail "watcher.start present when disabled (the poll thread ran)" |
| 201 | +! log_has "${DAEMON_LOG}" 'msg=watcher\.watch( |$)' \ |
| 202 | + || fail "watcher.watch (registration) present when disabled" |
| 203 | + |
| 204 | +resp="$(grep '"id":2' "${work}/offm.out" | head -1)" |
| 205 | +[ -n "${resp}" ] || fail "no index_repository response (tool unavailable when watcher off)" |
| 206 | +case "${resp}" in |
| 207 | + *'"error"'*) fail "index_repository returned an error with the watcher off: ${resp}" ;; |
| 208 | +esac |
| 209 | +case "${resp}" in |
| 210 | + *nodes*) : ;; |
| 211 | + *) fail "index_repository response reports no indexed nodes: ${resp}" ;; |
| 212 | +esac |
| 213 | +echo "ok: disabled — watcher.disabled emitted, no watcher.start/watch, manual index_repository served" |
| 214 | + |
| 215 | +# ============================================================================= |
| 216 | +# Run 2 — DISABLED + auto_index on. Same-config apples-to-apples for the |
| 217 | +# registration axis: indexing still runs, but NO registration happens. |
| 218 | +# ============================================================================= |
| 219 | +CURRENT_RUN="run 2: disabled + auto_index" |
| 220 | +c_offa="${work}/cache-offa" |
| 221 | +CBM_CACHE_DIR="${c_offa}" "${BINARY}" config set watcher_enabled false >/dev/null |
| 222 | +CBM_CACHE_DIR="${c_offa}" "${BINARY}" config set auto_index true >/dev/null |
| 223 | +# Hold the session open until auto-indexing has produced the project DB, so |
| 224 | +# registration's precondition is satisfied and the absence below is the gate. |
| 225 | +PRE_CLOSE_DB=1 |
| 226 | +run_session "${c_offa}" "${work}/offa.out" |
| 227 | +PRE_CLOSE_DB="" |
| 228 | + |
| 229 | +log_has "${DAEMON_LOG}" 'msg=watcher\.disabled( |$)' \ |
| 230 | + || fail "watcher.disabled not emitted (auto_index run)" |
| 231 | +# Contract point 3 is broader than the manual tool: turning the watcher off must |
| 232 | +# not turn auto-indexing off with it. The DB above proves the pipeline ran. |
| 233 | +log_has "${DAEMON_LOG}" 'msg=daemon\.start( |$)' \ |
| 234 | + || fail "daemon did not start with the watcher disabled" |
| 235 | +! log_has "${DAEMON_LOG}" 'msg=watcher\.start( |$)' \ |
| 236 | + || fail "watcher.start present when disabled (auto_index run)" |
| 237 | +! log_has "${DAEMON_LOG}" 'msg=watcher\.watch( |$)' \ |
| 238 | + || fail "project registered with the watcher when disabled" |
| 239 | +echo "ok: disabled+auto_index — auto-index still ran, no watcher.start, no registration" |
| 240 | + |
| 241 | +# ============================================================================= |
| 242 | +# Run 3 — ENABLED positive control (auto_index on). Proves the signals above |
| 243 | +# are real: the watcher starts AND registers the session project — so their |
| 244 | +# absence in Runs 1-2 is meaningful, and removing the gate fails this test. |
| 245 | +# ============================================================================= |
| 246 | +CURRENT_RUN="run 3: enabled positive control" |
| 247 | +c_on="${work}/cache-on" |
| 248 | +CBM_CACHE_DIR="${c_on}" "${BINARY}" config set watcher_enabled true >/dev/null |
| 249 | +CBM_CACHE_DIR="${c_on}" "${BINARY}" config set auto_index true >/dev/null |
| 250 | +# The control must observe registration actually happening in this exact session |
| 251 | +# shape — that is what makes its absence in runs 1-2 evidence rather than noise. |
| 252 | +PRE_CLOSE_WAIT='msg=watcher\.watch( |$)' |
| 253 | +PRE_CLOSE_LABEL="the session project to register with the watcher" |
| 254 | +run_session "${c_on}" "${work}/on.out" |
| 255 | +PRE_CLOSE_WAIT="" |
| 256 | + |
| 257 | +log_has "${DAEMON_LOG}" 'msg=watcher\.start( |$)' \ |
| 258 | + || fail "watcher.start absent when enabled (the watcher never started)" |
| 259 | +log_has "${DAEMON_LOG}" 'msg=watcher\.watch( |$)' \ |
| 260 | + || fail "no project registration when enabled (control failed)" |
| 261 | +! log_has "${DAEMON_LOG}" 'msg=watcher\.disabled( |$)' \ |
| 262 | + || fail "watcher.disabled present when enabled" |
| 263 | +echo "ok: enabled — watcher.start emitted and session project registered" |
| 264 | + |
| 265 | +echo "PASS: watcher_enabled kill-switch process regression (#335)" |
0 commit comments