Files
codebase-memory-mcp/tests/test_worker_watchdog.sh
T
Martin Vogel 1f674c805e harden: remove capability that should never have shipped
Microsoft's ML flagged the rc.1 release binaries. The decisive evidence is that
the SAME sha256 went from 0/62 clean to Microsoft-detected in about an hour with
no byte change, so the verdict lives partly in scanner-side state and no code
change can promise a clean result. What code CAN do is stop shipping things that
have no business in a release artifact, which is worth doing on its own merits
and incidentally widens the classifier margin. Every claim below is verified
against a built binary by the new gate, not by reading source.

Executable stack (the worst of the findings). vendored/nomic/code_vectors_blob.S
is the only assembly in the build and carried no .note.GNU-stack. An unannotated
object makes ld assume the worst for the whole link, so EVERY Linux release we
have ever shipped had GNU_STACK RWE. Adds the note (cause) plus ELF-only
-Wl,-z,noexecstack (outcome); the gate fails the release if it returns.

Test seams are now opt-in, never opt-out. TEST_SEAMS=1 defines
CBM_ENABLE_TEST_SEAMS; without it the crash-orphan probe -- which forks a child
that ignores SIGTERM and loops forever, then writes its pid to a caller-supplied
path -- and the lease-ownership marker compile to trivial stubs, so call sites are
untouched and the binary holds no fork, no signal handler and no env-var string.
Opt-IN is the point: forgetting the flag yields a clean binary rather than a leaky
one. scripts/test.sh requests it in the leg that consumes it, and
tests/test_worker_watchdog.sh now asserts the capability up front instead of
dying later with an opaque "Killed: 9".

The daemon's background version check is gone. It spawned curl against
api.github.com/repos/.../releases/latest on the first eligible session of every
run to say "a newer version exists" -- a release URL and an outbound request in
every shipped binary, for something the install scripts already report. The
INJECTABLE SEAM survives: update_ops is still honoured, the fakes in
tests/test_daemon_application.c still cover notice/ownership/cancellation/replay,
and with no provider application_update_subscribe_locked returns early so no
generation ever starts. "No network request by default" is now structural.

Dead capability out of release builds. The tar.gz/zip extraction block
(gzip_decompress through cbm_extract_binary_from_zip, plus its cli.h
declarations) moves under CBM_CLI_ENABLE_TEST_API -- verified self-contained, zero
uses of any helper outside it, only callers the excluded updater and
tests/test_cli.c. Downloading an archive, decompressing it, picking an executable
out of it and marking it executable is the canonical dropper composite; it is now
absent rather than merely unreachable. SQLite is built with
-DSQLITE_OMIT_LOAD_EXTENSION (no caller of load_extension anywhere in src/ or
internal/), removing that API surface and part of the dlopen/dlsym surface.

Temp files and environment scanning (S2/S3). Predictable paths in mcp.c,
artifact.c and diagnostics.c are created privately and exclusively and written
through the returned descriptor; pass_envscan.c no longer descends symlinked
directories out of the project root, and its fixed 512-byte path buffers no
longer truncate into pointer arithmetic that could land outside the buffer.

Build-time entropy. mimalloc's version banner baked __DATE__/__TIME__ into every
binary, so two builds of identical source seconds apart could never share a hash
and no release could inherit a false-positive determination made about its
predecessor. Local patch removes it (marked to survive refreshes), -Wdate-time
makes any future use a build error, and -Wl,--no-insert-timestamp stops the PE
header carrying the link clock.

scripts/ci/check-binary-composition.sh is the proof that each removal stays
removed, wired into package-release.sh after strip so the local artifact-flow
smoke enforces exactly what CI does. It asserts absences plus a CANARY string, so
handing it a compressed, stubbed or empty file fails instead of passing
vacuously, and a missing tool is a hard error -- a skipped assertion must never
look like a satisfied one.

Two build-system traps found by that gate, both of which had silently defeated a
fix: the product binary is compiled in one shot from sources, so a flag flip did
not rebuild it (now tracked by a .build-config stamp that also removes the
binary, making it independent of mtime granularity); and prod_sqlite3.o /
prod_mimalloc.o depended on a single named source, so SQLITE_OMIT_LOAD_EXTENSION
and the mimalloc patch BOTH compiled to nothing on the first incremental build.
Source review would have called them done.

Deliberately NOT changed. Three seams stay in release artifacts because
scripts/smoke-test.sh runs against the real artifact and needs them:
CBM_TEST_CRASH_ON and CBM_TEST_HANG_ON inject the faults that prove supervisor
recovery, and CBM_TEST_WINDOWS_USER_PATH_RUN_ID is what keeps the PATH smoke from
writing the tester's actual PATH. The gate treats those as an allowlist, so a
NOVEL seam still fails. The true no-UI standard build is deferred rather than
rushed: src/ui/* is in PROD_SRCS and four files outside src/ui reference UI
symbols, including the daemon that serves the UI, so that assertion reports
instead of failing until the split lands -- a gate everyone knows is red teaches
people to ignore gates.

No grammar is removed. ObjectScript accounts for essentially all binary growth
since the last provably-clean release (+21.4MB rodata, +1.1MB text from two
four-line shims), which made it the obvious ablation candidate, but a dry run
performed twelve real Defender endpoint scans across standard/UI and amd64/arm64
with ObjectScript, the daemon and the expanded hooks all present and every scan
was clean. Nothing there is a deterministic trigger, so cutting a
community-contributed language would spend a real feature on unproven margin.
Lean is not a candidate either: at 99.6MB of source it is by far the largest
grammar, but it shipped in v0.9.0 which scanned 20/20 clean, so removing it would
produce a novel unscanned profile instead of restoring a known-good one.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 23:50:53 +02:00

200 lines
7.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# test_worker_watchdog.sh — regression guard for the WORKER-mode parent-death
# watchdog (#845). A supervised index worker (`cli --index-worker
# index_repository …`) whose supervisor dies must exit on its own instead of
# indexing on as an orphan (orphaned workers contributed to memory pressure
# during the 2026-07-04 host panics). The MCP-server watchdog (#406/#407,
# tests/test_parent_watchdog.sh) did not cover CLI worker mode.
#
# Strategy: launch the worker under a wrapper "parent" on a fixture where the
# test-only injector (CBM_TEST_HANG_ON) busy-spins on one file, so the worker
# is guaranteed to still be mid-index when the wrapper is killed — the guard
# cannot pass vacuously via the worker simply finishing. The hidden
# --index-worker-single-thread + --index-worker-marker recovery knobs give a
# deterministic "worker is AT the hang file" sync point: the worker writes the
# rel_path it is about to process before touching it. After kill -9 of the
# wrapper, the worker-mode watchdog must notice the changed ppid and kill its
# isolated process group within a few seconds. A test-only worker descendant is
# kept alive in that group to prove teardown is tree-wide rather than merely a
# root-process exit. The recovery knobs are passed as the supervisor's hidden
# worker argv, never by mutating the supervisor environment. Skipped on
# Windows-like shells (the watchdog is POSIX-only).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BINARY="${CBM_TEST_BINARY:-${ROOT}/build/c/codebase-memory-mcp}"
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
echo "skipping worker watchdog test on Windows"
exit 0
;;
esac
if [[ ! -x "${BINARY}" ]]; then
echo "missing binary: ${BINARY}" >&2
exit 2
fi
# PRECONDITION, not a skip: this test drives the crash-orphan probe, which is
# compiled out of ordinary builds (it forks a SIGTERM-ignoring child, which must
# never ship — see src/main.c and TEST_SEAMS in Makefile.cbm). Against a
# seam-less binary the probe silently no-ops and the test dies with an opaque
# "Killed: 9" many lines later. Assert the capability up front and say exactly
# how to get it. Failing (not skipping) is deliberate: a skip here would hide
# the loss of watchdog coverage entirely.
if ! LC_ALL=C grep -a -q -F 'CBM_TEST_WORKER_DESCENDANT_PID_FILE' "${BINARY}"; then
echo "binary lacks the crash-orphan test seam: ${BINARY}" >&2
echo " rebuild it with: make -f Makefile.cbm cbm TEST_SEAMS=1" >&2
echo " or run this test through scripts/test.sh, which does that for you." >&2
exit 2
fi
if command -v shasum >/dev/null 2>&1; then
BUILD_FINGERPRINT="$(shasum -a 256 "${BINARY}" | awk '{print $1}')"
elif command -v sha256sum >/dev/null 2>&1; then
BUILD_FINGERPRINT="$(sha256sum "${BINARY}" | awk '{print $1}')"
elif command -v openssl >/dev/null 2>&1; then
BUILD_FINGERPRINT="$(openssl dgst -sha256 "${BINARY}" | awk '{print $NF}')"
else
echo "no SHA-256 command available for worker build binding" >&2
exit 2
fi
if [[ ! "${BUILD_FINGERPRINT}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid worker build fingerprint: ${BUILD_FINGERPRINT}" >&2
exit 2
fi
tmpdir="$(mktemp -d)"
wrapper_pid=""
cleanup() {
if [[ -s "${tmpdir}/child.pid" ]]; then
local child_pid
child_pid="$(cat "${tmpdir}/child.pid" 2>/dev/null || true)"
[[ -n "${child_pid}" ]] && kill -9 "${child_pid}" 2>/dev/null || true
fi
if [[ -s "${tmpdir}/descendant.pid" ]]; then
local descendant_pid
descendant_pid="$(cat "${tmpdir}/descendant.pid" 2>/dev/null || true)"
[[ -n "${descendant_pid}" ]] && kill -9 "${descendant_pid}" 2>/dev/null || true
fi
[[ -n "${wrapper_pid}" ]] && kill -9 "${wrapper_pid}" 2>/dev/null || true
rm -rf "${tmpdir}"
}
trap cleanup EXIT
# Fixture: one good file + one the injector busy-spins on.
mkdir -p "${tmpdir}/repo"
printf 'def good():\n return 1\n' > "${tmpdir}/repo/good.py"
printf 'def slow():\n return 2\n' > "${tmpdir}/repo/hang_me.py"
# Wrapper "parent": launches the worker exactly as the supervisor would
# (cli --index-worker … --response-out), records the child PID, then waits.
cat >"${tmpdir}/wrapper.sh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
"${CBM_BINARY}" cli --index-worker \
--index-worker-build "${BUILD_FINGERPRINT}" \
index_repository "${ARGS_JSON}" \
--response-out "${TMPDIR_PATH}/resp" \
--index-worker-single-thread \
--index-worker-marker "${TMPDIR_PATH}/marker" \
>/dev/null 2>"${TMPDIR_PATH}/child.err" &
echo "$!" >"${TMPDIR_PATH}/child.pid"
wait
SH
chmod +x "${tmpdir}/wrapper.sh"
CBM_BINARY="${BINARY}" BUILD_FINGERPRINT="${BUILD_FINGERPRINT}" TMPDIR_PATH="${tmpdir}" \
ARGS_JSON="{\"repo_path\":\"${tmpdir}/repo\"}" \
CBM_TEST_HANG_ON=hang_me \
CBM_TEST_WORKER_DESCENDANT_PID_FILE="${tmpdir}/descendant.pid" \
"${tmpdir}/wrapper.sh" &
wrapper_pid=$!
# Wait for the worker PID file to appear.
for _ in {1..50}; do
[[ -s "${tmpdir}/child.pid" ]] && break
sleep 0.1
done
if [[ ! -s "${tmpdir}/child.pid" ]]; then
echo "worker pid file was not written" >&2
[[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2
exit 3
fi
child_pid="$(cat "${tmpdir}/child.pid")"
# The validated worker creates its own process group before starting this
# deliberate long-lived descendant. Both must be alive and in that group before
# the wrapper is killed, otherwise the tree assertion would be vacuous.
for _ in {1..50}; do
[[ -s "${tmpdir}/descendant.pid" ]] && break
sleep 0.1
done
if [[ ! -s "${tmpdir}/descendant.pid" ]]; then
echo "worker descendant pid file was not written" >&2
[[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2
exit 3
fi
descendant_pid="$(cat "${tmpdir}/descendant.pid")"
if ! kill -0 "${descendant_pid}" 2>/dev/null; then
echo "worker descendant exited before supervisor death" >&2
exit 3
fi
child_pgid="$(ps -p "${child_pid}" -o pgid= 2>/dev/null | tr -d '[:space:]')"
descendant_pgid="$(ps -p "${descendant_pid}" -o pgid= 2>/dev/null | tr -d '[:space:]')"
if [[ -z "${child_pgid}" || "${child_pgid}" != "${child_pid}" ||
"${descendant_pgid}" != "${child_pgid}" ]]; then
echo "worker tree is not in its expected isolated process group" >&2
exit 3
fi
# Sync point: once the marker names the hang file, the worker is provably
# mid-index (busy-spinning in extraction) and long past watchdog installation.
for _ in {1..100}; do
if [[ -s "${tmpdir}/marker" ]] && grep -q "hang_me" "${tmpdir}/marker"; then
break
fi
sleep 0.1
done
if ! grep -q "hang_me" "${tmpdir}/marker" 2>/dev/null; then
echo "worker never reached the hang file (marker: $(cat "${tmpdir}/marker" 2>/dev/null || echo '<empty>'))" >&2
[[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2
exit 3
fi
# Vacuity guard: the worker must still be ALIVE (busy-spinning) right now —
# it cannot "pass" by having finished before the parent dies.
if ! kill -0 "${child_pid}" 2>/dev/null; then
echo "worker exited before the supervisor was killed — guard would be vacuous" >&2
[[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2
exit 3
fi
# Kill the wrapper parent: the orphaned worker must now self-exit.
kill -9 "${wrapper_pid}"
wait "${wrapper_pid}" 2>/dev/null || true
process_gone_or_zombie() {
local pid="$1"
if ! kill -0 "${pid}" 2>/dev/null; then
return 0
fi
local state
state="$(ps -p "${pid}" -o stat= 2>/dev/null | tr -d '[:space:]' || true)"
[[ "${state}" == Z* ]]
}
deadline=$((SECONDS + 15))
while (( SECONDS < deadline )); do
if process_gone_or_zombie "${child_pid}" && process_gone_or_zombie "${descendant_pid}"; then
echo "ok: worker ${child_pid} and descendant ${descendant_pid} exited after supervisor death"
exit 0
fi
sleep 0.2
done
echo "index worker tree survived supervisor death (worker=${child_pid}, descendant=${descendant_pid})" >&2
[[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2
exit 1