mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-27 06:53:11 +08:00
New src/foundation/subprocess.{h,c}: spawn a child, tail its output, and
classify how it ended — clean / exit-nonzero / crash / hang / killed — from
POSIX WIFSIGNALED/WTERMSIG and the Windows NTSTATUS exception exit codes
(0xC0000005 access violation, 0xC00000FD stack overflow). Adds an EINTR-safe
reap loop, a quiet-timeout that kills and reports a hung child (no new output
within the window), and partial-line-safe log tailing.
Generalized from the ad-hoc index spawn in src/ui/http_server.c so the
crash/hang supervisor can reuse one primitive across platforms. The exit-code
to outcome mapping lives in a pure cbm_proc_classify() so the Windows
crash-code path is unit-tested on every platform, not just an untested
Windows branch.
tests/test_subprocess.c: 13 tests — the classifier on all platforms plus real
POSIX spawn/reap of clean, non-zero, SIGSEGV-crash and hang children. Green
under ASan/UBSan.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
186 lines
6.3 KiB
C
186 lines
6.3 KiB
C
/*
|
|
* test_subprocess.c — foundation/subprocess: spawn + supervise + classify.
|
|
*
|
|
* Two layers:
|
|
* 1. cbm_proc_classify() — pure, exercised on EVERY platform (so the Windows
|
|
* NTSTATUS crash-code mapping is guarded on Linux/macOS CI too, not just an
|
|
* untested Windows branch).
|
|
* 2. cbm_subprocess_run() — real spawn/reap, exercised on POSIX via /bin/sh
|
|
* (SKIP_PLATFORM on Windows, which lacks it).
|
|
*/
|
|
#include "test_framework.h"
|
|
#include "../src/foundation/subprocess.h"
|
|
|
|
#include <string.h>
|
|
|
|
#ifndef _WIN32
|
|
#include <signal.h>
|
|
#endif
|
|
|
|
/* ── Layer 1: pure classifier (all platforms) ─────────────────────────────── */
|
|
|
|
TEST(subprocess_classify_clean) {
|
|
ASSERT_EQ(cbm_proc_classify(true, 0, 0, false), CBM_PROC_CLEAN);
|
|
PASS();
|
|
}
|
|
|
|
TEST(subprocess_classify_exit_nonzero) {
|
|
ASSERT_EQ(cbm_proc_classify(true, 3, 0, false), CBM_PROC_EXIT_NONZERO);
|
|
ASSERT_EQ(cbm_proc_classify(true, 127, 0, false), CBM_PROC_EXIT_NONZERO);
|
|
PASS();
|
|
}
|
|
|
|
/* Windows exception exit codes classify as CRASH — the key reason to unit-test
|
|
* the classifier cross-platform. 0xC0000005 = access violation (SIGSEGV analog),
|
|
* 0xC00000FD = stack overflow (the #668 reporter's 0xC00000FD). */
|
|
TEST(subprocess_classify_windows_crash_codes) {
|
|
ASSERT_EQ(cbm_proc_classify(true, (int)0xC0000005u, 0, false), CBM_PROC_CRASH);
|
|
ASSERT_EQ(cbm_proc_classify(true, (int)0xC00000FDu, 0, false), CBM_PROC_CRASH);
|
|
ASSERT_EQ(cbm_proc_classify(true, (int)0xC000001Du, 0, false), CBM_PROC_CRASH);
|
|
PASS();
|
|
}
|
|
|
|
TEST(subprocess_classify_posix_fault_signal_is_crash) {
|
|
#ifndef _WIN32
|
|
ASSERT_EQ(cbm_proc_classify(false, -1, SIGSEGV, false), CBM_PROC_CRASH);
|
|
ASSERT_EQ(cbm_proc_classify(false, -1, SIGABRT, false), CBM_PROC_CRASH);
|
|
ASSERT_EQ(cbm_proc_classify(false, -1, SIGBUS, false), CBM_PROC_CRASH);
|
|
#endif
|
|
PASS();
|
|
}
|
|
|
|
TEST(subprocess_classify_non_fault_signal_is_killed) {
|
|
#ifndef _WIN32
|
|
ASSERT_EQ(cbm_proc_classify(false, -1, SIGTERM, false), CBM_PROC_KILLED);
|
|
ASSERT_EQ(cbm_proc_classify(false, -1, SIGKILL, false), CBM_PROC_KILLED);
|
|
#endif
|
|
PASS();
|
|
}
|
|
|
|
/* timed_out dominates every other signal — a killed-for-hang child is HANG,
|
|
* not KILLED, even though we deliver SIGKILL/TerminateProcess to end it. */
|
|
TEST(subprocess_classify_timeout_dominates) {
|
|
ASSERT_EQ(cbm_proc_classify(false, -1, 9 /*SIGKILL*/, true), CBM_PROC_HANG);
|
|
ASSERT_EQ(cbm_proc_classify(true, 0, 0, true), CBM_PROC_HANG);
|
|
PASS();
|
|
}
|
|
|
|
TEST(subprocess_outcome_str) {
|
|
ASSERT_STR_EQ(cbm_proc_outcome_str(CBM_PROC_CLEAN), "clean");
|
|
ASSERT_STR_EQ(cbm_proc_outcome_str(CBM_PROC_CRASH), "crash");
|
|
ASSERT_STR_EQ(cbm_proc_outcome_str(CBM_PROC_HANG), "hang");
|
|
ASSERT_STR_EQ(cbm_proc_outcome_str(CBM_PROC_EXIT_NONZERO), "exit_nonzero");
|
|
ASSERT_STR_EQ(cbm_proc_outcome_str(CBM_PROC_KILLED), "killed");
|
|
PASS();
|
|
}
|
|
|
|
/* ── Layer 2: real spawn/reap (POSIX) ─────────────────────────────────────── */
|
|
|
|
#ifndef _WIN32
|
|
static cbm_proc_result_t run_sh(const char *script, int quiet_timeout_ms) {
|
|
const char *argv[] = {"/bin/sh", "-c", script, NULL};
|
|
cbm_proc_opts_t opts = {0};
|
|
opts.bin = "/bin/sh";
|
|
opts.argv = argv;
|
|
opts.log_file = NULL;
|
|
opts.quiet_timeout_ms = quiet_timeout_ms;
|
|
cbm_proc_result_t r;
|
|
cbm_subprocess_run(&opts, &r);
|
|
return r;
|
|
}
|
|
#endif
|
|
|
|
TEST(subprocess_run_clean) {
|
|
#ifdef _WIN32
|
|
SKIP_PLATFORM("POSIX /bin/sh spawn");
|
|
#else
|
|
cbm_proc_result_t r = run_sh("exit 0", 0);
|
|
ASSERT_EQ(r.outcome, CBM_PROC_CLEAN);
|
|
ASSERT_EQ(r.exit_code, 0);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
TEST(subprocess_run_exit_nonzero) {
|
|
#ifdef _WIN32
|
|
SKIP_PLATFORM("POSIX /bin/sh spawn");
|
|
#else
|
|
cbm_proc_result_t r = run_sh("exit 7", 0);
|
|
ASSERT_EQ(r.outcome, CBM_PROC_EXIT_NONZERO);
|
|
ASSERT_EQ(r.exit_code, 7);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
/* A child that dies of SIGSEGV must classify as CRASH — NOT exit_nonzero and NOT
|
|
* killed. This is the whole point of the primitive: distinguish a crash from a
|
|
* clean failure so the supervisor can quarantine the culprit. */
|
|
TEST(subprocess_run_crash_is_crash) {
|
|
#ifdef _WIN32
|
|
SKIP_PLATFORM("POSIX signal semantics");
|
|
#else
|
|
cbm_proc_result_t r = run_sh("kill -SEGV $$", 0);
|
|
ASSERT_EQ(r.outcome, CBM_PROC_CRASH);
|
|
ASSERT_EQ(r.term_signal, SIGSEGV);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
/* A child that makes no progress within the quiet-timeout is killed and reported
|
|
* as HANG — the sibling failure mode of a crash (external-scanner infinite loop). */
|
|
TEST(subprocess_run_hang_is_hang) {
|
|
#ifdef _WIN32
|
|
SKIP_PLATFORM("POSIX /bin/sh spawn");
|
|
#else
|
|
cbm_proc_result_t r = run_sh("sleep 30", 300 /* ms quiet-timeout */);
|
|
ASSERT_EQ(r.outcome, CBM_PROC_HANG);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
/* A spawn of a non-existent binary fails cleanly (no child), not a crash. */
|
|
TEST(subprocess_run_spawn_failure) {
|
|
#ifdef _WIN32
|
|
SKIP_PLATFORM("POSIX exec semantics");
|
|
#else
|
|
/* execv of a bogus path: fork succeeds, child _exit(127). We classify the
|
|
* reaped 127 as exit_nonzero — spawn_failed is reserved for fork() failing. */
|
|
const char *argv[] = {"/nonexistent/cbm-bogus-binary", NULL};
|
|
cbm_proc_opts_t opts = {0};
|
|
opts.bin = "/nonexistent/cbm-bogus-binary";
|
|
opts.argv = argv;
|
|
cbm_proc_result_t r;
|
|
int rc = cbm_subprocess_run(&opts, &r);
|
|
ASSERT_EQ(rc, 0); /* fork itself succeeded */
|
|
ASSERT_EQ(r.outcome, CBM_PROC_EXIT_NONZERO);
|
|
ASSERT_EQ(r.exit_code, 127);
|
|
PASS();
|
|
#endif
|
|
}
|
|
|
|
TEST(subprocess_run_null_bin_rejected) {
|
|
cbm_proc_opts_t opts = {0};
|
|
opts.bin = NULL;
|
|
cbm_proc_result_t r;
|
|
int rc = cbm_subprocess_run(&opts, &r);
|
|
ASSERT_EQ(rc, -1);
|
|
ASSERT_EQ(r.outcome, CBM_PROC_SPAWN_FAILED);
|
|
PASS();
|
|
}
|
|
|
|
SUITE(subprocess) {
|
|
RUN_TEST(subprocess_classify_clean);
|
|
RUN_TEST(subprocess_classify_exit_nonzero);
|
|
RUN_TEST(subprocess_classify_windows_crash_codes);
|
|
RUN_TEST(subprocess_classify_posix_fault_signal_is_crash);
|
|
RUN_TEST(subprocess_classify_non_fault_signal_is_killed);
|
|
RUN_TEST(subprocess_classify_timeout_dominates);
|
|
RUN_TEST(subprocess_outcome_str);
|
|
RUN_TEST(subprocess_run_clean);
|
|
RUN_TEST(subprocess_run_exit_nonzero);
|
|
RUN_TEST(subprocess_run_crash_is_crash);
|
|
RUN_TEST(subprocess_run_hang_is_hang);
|
|
RUN_TEST(subprocess_run_spawn_failure);
|
|
RUN_TEST(subprocess_run_null_bin_rejected);
|
|
}
|