diff --git a/Makefile.cbm b/Makefile.cbm index 2c6044c74..ddd080822 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -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 \ diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c new file mode 100644 index 000000000..14ba76a40 --- /dev/null +++ b/src/foundation/subprocess.c @@ -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 +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#include +#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 +} diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h new file mode 100644 index 000000000..bb8d683af --- /dev/null +++ b/src/foundation/subprocess.h @@ -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 + +/* 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 */ diff --git a/tests/test_main.c b/tests/test_main.c index 67d3d81b3..df86c18b0 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -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 */ diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c new file mode 100644 index 000000000..bd73e7ed0 --- /dev/null +++ b/tests/test_subprocess.c @@ -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 + +#ifndef _WIN32 +#include +#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); +}