feat(foundation): add cross-platform subprocess spawn + exit classification

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>
This commit is contained in:
Martin Vogel committed 2026-07-03 11:20:32 +02:00
1 parent 9cb3cabf76
commit c7c94003cc
5 files changed
+575 -2

No files matched your search

+4 -2
View File
@@ -113,7 +113,8 @@ FOUNDATION_SRCS = \
src/foundation/diagnostics.c \
src/foundation/profile.c \
src/foundation/dump_verify.c \
src/foundation/limits.c
src/foundation/limits.c \
src/foundation/subprocess.c
# Existing extraction C code (compiled from current location)
EXTRACTION_SRCS = \
@@ -310,7 +311,8 @@ TEST_FOUNDATION_SRCS = \
tests/test_log.c \
tests/test_str_util.c \
tests/test_platform.c \
tests/test_dump_verify.c
tests/test_dump_verify.c \
tests/test_subprocess.c
TEST_EXTRACTION_SRCS = \
tests/test_extraction.c \
+308
View File
@@ -0,0 +1,308 @@
/*
* subprocess.c — cross-platform spawn + supervise + classify.
* See subprocess.h. The spawn/reap skeleton mirrors src/ui/http_server.c's
* index subprocess; this generalizes it and adds crash/hang classification.
*/
#include "subprocess.h"
#include "compat.h" /* cbm_nanosleep */
#include "platform.h" /* cbm_now_ms */
#include <stdio.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
/* NTSTATUS severity ERROR (top two bits set) covers the Windows crash exception
* exit codes: 0xC0000005 (access violation), 0xC00000FD (stack overflow),
* 0xC000001D (illegal instruction), 0xC0000094 (integer divide by zero), … */
#define CBM_WIN_CRASH_CODE_MIN 0xC0000000u
#ifndef _WIN32
static bool cbm_is_fault_signal(int sig) {
switch (sig) {
case SIGSEGV:
case SIGBUS:
case SIGILL:
case SIGFPE:
case SIGABRT:
case SIGSYS:
return true;
default:
return false;
}
}
#endif
cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int term_signal,
bool timed_out) {
if (timed_out) {
return CBM_PROC_HANG;
}
if (!exited_normally) {
/* POSIX signal death. */
#ifndef _WIN32
if (cbm_is_fault_signal(term_signal)) {
return CBM_PROC_CRASH;
}
#else
(void)term_signal;
#endif
return CBM_PROC_KILLED;
}
/* Exited with a code. A Windows NTSTATUS exception code is a crash; on POSIX
* exit codes are 0..255 so this branch never misfires there. */
if ((unsigned)exit_code >= CBM_WIN_CRASH_CODE_MIN) {
return CBM_PROC_CRASH;
}
return (exit_code == 0) ? CBM_PROC_CLEAN : CBM_PROC_EXIT_NONZERO;
}
const char *cbm_proc_outcome_str(cbm_proc_outcome_t o) {
switch (o) {
case CBM_PROC_CLEAN:
return "clean";
case CBM_PROC_EXIT_NONZERO:
return "exit_nonzero";
case CBM_PROC_CRASH:
return "crash";
case CBM_PROC_HANG:
return "hang";
case CBM_PROC_KILLED:
return "killed";
case CBM_PROC_SPAWN_FAILED:
default:
return "spawn_failed";
}
}
/* Tail newly-appended complete lines from the child log, starting at *tail_pos.
* A partial (non-newline-terminated) final line is left buffered: *tail_pos is
* not advanced past it, so it is re-read once completed. Returns true if any
* complete line was consumed (i.e. there was progress). */
static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb cb, void *ud) {
if (!log_file) {
return false;
}
FILE *lf = fopen(log_file, "r");
if (!lf) {
return false;
}
bool progressed = false;
if (fseek(lf, *tail_pos, SEEK_SET) == 0) {
char line[1024];
for (;;) {
long before = ftell(lf);
if (!fgets(line, sizeof(line), lf)) {
break;
}
size_t l = strlen(line);
bool complete = (l > 0 && line[l - 1] == '\n');
if (complete) {
line[l - 1] = '\0';
*tail_pos = ftell(lf);
progressed = true;
if (line[0] && cb) {
cb(line, ud);
}
} else if (l == sizeof(line) - 1) {
/* Oversized line filled the buffer without a newline — consume it
* anyway (counts as progress) so we never stall on one long line. */
*tail_pos = ftell(lf);
progressed = true;
if (cb) {
cb(line, ud);
}
} else {
/* Genuine partial final line — keep it buffered for next poll. */
*tail_pos = before;
break;
}
}
}
fclose(lf);
return progressed;
}
#ifdef _WIN32
static int cbm_run_win(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
const char *bin = opts->bin;
const char *const default_argv[] = {bin, NULL};
const char *const *argv = opts->argv ? opts->argv : default_argv;
/* Build a quoted command line from argv. */
char cmdline[8192];
size_t pos = 0;
for (int i = 0; argv[i]; i++) {
int n = snprintf(cmdline + pos, sizeof(cmdline) - pos, "%s\"%s\"", (i ? " " : ""), argv[i]);
if (n < 0 || (size_t)n >= sizeof(cmdline) - pos) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
pos += (size_t)n;
}
HANDLE hlog = INVALID_HANDLE_VALUE;
STARTUPINFOA si = {.cb = sizeof(si)};
if (opts->log_file) {
hlog = CreateFileA(opts->log_file, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, NULL);
if (hlog != INVALID_HANDLE_VALUE) {
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdError = hlog;
si.hStdOutput = hlog;
}
}
PROCESS_INFORMATION pi = {0};
BOOL ok = CreateProcessA(NULL, cmdline, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
if (hlog != INVALID_HANDLE_VALUE) {
CloseHandle(hlog);
}
if (!ok) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
long tail_pos = 0;
uint64_t last_activity = cbm_now_ms();
bool timed_out = false;
for (;;) {
DWORD w = WaitForSingleObject(pi.hProcess, 200);
if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) {
last_activity = cbm_now_ms();
}
if (w == WAIT_OBJECT_0) {
break;
}
if (opts->quiet_timeout_ms > 0 &&
(cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) {
TerminateProcess(pi.hProcess, 1);
WaitForSingleObject(pi.hProcess, INFINITE);
timed_out = true;
break;
}
}
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
if (opts->log_file && opts->delete_log_on_exit) {
DeleteFileA(opts->log_file);
}
out->exit_code = (int)code;
out->term_signal = 0;
out->outcome = cbm_proc_classify(true, (int)code, 0, timed_out);
return 0;
}
#else /* POSIX */
static int cbm_run_posix(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
const char *bin = opts->bin;
const char *const default_argv[] = {bin, NULL};
const char *const *argv = opts->argv ? opts->argv : default_argv;
pid_t pid = fork();
if (pid < 0) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
if (pid == 0) {
/* Child: redirect stdout+stderr to the log (or discard), then exec. */
if (opts->log_file) {
(void)!freopen(opts->log_file, "w", stderr);
(void)!freopen(opts->log_file, "a", stdout);
} else {
(void)!freopen("/dev/null", "w", stderr);
(void)!freopen("/dev/null", "w", stdout);
}
execv(bin, (char *const *)argv);
_exit(127); /* exec failed */
}
long tail_pos = 0;
uint64_t last_activity = cbm_now_ms();
bool timed_out = false;
int wstatus = 0;
for (;;) {
pid_t wr;
do {
wr = waitpid(pid, &wstatus, WNOHANG);
} while (wr < 0 && errno == EINTR);
bool done = (wr == pid);
if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) {
last_activity = cbm_now_ms();
}
if (done) {
break;
}
if (opts->quiet_timeout_ms > 0 &&
(cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) {
kill(pid, SIGKILL);
do {
wr = waitpid(pid, &wstatus, 0);
} while (wr < 0 && errno == EINTR);
timed_out = true;
break;
}
struct timespec ts = {0, 100000000L}; /* 100 ms poll */
cbm_nanosleep(&ts, NULL);
}
if (opts->log_file && opts->delete_log_on_exit) {
(void)unlink(opts->log_file);
}
if (WIFEXITED(wstatus)) {
out->exit_code = WEXITSTATUS(wstatus);
out->term_signal = 0;
out->outcome = cbm_proc_classify(true, out->exit_code, 0, timed_out);
} else if (WIFSIGNALED(wstatus)) {
out->exit_code = -1;
out->term_signal = WTERMSIG(wstatus);
out->outcome = cbm_proc_classify(false, -1, out->term_signal, timed_out);
} else {
out->exit_code = -1;
out->term_signal = 0;
out->outcome = timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED;
}
return 0;
}
#endif
int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
cbm_proc_result_t local;
if (!out) {
out = &local;
}
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
if (!opts || !opts->bin || !opts->bin[0]) {
return -1;
}
#ifdef _WIN32
return cbm_run_win(opts, out);
#else
return cbm_run_posix(opts, out);
#endif
}
+76
View File
@@ -0,0 +1,76 @@
/*
* subprocess.h — spawn a child process, supervise it, and classify how it ended.
*
* Generalized from the crash-isolating index spawn in src/ui/http_server.c so the
* crash/hang supervisor (Track C) can reuse one primitive across platforms.
*
* Beyond a plain spawn+wait it adds the two things a supervisor needs and the
* ad-hoc harness lacked:
* 1. Exit CLASSIFICATION — {clean, exit-nonzero, crash, hang, killed} — from
* POSIX WIFSIGNALED/WTERMSIG and the Windows NTSTATUS exception exit codes
* (0xC0000005 access-violation, 0xC00000FD stack-overflow, …).
* 2. A quiet-timeout — kill + report HANG when the child makes no progress
* (emits no new log line) for a configurable window. This catches external
* tree-sitter scanners that infinite-loop (a hang, not a crash).
*
* The reap loop is EINTR-safe. Line tailing keeps a partial final line buffered
* (an incomplete, un-newline-terminated line is not yet "progress" and is not
* mis-read as a completed marker).
*/
#ifndef CBM_SUBPROCESS_H
#define CBM_SUBPROCESS_H
#include <stdbool.h>
/* How a supervised child ended. */
typedef enum {
CBM_PROC_CLEAN = 0, /* exited with code 0 */
CBM_PROC_EXIT_NONZERO, /* exited with a nonzero code (a graceful failure) */
CBM_PROC_CRASH, /* died from a fault: POSIX SIGSEGV/BUS/ILL/FPE/ABRT/SYS,
* or a Windows NTSTATUS exception exit code (>= 0xC0000000) */
CBM_PROC_HANG, /* made no progress within the quiet-timeout; we killed it */
CBM_PROC_KILLED, /* terminated by a non-fault signal we did not initiate */
CBM_PROC_SPAWN_FAILED /* fork/exec/CreateProcess failed — no child ever ran */
} cbm_proc_outcome_t;
typedef struct {
cbm_proc_outcome_t outcome;
int exit_code; /* WEXITSTATUS / GetExitCodeProcess; -1 when terminated by a POSIX signal */
int term_signal; /* WTERMSIG on POSIX; 0 otherwise */
} cbm_proc_result_t;
/* Called for each newly-completed (newline-terminated) log line while the child
* runs. A completed line also resets the quiet-timeout (it is progress). */
typedef void (*cbm_proc_log_cb)(const char *line, void *ud);
typedef struct {
const char *bin; /* executable path; also argv[0] when argv is NULL */
const char *const *argv; /* NULL-terminated argv; NULL => { bin, NULL } */
const char *log_file; /* child stdout+stderr are redirected here and tailed;
* NULL => discard child output, no tailing */
cbm_proc_log_cb on_log_line; /* optional per-line callback */
void *log_ud; /* user data for on_log_line */
int quiet_timeout_ms; /* <= 0 => no timeout; else kill+HANG after this many
* ms with no new completed log line */
bool delete_log_on_exit; /* unlink log_file after reaping */
} cbm_proc_opts_t;
/* Spawn opts->bin, supervise (tail + optional quiet-timeout), block until it ends,
* and classify the result into *out. Returns 0 if a child was spawned and reaped
* (out filled), or -1 if the spawn itself failed (out->outcome == CBM_PROC_SPAWN_FAILED). */
int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out);
/* Pure outcome classifier — exposed so the platform-specific exit-code mapping
* (notably the Windows NTSTATUS crash codes) is unit-testable on every platform.
* exited_normally: the child returned an exit code (POSIX WIFEXITED; always true
* on Windows, which has no signals — crashes surface as codes).
* exit_code: the exit / exception code (meaningful when exited_normally).
* term_signal: POSIX terminating signal (meaningful when !exited_normally).
* timed_out: we killed the child for exceeding the quiet-timeout. */
cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int term_signal,
bool timed_out);
/* Stable lowercase name for an outcome (for structured logs / skip reasons). */
const char *cbm_proc_outcome_str(cbm_proc_outcome_t o);
#endif /* CBM_SUBPROCESS_H */
+2
View File
@@ -43,6 +43,7 @@ extern void suite_str_intern(void);
extern void suite_log(void);
extern void suite_str_util(void);
extern void suite_platform(void);
extern void suite_subprocess(void);
extern void suite_extraction(void);
extern void suite_extraction_inheritance(void);
extern void suite_extraction_imports(void);
@@ -145,6 +146,7 @@ int main(int argc, char **argv) {
RUN_SELECTED_SUITE(log);
RUN_SELECTED_SUITE(str_util);
RUN_SELECTED_SUITE(platform);
RUN_SELECTED_SUITE(subprocess);
RUN_SELECTED_SUITE(dump_verify);
/* Existing C code regression tests */
+185
View File
@@ -0,0 +1,185 @@
/*
* 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);
}