diff --git a/scripts/test.sh b/scripts/test.sh index 918033a9..b8f03e70 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -60,4 +60,8 @@ scripts/clean.sh # Step 2 + 3: Build and run tests (with arch prefix on macOS) $ARCH_PREFIX make -j"$NPROC" -f Makefile.cbm test $MAKE_ARGS +# Step 4: Build the stdio MCP binary and verify it exits when its parent dies. +$ARCH_PREFIX make -j"$NPROC" -f Makefile.cbm cbm $MAKE_ARGS +bash tests/test_parent_watchdog.sh + echo "=== All tests passed ===" diff --git a/src/main.c b/src/main.c index d413d9dd..758acdf6 100644 --- a/src/main.c +++ b/src/main.c @@ -27,6 +27,7 @@ enum { MAIN_FLAG_OFF = 5, /* strlen("--ui=") */ MAIN_PORT_OFF = 7, /* strlen("--port=") */ MAIN_MAX_PORT = 65536, + PARENT_WATCHDOG_STACK_SIZE = 64 * CBM_SZ_1K, }; #define MAIN_RAM_FRACTION 0.5 @@ -34,6 +35,7 @@ enum { #include "foundation/log.h" #include "foundation/diagnostics.h" #include "foundation/platform.h" +#include "foundation/compat.h" #include "foundation/compat_thread.h" #include "foundation/mem.h" #include "foundation/profile.h" @@ -59,9 +61,10 @@ static cbm_mcp_server_t *g_server = NULL; static cbm_http_server_t *g_http_server = NULL; static atomic_int g_shutdown = 0; -static void signal_handler(int sig) { - (void)sig; - atomic_store(&g_shutdown, 1); +static void request_shutdown(void) { + if (atomic_exchange(&g_shutdown, 1)) { + return; + } /* Cancel any in-progress pipeline (async-signal-safe: only does atomic_store) */ if (g_server) { @@ -83,6 +86,37 @@ static void signal_handler(int sig) { (void)fclose(stdin); } +static void signal_handler(int sig) { + (void)sig; + request_shutdown(); +} + +/* ── Parent process watchdog ───────────────────────────────────── */ + +#ifndef _WIN32 +static void *parent_watchdog_thread(void *arg) { + pid_t initial_ppid = *(pid_t *)arg; + const unsigned int interval_us = 500000; + + while (!atomic_load(&g_shutdown)) { + cbm_usleep(interval_us); + + if (atomic_load(&g_shutdown)) { + break; + } + + pid_t current_ppid = getppid(); + if (initial_ppid > 1 && current_ppid != initial_ppid) { + cbm_log_warn("parent.exited", "reason", "ppid_changed"); + request_shutdown(); + exit(0); + } + } + + return NULL; +} +#endif + /* ── Watcher background thread ──────────────────────────────────── */ static void *watcher_thread(void *arg) { @@ -345,6 +379,24 @@ int main(int argc, char **argv) { return subcmd; } +#ifndef _WIN32 + pid_t initial_ppid = getppid(); + if (initial_ppid <= 1) { + return 0; + } + + cbm_thread_t parent_watchdog_tid; + bool parent_watchdog_started = false; + int parent_watchdog_rc = cbm_thread_create(&parent_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, + parent_watchdog_thread, &initial_ppid); + if (parent_watchdog_rc == 0) { + parent_watchdog_started = true; + } else { + cbm_log_int(CBM_LOG_WARN, "parent.watchdog.unavailable", "rc", parent_watchdog_rc); + return SKIP_ONE; + } +#endif + /* Default: MCP server on stdio */ cbm_mem_init(MAIN_RAM_FRACTION); /* 50% of RAM — safe now because mimalloc tracks ALL * memory (C + C++ allocations) via global override. @@ -391,6 +443,12 @@ int main(int argc, char **argv) { if (!g_server) { cbm_log_error("server.err", "msg", "failed to create server"); cbm_config_close(runtime_config); +#ifndef _WIN32 + atomic_store(&g_shutdown, 1); + if (parent_watchdog_started) { + cbm_thread_join(&parent_watchdog_tid); + } +#endif return SKIP_ONE; } @@ -430,10 +488,17 @@ int main(int argc, char **argv) { /* Run MCP event loop (blocks until EOF or signal) */ int rc = cbm_mcp_server_run(g_server, stdin, stdout); + atomic_store(&g_shutdown, 1); /* Shutdown */ cbm_log_info("server.shutdown"); +#ifndef _WIN32 + if (parent_watchdog_started) { + cbm_thread_join(&parent_watchdog_tid); + } +#endif + if (http_started) { cbm_http_server_stop(g_http_server); cbm_thread_join(&http_tid); diff --git a/tests/test_parent_watchdog.sh b/tests/test_parent_watchdog.sh new file mode 100644 index 00000000..d6cd26de --- /dev/null +++ b/tests/test_parent_watchdog.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BINARY="${ROOT}/build/c/codebase-memory-mcp" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + echo "skipping parent watchdog test on Windows" + exit 0 + ;; +esac + +if [[ ! -x "${BINARY}" ]]; then + echo "missing binary: ${BINARY}" >&2 + exit 2 +fi + +tmpdir="$(mktemp -d)" +cleanup() { + if [[ -f "${tmpdir}/child.pid" ]]; then + child_pid="$(cat "${tmpdir}/child.pid" 2>/dev/null || true)" + if [[ -n "${child_pid}" ]]; then + kill "${child_pid}" 2>/dev/null || true + fi + fi + if [[ -n "${wrapper_pid:-}" ]]; then + kill "${wrapper_pid}" 2>/dev/null || true + fi + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +cat >"${tmpdir}/wrapper.sh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +exec 3<>"${FIFO}" +"${CBM_BINARY}" <&3 >/dev/null 2>"${TMPDIR_PATH}/child.err" & +echo "$!" >"${TMPDIR_PATH}/child.pid" +wait +SH +chmod +x "${tmpdir}/wrapper.sh" +mkfifo "${tmpdir}/stdin" + +CBM_BINARY="${BINARY}" FIFO="${tmpdir}/stdin" TMPDIR_PATH="${tmpdir}" "${tmpdir}/wrapper.sh" & +wrapper_pid=$! + +for _ in {1..50}; do + [[ -s "${tmpdir}/child.pid" ]] && break + sleep 0.1 +done + +if [[ ! -s "${tmpdir}/child.pid" ]]; then + echo "child pid file was not written" >&2 + if [[ -s "${tmpdir}/child.err" ]]; then + cat "${tmpdir}/child.err" >&2 + fi + exit 3 +fi + +child_pid="$(cat "${tmpdir}/child.pid")" +if ! kill -0 "${child_pid}" 2>/dev/null; then + echo "child did not start" >&2 + exit 3 +fi + +kill -9 "${wrapper_pid}" +wait "${wrapper_pid}" 2>/dev/null || true + +deadline=$((SECONDS + 6)) +while (( SECONDS < deadline )); do + if ! kill -0 "${child_pid}" 2>/dev/null; then + exit 0 + fi + sleep 0.2 +done + +echo "codebase-memory-mcp child ${child_pid} survived parent death" >&2 +if [[ -s "${tmpdir}/child.err" ]]; then + cat "${tmpdir}/child.err" >&2 +fi +exit 1