Files

2671 lines
103 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 "test_helpers.h"
#include "../src/foundation/subprocess.h"
#include "../src/foundation/compat.h"
#include "../src/foundation/platform.h"
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/git_env.h"
#include "../src/foundation/mem_core.h"
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <fcntl.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <unistd.h>
#ifdef __linux__
#include <sys/syscall.h>
#if !defined(SYS_close_range) && (defined(__x86_64__) || defined(__aarch64__))
#define SYS_close_range 436
#endif
#endif
#else
#include <fcntl.h>
#include <io.h>
#include <windows.h>
#include "../src/foundation/win_utf8.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);
ASSERT_EQ(cbm_proc_classify(true, (int)0xC000013Au, 0, false), CBM_PROC_KILLED);
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) ─────────────────────────────────────── */
extern const char *cbm_test_program_path;
TEST(subprocess_environment_is_child_local) {
const char *names[] = {"CBM_ENV_TEST_VALUE", "CBM_ENV_TEST_KEEP", "CBM_ENV_TEST_EMPTY",
"CBM_ENV_TEST_NEW"};
char *saved[4] = {0};
for (int i = 0; i < 4; i++) {
const char *value = getenv(names[i]);
saved[i] = value ? cbm_strdup(value) : NULL;
if (i == 3) {
(void)cbm_unsetenv(names[i]);
} else {
(void)cbm_setenv(names[i], i == 1 ? "kept" : "parent", 1);
}
}
const char *args[] = {cbm_test_program_path,
"__cbm_env_values",
names[0],
"child",
names[1],
"kept",
names[2],
"",
names[3],
"last=tail",
NULL};
const char *overrides[] = {
#ifdef _WIN32
"cbm_env_test_value=child",
#else
"CBM_ENV_TEST_VALUE=child",
#endif
"CBM_ENV_TEST_EMPTY=", "CBM_ENV_TEST_NEW=first", "CBM_ENV_TEST_NEW=last=tail", NULL};
cbm_proc_opts_t opts = {.bin = cbm_test_program_path,
.argv = args,
.env_overrides = overrides,
.quiet_timeout_ms = 5000};
cbm_proc_result_t result;
int rc = cbm_subprocess_run(&opts, &result);
bool parent_unchanged = strcmp(getenv(names[0]), "parent") == 0 &&
strcmp(getenv(names[1]), "kept") == 0 &&
strcmp(getenv(names[2]), "parent") == 0 && getenv(names[3]) == NULL;
for (int i = 0; i < 4; i++) {
if (saved[i]) {
(void)cbm_setenv(names[i], saved[i], 1);
} else {
(void)cbm_unsetenv(names[i]);
}
free(saved[i]);
}
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(parent_unchanged);
PASS();
}
TEST(subprocess_environment_rejects_invalid_override) {
const char *args[] = {cbm_test_program_path, "--version", NULL};
const char *invalid[] = {"MISSING_EQUALS", "", "=value"};
for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) {
const char *overrides[] = {invalid[i], NULL};
cbm_proc_opts_t opts = {.bin = cbm_test_program_path,
.argv = args,
.env_overrides = overrides,
.quiet_timeout_ms = 5000};
cbm_proc_result_t result;
ASSERT_EQ(cbm_subprocess_run(&opts, &result), -1);
ASSERT_EQ(result.outcome, CBM_PROC_SPAWN_FAILED);
}
PASS();
}
/* Git scrubbing wins over a repository-local override, while ordinary overrides
* remain child-local. The early child probe runs before test-runner scrubbing. */
TEST(subprocess_environment_overrides_preserve_git_isolation) {
const char *names[] = {"GIT_DIR", "GIT_WORK_TREE", "GIT_DIFF_OPTS", "CBM_ENV_GIT_KEEP"};
char *saved[4] = {0};
bool present[4] = {0};
bool setup = true;
for (int i = 0; i < 4; i++) {
const char *value = getenv(names[i]);
present[i] = value != NULL;
saved[i] = value ? cbm_strdup(value) : NULL;
setup = setup && (!present[i] || saved[i]);
}
if (setup) {
for (int i = 0; i < 4; i++) {
setup = cbm_setenv(names[i], "parent", 1) == 0 && setup;
}
}
const char *args[] = {cbm_test_program_path,
"__cbm_env_isolation",
names[3],
"child",
names[0],
names[1],
names[2],
NULL};
const char *overrides[] = {
#ifdef _WIN32
"git_dir=/cbm-override-repo",
#else
"GIT_DIR=/cbm-override-repo",
#endif
"GIT_DIFF_OPTS=--unified=999", "CBM_ENV_GIT_KEEP=child", NULL};
cbm_proc_opts_t opts = {.bin = cbm_test_program_path,
.argv = args,
.env_overrides = overrides,
.strip_git_repo_env = true,
.quiet_timeout_ms = 5000};
cbm_proc_result_t result = {0};
int rc = setup ? cbm_subprocess_run(&opts, &result) : -1;
bool parent_unchanged = true;
for (int i = 0; i < 4; i++) {
const char *value = getenv(names[i]);
parent_unchanged = parent_unchanged && value && strcmp(value, "parent") == 0;
if (saved[i]) {
(void)cbm_setenv(names[i], saved[i], 1);
} else if (!present[i]) {
(void)cbm_unsetenv(names[i]);
}
free(saved[i]);
}
ASSERT_TRUE(setup);
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.exit_code, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(parent_unchanged);
PASS();
}
/* Environment ownership outlives spawn, even if the child already exited.
* Count while the handle is retained, then release only after terminal poll.
* This also catches a libc/core mismatch on production mimalloc builds. */
TEST(subprocess_environment_core_ownership) {
const char *args[] = {cbm_test_program_path, "__cbm_env_values", "CBM_CORE_ENV_TEST", "child",
NULL};
const char *overrides[] = {"CBM_CORE_ENV_TEST=first", "CBM_CORE_ENV_TEST=child", NULL};
cbm_proc_opts_t opts = {.bin = cbm_test_program_path,
.argv = args,
.env_overrides = overrides,
.quiet_timeout_ms = 5000};
size_t before = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
cbm_subprocess_t *process = NULL;
int rc = cbm_subprocess_spawn(&opts, &process);
bool tracked = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER) > before;
cbm_proc_result_t result = {0};
cbm_proc_poll_t poll = CBM_PROC_POLL_ERROR;
if (rc == 0) {
do {
poll = cbm_subprocess_poll(process, &result);
if (poll == CBM_PROC_POLL_RUNNING) {
cbm_usleep(1000);
}
} while (poll == CBM_PROC_POLL_RUNNING);
}
if (poll == CBM_PROC_POLL_TERMINAL) {
cbm_subprocess_destroy(process);
}
size_t after = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
ASSERT_EQ(rc, 0);
ASSERT_EQ(poll, CBM_PROC_POLL_TERMINAL);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(tracked);
ASSERT_EQ(after, before);
PASS();
}
#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 short command can finish before its parent checks the child's process
* group; macOS then answers ESRCH for the exited, unreaped child. That is a
* finished run with its own exit status, never a spawn failure (macOS CI read
* `git write-tree` as SPAWN_FAILED errno 3 under load). The seam holds the
* parent until the child has exited: that order, deterministically. */
TEST(subprocess_run_child_exited_before_group_check) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process groups");
#else
cbm_subprocess_hold_parent_until_child_exits_for_testing(1);
cbm_proc_result_t r = run_sh("exit 3", 0);
cbm_subprocess_hold_parent_until_child_exits_for_testing(0);
ASSERT_EQ(r.outcome, CBM_PROC_EXIT_NONZERO);
ASSERT_EQ(r.exit_code, 3);
PASS();
#endif
}
/* The window before that: macOS answers ESRCH for a child that has started
* exiting, before waitid can report it. A non-blocking "has it exited?"
* probe said no there, and macos-15-intel CI read a short `git config` as
* SPAWN_FAILED errno 3. The seam makes the parent observe exactly that. */
TEST(subprocess_run_child_exiting_at_group_check) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process groups");
#else
cbm_subprocess_observe_exiting_child_for_testing(1);
cbm_proc_result_t r = run_sh("exit 3", 0);
cbm_subprocess_observe_exiting_child_for_testing(0);
ASSERT_EQ(r.outcome, CBM_PROC_EXIT_NONZERO);
ASSERT_EQ(r.exit_code, 3);
PASS();
#endif
}
/* Daemon background helpers intentionally invoke fixed tool names such as
* `curl` and `git`. A shell-free spawn must still perform the normal PATH
* lookup for a name without a directory separator; exact binary paths keep
* their existing exec semantics. */
TEST(subprocess_run_resolves_literal_binary_name_from_path) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX PATH/execvp semantics");
#else
const char *argv[] = {"sh", "-c", "exit 0", NULL};
cbm_proc_opts_t opts = {
.bin = "sh",
.argv = argv,
};
cbm_proc_result_t result;
int rc = cbm_subprocess_run(&opts, &result);
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_EQ(result.exit_code, 0);
PASS();
#endif
}
#ifndef _WIN32
/* Both the native spawn and ENOEXEC shell fallback must select the caller's
* tool, even when the child receives a different PATH with the same tool name. */
TEST(subprocess_environment_path_preserves_caller_lookup) {
char parent_dir[] = "/tmp/cbm-env-parent-XXXXXX";
char child_dir[] = "/tmp/cbm-env-child-XXXXXX";
ASSERT_NOT_NULL(mkdtemp(parent_dir));
if (!mkdtemp(child_dir)) {
(void)rmdir(parent_dir);
FAIL("could not create child PATH fixture");
}
char parent_tool[PATH_MAX];
char child_tool[PATH_MAX];
char path_override[PATH_MAX];
snprintf(parent_tool, sizeof(parent_tool), "%s/cbm-env-probe", parent_dir);
snprintf(child_tool, sizeof(child_tool), "%s/cbm-env-probe", child_dir);
snprintf(path_override, sizeof(path_override), "PATH=%s", child_dir);
const char *old_path = getenv("PATH");
char *saved_path = old_path ? cbm_strdup(old_path) : NULL;
bool had_path = old_path != NULL;
bool setup = !had_path || saved_path != NULL;
bool parent_unchanged = true;
cbm_proc_result_t results[3] = {0};
int spawn_rc[3] = {-1, -1, -1};
const char *args[] = {"cbm-env-probe", child_dir, NULL};
const char *overrides[] = {path_override, NULL};
cbm_proc_opts_t opts = {
.bin = args[0], .argv = args, .env_overrides = overrides, .quiet_timeout_ms = 5000};
for (int i = 0; setup && i < 2; i++) {
FILE *parent_file = fopen(parent_tool, "w");
FILE *child_file = fopen(child_tool, "w");
if (!parent_file || !child_file) {
if (parent_file) {
fclose(parent_file);
}
if (child_file) {
fclose(child_file);
}
setup = false;
break;
}
const char *prefix = i == 0 ? "#!/bin/sh\n" : "";
setup = fprintf(parent_file, "%stest \"$PATH\" = \"$1\"\n", prefix) >= 0 &&
fprintf(child_file, "%sexit 42\n", prefix) >= 0 &&
fchmod(fileno(parent_file), 0700) == 0 && fchmod(fileno(child_file), 0700) == 0;
if (fclose(parent_file) != 0) {
setup = false;
}
if (fclose(child_file) != 0) {
setup = false;
}
if (!setup || cbm_setenv("PATH", parent_dir, 1) != 0) {
setup = false;
break;
}
spawn_rc[i] = cbm_subprocess_run(&opts, &results[i]);
parent_unchanged =
parent_unchanged && getenv("PATH") && strcmp(getenv("PATH"), parent_dir) == 0;
}
if (setup) {
/* ENOENT must remain a missing caller-side tool, even though an
* executable with the same name exists only in the child's PATH. */
setup = unlink(parent_tool) == 0;
if (setup) {
spawn_rc[2] = cbm_subprocess_run(&opts, &results[2]);
parent_unchanged =
parent_unchanged && getenv("PATH") && strcmp(getenv("PATH"), parent_dir) == 0;
}
}
if (saved_path) {
(void)cbm_setenv("PATH", saved_path, 1);
} else if (!had_path) {
(void)cbm_unsetenv("PATH");
}
free(saved_path);
(void)unlink(parent_tool);
(void)unlink(child_tool);
(void)rmdir(parent_dir);
(void)rmdir(child_dir);
ASSERT_TRUE(setup);
ASSERT_TRUE(parent_unchanged);
for (int i = 0; i < 2; i++) {
ASSERT_EQ(spawn_rc[i], 0);
ASSERT_EQ(results[i].exit_code, 0);
ASSERT_EQ(results[i].outcome, CBM_PROC_CLEAN);
}
ASSERT_EQ(spawn_rc[2], 0);
ASSERT_EQ(results[2].exit_code, 127);
ASSERT_EQ(results[2].outcome, CBM_PROC_EXIT_NONZERO);
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. */
/* The kernel refusing a spawn with EAGAIN means "not right now", not "never" —
* a momentarily full process table on a busy machine. We used to treat it as a
* permanent failure, so a git probe or LSP server refused to start for a reason
* the user could neither see nor act on, and `subprocess_run_spawn_failure`
* failed on loaded CI runners with the contract intact.
*
* These pin the retry itself rather than the constant. Injecting refusals is
* deterministic, so this proves the loop retries on EVERY machine instead of
* only on one that happens to be starved. */
TEST(subprocess_retries_transient_spawn_refusal) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX fork/EAGAIN path");
#else
/* Fewer refusals than the budget: the spawn must still succeed. */
cbm_subprocess_force_spawn_eagain_for_testing(3);
cbm_proc_result_t r = run_sh("exit 0", 0);
ASSERT_EQ(cbm_subprocess_pending_spawn_eagain_for_testing(), 0);
ASSERT_EQ(r.outcome, CBM_PROC_CLEAN);
ASSERT_EQ(r.exit_code, 0);
PASS();
#endif
}
#ifndef _WIN32
static volatile sig_atomic_t g_spawn_backoff_alarm_count = 0;
static void spawn_backoff_alarm_handler(int signal_number) {
(void)signal_number;
g_spawn_backoff_alarm_count++;
}
#endif
TEST(subprocess_spawn_backoff_resumes_after_eintr) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX signal interruption");
#else
struct sigaction action = {0};
struct sigaction previous_action = {0};
action.sa_handler = spawn_backoff_alarm_handler;
(void)sigemptyset(&action.sa_mask);
bool handler_installed = sigaction(SIGALRM, &action, &previous_action) == 0;
struct itimerval timer = {
.it_interval = {.tv_sec = 0, .tv_usec = 1000},
.it_value = {.tv_sec = 0, .tv_usec = 1000},
};
g_spawn_backoff_alarm_count = 0;
bool timer_started = handler_installed && setitimer(ITIMER_REAL, &timer, NULL) == 0;
uint64_t started_at = cbm_now_ms();
cbm_subprocess_force_spawn_eagain_for_testing(3);
cbm_proc_result_t result = run_sh("exit 0", 0);
uint64_t elapsed_ms = cbm_now_ms() - started_at;
struct itimerval disabled = {0};
(void)setitimer(ITIMER_REAL, &disabled, NULL);
if (handler_installed) {
(void)sigaction(SIGALRM, &previous_action, NULL);
}
ASSERT_TRUE(handler_installed);
ASSERT_TRUE(timer_started);
ASSERT_TRUE(g_spawn_backoff_alarm_count > 0);
ASSERT_TRUE(elapsed_ms >= 50);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
PASS();
#endif
}
TEST(subprocess_gives_up_after_the_retry_budget) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX fork/EAGAIN path");
#else
/* More refusals than the budget: it must fail rather than retry forever.
* A machine still refusing after ~0.6s is genuinely out of capacity, and
* failing fast beats hanging. */
cbm_subprocess_force_spawn_eagain_for_testing(50);
cbm_proc_result_t r = run_sh("exit 0", 0);
bool refused = r.outcome == CBM_PROC_SPAWN_FAILED;
cbm_subprocess_force_spawn_eagain_for_testing(0); /* never leak into later tests */
ASSERT_TRUE(refused);
PASS();
#endif
}
TEST(subprocess_run_spawn_failure) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX exec semantics");
#else
/* execvp 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;
/* Reusing a previous result must not expose stale Job Object diagnostics
* when validation rejects the next spawn before a process exists. */
cbm_proc_result_t r = {
.job_memory_limit_bytes = 123,
.peak_job_memory_bytes = 456,
.job_memory_available = true,
};
int rc = cbm_subprocess_run(&opts, &r);
ASSERT_EQ(rc, -1);
ASSERT_EQ(r.outcome, CBM_PROC_SPAWN_FAILED);
ASSERT_EQ(r.exit_code, -1);
ASSERT_TRUE(r.job_memory_limit_bytes == 0);
ASSERT_TRUE(r.peak_job_memory_bytes == 0);
ASSERT_FALSE(r.job_memory_available);
PASS();
}
/* ── Layer 3: nonblocking handle + whole-tree cancellation (POSIX) ──────────
*
* The daemon cannot block its coordinator thread in cbm_subprocess_run(). It
* owns an opaque handle, polls it, and requests cancellation when the final job
* subscriber leaves. These probes deliberately use /bin/sh descendants which
* ignore SIGTERM: a direct-child-only implementation leaves the grandchild alive,
* while a correct process-group implementation escalates and reports quiescence.
* All test-side waits have explicit monotonic deadlines. */
#ifndef _WIN32
static void subprocess_test_pause(void) {
const struct timespec delay = {0, 10000000L}; /* 10 ms */
(void)cbm_nanosleep(&delay, NULL);
}
static bool poll_until_terminal(cbm_subprocess_t *process, int timeout_ms, cbm_proc_result_t *out) {
uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms;
do {
cbm_proc_poll_t state = cbm_subprocess_poll(process, out);
if (state == CBM_PROC_POLL_TERMINAL) {
return true;
}
if (state == CBM_PROC_POLL_ERROR) {
return false;
}
subprocess_test_pause();
} while (cbm_now_ms() < deadline);
return cbm_subprocess_poll(process, out) == CBM_PROC_POLL_TERMINAL;
}
static bool make_tree_pid_path(char path[64]) {
strcpy(path, "/tmp/cbm-subprocess-tree-XXXXXX");
int fd = cbm_mkstemp(path);
if (fd < 0) {
return false;
}
(void)close(fd);
return unlink(path) == 0; /* child creates it only after both traps are installed */
}
static bool wait_for_tree_pids(const char *path, cbm_subprocess_t *process, pid_t *parent_pid,
pid_t *grandchild_pid, int timeout_ms) {
uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms;
do {
FILE *f = fopen(path, "r");
if (f) {
long parent_value = 0;
long grandchild_value = 0;
int fields = fscanf(f, "%ld %ld", &parent_value, &grandchild_value);
fclose(f);
if (fields == 2 && parent_value > 1 && grandchild_value > 1) {
*parent_pid = (pid_t)parent_value;
*grandchild_pid = (pid_t)grandchild_value;
return true;
}
}
cbm_proc_result_t ignored;
if (cbm_subprocess_poll(process, &ignored) != CBM_PROC_POLL_RUNNING) {
return false;
}
subprocess_test_pause();
} while (cbm_now_ms() < deadline);
return false;
}
static bool wait_pid_gone(pid_t pid, int timeout_ms) {
uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms;
do {
errno = 0;
if (kill(pid, 0) < 0 && errno == ESRCH) {
return true;
}
subprocess_test_pause();
} while (cbm_now_ms() < deadline);
errno = 0;
return kill(pid, 0) < 0 && errno == ESRCH;
}
/* Best-effort cleanup for a failing implementation, so a red tree test does not
* leave its TERM-ignoring probes behind for later tests. The production API must
* still report terminal itself; callers never use this escape hatch. */
static void force_probe_cleanup(pid_t parent_pid, pid_t grandchild_pid) {
if (parent_pid > 1) {
(void)kill(-parent_pid, SIGKILL);
(void)kill(parent_pid, SIGKILL);
}
if (grandchild_pid > 1) {
(void)kill(grandchild_pid, SIGKILL);
}
}
static int spawn_ignoring_tree(const char *pid_path, int quiet_timeout_ms, int cancel_grace_ms,
cbm_subprocess_t **out) {
/* The direct child installs its trap before starting a nested shell. The two
* PIDs are written only after the nested process exists, eliminating the
* cancellation-before-trap race from the test. */
const char *script = "trap '' TERM; "
"/bin/sh -c 'trap \"\" TERM; while :; do sleep 1; done' cbm-grandchild & "
"grandchild=$!; echo \"$$ $grandchild\" > \"$1\"; wait";
const char *argv[] = {"/bin/sh", "-c", script, "cbm-parent", pid_path, NULL};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
opts.quiet_timeout_ms = quiet_timeout_ms;
opts.cancel_grace_ms = cancel_grace_ms;
return cbm_subprocess_spawn(&opts, out);
}
typedef struct {
cbm_subprocess_t *process;
int count;
bool ordered;
bool late_cancel_attempted;
bool late_cancel_accepted;
} subprocess_log_capture_t;
static void ordered_log_callback(const char *line, void *opaque) {
subprocess_log_capture_t *capture = opaque;
int index = -1;
char trailing = '\0';
bool parsed = sscanf(line, "line-%d%c", &index, &trailing) == 1;
capture->ordered = capture->ordered && parsed && index == capture->count;
capture->count++;
if (index == 399) {
capture->late_cancel_attempted = true;
capture->late_cancel_accepted = cbm_subprocess_request_cancel(capture->process);
}
}
static bool wait_for_log_marker(const char *path, const char *marker, uint64_t deadline_ms) {
char contents[8192];
do {
FILE *file = fopen(path, "rb");
if (file) {
size_t used = fread(contents, 1, sizeof(contents) - 1, file);
contents[used] = '\0';
(void)fclose(file);
if (strstr(contents, marker)) {
return true;
}
}
subprocess_test_pause();
} while (cbm_now_ms() < deadline_ms);
return false;
}
#endif /* !_WIN32 */
TEST(subprocess_spawn_returns_while_child_is_running) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX /bin/sh nonblocking spawn probe; native Job Object coverage pending");
#else
const char *argv[] = {"/bin/sh", "-c", "sleep 4", NULL};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
opts.cancel_grace_ms = 100;
cbm_subprocess_t *process = NULL;
uint64_t before_spawn = cbm_now_ms();
int spawn_rc = cbm_subprocess_spawn(&opts, &process);
uint64_t spawn_elapsed = cbm_now_ms() - before_spawn;
ASSERT_EQ(spawn_rc, 0);
ASSERT_NOT_NULL(process);
cbm_proc_result_t result;
uint64_t before_poll = cbm_now_ms();
cbm_proc_poll_t first_poll = cbm_subprocess_poll(process, &result);
uint64_t poll_elapsed = cbm_now_ms() - before_poll;
bool cancel_accepted = cbm_subprocess_request_cancel(process);
bool terminal = poll_until_terminal(process, 2000, &result);
if (terminal) {
cbm_subprocess_destroy(process);
}
/* first_poll == RUNNING is the authoritative proof of non-blocking: the
* 4s child is still running when spawn+poll returned. The wall-clock bounds
* are only a coarse backstop against a regression that blocks on the child;
* they stay well under the 4s child so heavy scheduler starvation (the
* CBM_LOCAL_CI_CPUS=4 fidelity pass, CI runners) can never make them
* test-significant. */
ASSERT_EQ(first_poll, CBM_PROC_POLL_RUNNING);
ASSERT_LT(spawn_elapsed, 3500);
ASSERT_LT(poll_elapsed, 3500);
ASSERT_TRUE(cancel_accepted);
ASSERT_TRUE(terminal);
ASSERT_TRUE(result.cancellation_requested);
ASSERT_TRUE(result.tree_quiesced);
PASS();
#endif
}
TEST(subprocess_natural_completion_is_cached_across_polls) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX /bin/sh completion-cache probe; native Job Object coverage pending");
#else
const char *argv[] = {"/bin/sh", "-c", "exit 7", NULL};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
opts.cancel_grace_ms = 100;
cbm_subprocess_t *process = NULL;
ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0);
ASSERT_NOT_NULL(process);
cbm_proc_result_t first;
ASSERT_TRUE(poll_until_terminal(process, 2000, &first));
cbm_proc_result_t second;
cbm_proc_result_t third;
cbm_proc_poll_t second_poll = cbm_subprocess_poll(process, &second);
cbm_proc_poll_t third_poll = cbm_subprocess_poll(process, &third);
cbm_subprocess_destroy(process);
ASSERT_EQ(second_poll, CBM_PROC_POLL_TERMINAL);
ASSERT_EQ(third_poll, CBM_PROC_POLL_TERMINAL);
ASSERT_EQ(first.outcome, CBM_PROC_EXIT_NONZERO);
ASSERT_EQ(first.exit_code, 7);
ASSERT_EQ(second.outcome, first.outcome);
ASSERT_EQ(second.exit_code, first.exit_code);
ASSERT_EQ(second.term_signal, first.term_signal);
ASSERT_EQ(second.cancellation_requested, first.cancellation_requested);
ASSERT_EQ(second.forced, first.forced);
ASSERT_EQ(second.tree_quiesced, first.tree_quiesced);
ASSERT_EQ(third.outcome, first.outcome);
ASSERT_EQ(third.exit_code, first.exit_code);
ASSERT_EQ(third.term_signal, first.term_signal);
ASSERT_EQ(third.cancellation_requested, first.cancellation_requested);
ASSERT_EQ(third.forced, first.forced);
ASSERT_EQ(third.tree_quiesced, first.tree_quiesced);
ASSERT_FALSE(first.cancellation_requested);
ASSERT_FALSE(first.forced);
ASSERT_TRUE(first.tree_quiesced);
PASS();
#endif
}
TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process-group probe; native Windows Job Object tree probe pending");
#else
char pid_path[64];
ASSERT_TRUE(make_tree_pid_path(pid_path));
cbm_subprocess_t *process = NULL;
ASSERT_EQ(spawn_ignoring_tree(pid_path, 0, 100, &process), 0);
ASSERT_NOT_NULL(process);
pid_t parent_pid = -1;
pid_t grandchild_pid = -1;
bool ready = wait_for_tree_pids(pid_path, process, &parent_pid, &grandchild_pid, 1000);
bool first_cancel = ready && cbm_subprocess_request_cancel(process);
bool second_cancel = ready && cbm_subprocess_request_cancel(process);
cbm_proc_result_t result;
bool terminal = ready && poll_until_terminal(process, 2500, &result);
bool parent_gone = terminal && wait_pid_gone(parent_pid, 1000);
bool grandchild_gone = terminal && wait_pid_gone(grandchild_pid, 1000);
if (!terminal) {
force_probe_cleanup(parent_pid, grandchild_pid);
cbm_proc_result_t cleanup_result;
if (poll_until_terminal(process, 1000, &cleanup_result)) {
cbm_subprocess_destroy(process);
}
} else {
cbm_subprocess_destroy(process);
}
(void)unlink(pid_path);
ASSERT_TRUE(ready);
ASSERT_TRUE(first_cancel);
ASSERT_TRUE(second_cancel);
ASSERT_TRUE(terminal);
ASSERT_EQ(result.outcome, CBM_PROC_KILLED);
ASSERT_TRUE(result.cancellation_requested);
ASSERT_TRUE(result.forced);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_TRUE(parent_gone);
ASSERT_TRUE(grandchild_gone);
PASS();
#endif
}
TEST(subprocess_quiet_timeout_kills_ignoring_tree) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process-group probe; native Windows Job Object tree probe pending");
#else
char pid_path[64];
ASSERT_TRUE(make_tree_pid_path(pid_path));
cbm_subprocess_t *process = NULL;
ASSERT_EQ(spawn_ignoring_tree(pid_path, 750, 100, &process), 0);
ASSERT_NOT_NULL(process);
pid_t parent_pid = -1;
pid_t grandchild_pid = -1;
bool ready = wait_for_tree_pids(pid_path, process, &parent_pid, &grandchild_pid, 500);
cbm_proc_result_t result;
bool terminal = ready && poll_until_terminal(process, 3000, &result);
bool parent_gone = terminal && wait_pid_gone(parent_pid, 1000);
bool grandchild_gone = terminal && wait_pid_gone(grandchild_pid, 1000);
if (!terminal) {
force_probe_cleanup(parent_pid, grandchild_pid);
cbm_proc_result_t cleanup_result;
if (poll_until_terminal(process, 1000, &cleanup_result)) {
cbm_subprocess_destroy(process);
}
} else {
cbm_subprocess_destroy(process);
}
(void)unlink(pid_path);
ASSERT_TRUE(ready);
ASSERT_TRUE(terminal);
ASSERT_EQ(result.outcome, CBM_PROC_HANG);
ASSERT_FALSE(result.cancellation_requested);
ASSERT_TRUE(result.forced);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_TRUE(parent_gone);
ASSERT_TRUE(grandchild_gone);
PASS();
#endif
}
TEST(subprocess_windows_job_object_cancellation_quiesces_descendant_tree) {
#ifndef _WIN32
SKIP_PLATFORM("native Windows Job Object descendant-tree probe");
#else
char temp_dir[MAX_PATH];
DWORD temp_length = GetTempPathA((DWORD)sizeof(temp_dir), temp_dir);
ASSERT_TRUE(temp_length > 0 && temp_length < sizeof(temp_dir));
char pid_path[MAX_PATH];
ASSERT_TRUE(GetTempFileNameA(temp_dir, "cbm", 0, pid_path) != 0);
ASSERT_TRUE(DeleteFileA(pid_path));
char system_directory[MAX_PATH];
UINT system_length = GetSystemDirectoryA(system_directory, (UINT)sizeof(system_directory));
ASSERT_TRUE(system_length > 0 && system_length < sizeof(system_directory));
char powershell_path[MAX_PATH];
ASSERT_TRUE(snprintf(powershell_path, sizeof(powershell_path),
"%s\\WindowsPowerShell\\v1.0\\powershell.exe", system_directory) > 0);
char script[4096];
int script_length = snprintf(
script, sizeof(script),
"$child=Start-Process powershell.exe "
"-ArgumentList '-NoProfile','-Command','while ($true) { Start-Sleep -Milliseconds 100 }' "
"-WindowStyle Hidden -PassThru; Set-Content -Encoding ASCII -LiteralPath '%s' "
"-Value ($PID.ToString() + ' ' + $child.Id.ToString()); "
"while ($true) { Start-Sleep -Milliseconds 100 }",
pid_path);
ASSERT_TRUE(script_length > 0 && (size_t)script_length < sizeof(script));
const char *argv[] = {powershell_path, "-NoProfile", "-Command", script, NULL};
cbm_proc_opts_t opts = {0};
opts.bin = powershell_path;
opts.argv = argv;
opts.cancel_grace_ms = 100;
cbm_subprocess_t *process = NULL;
ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0);
ASSERT_NOT_NULL(process);
DWORD root_pid = 0;
DWORD grandchild_pid = 0;
uint64_t ready_deadline = cbm_now_ms() + 3000U;
bool ready = false;
while (cbm_now_ms() < ready_deadline) {
FILE *pid_file = fopen(pid_path, "r");
if (pid_file) {
unsigned long root_value = 0;
unsigned long grandchild_value = 0;
int fields = fscanf(pid_file, "%lu %lu", &root_value, &grandchild_value);
(void)fclose(pid_file);
if (fields == 2 && root_value > 0 && grandchild_value > 0) {
root_pid = (DWORD)root_value;
grandchild_pid = (DWORD)grandchild_value;
ready = true;
break;
}
}
cbm_proc_result_t ignored;
if (cbm_subprocess_poll(process, &ignored) != CBM_PROC_POLL_RUNNING) {
break;
}
Sleep(10);
}
HANDLE root_handle =
ready ? OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, root_pid) : NULL;
HANDLE grandchild_handle =
ready ? OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, grandchild_pid) : NULL;
/* Request cancellation even if the PID probe failed so a failing test never
* leaves its supervised tree running in the shared Windows VM. */
bool cancelled = cbm_subprocess_request_cancel(process);
cbm_proc_result_t result = {0};
bool terminal = false;
uint64_t terminal_deadline = cbm_now_ms() + 4000U;
while (cbm_now_ms() < terminal_deadline) {
cbm_proc_poll_t state = cbm_subprocess_poll(process, &result);
if (state == CBM_PROC_POLL_TERMINAL) {
terminal = true;
break;
}
if (state == CBM_PROC_POLL_ERROR) {
break;
}
Sleep(10);
}
/* If the behavior under test regresses, terminate both known Job members
* directly and give the supervisor one bounded drain interval. The test
* still fails on the original `terminal` verdict, but it cannot strand its
* infinite root/grandchild or retain a terminal subprocess/Job handle. */
bool cleanup_terminal = terminal;
if (!cleanup_terminal) {
if (grandchild_handle) {
(void)TerminateProcess(grandchild_handle, 1);
}
if (root_handle) {
(void)TerminateProcess(root_handle, 1);
}
(void)cbm_subprocess_request_cancel(process);
uint64_t cleanup_deadline = cbm_now_ms() + 2000U;
while (cbm_now_ms() < cleanup_deadline) {
cbm_proc_result_t cleanup_result;
cbm_proc_poll_t state = cbm_subprocess_poll(process, &cleanup_result);
if (state == CBM_PROC_POLL_TERMINAL) {
cleanup_terminal = true;
break;
}
if (state == CBM_PROC_POLL_ERROR) {
break;
}
Sleep(10);
}
}
bool root_gone = root_handle && WaitForSingleObject(root_handle, 2000) == WAIT_OBJECT_0;
bool grandchild_gone =
grandchild_handle && WaitForSingleObject(grandchild_handle, 2000) == WAIT_OBJECT_0;
if (root_handle) {
CloseHandle(root_handle);
}
if (grandchild_handle) {
CloseHandle(grandchild_handle);
}
if (cleanup_terminal) {
cbm_subprocess_destroy(process);
}
(void)DeleteFileA(pid_path);
ASSERT_TRUE(ready);
ASSERT_TRUE(cancelled);
ASSERT_TRUE(terminal);
ASSERT_TRUE(result.cancellation_requested);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_FALSE(result.supervision_failed);
ASSERT_TRUE(root_gone);
ASSERT_TRUE(grandchild_gone);
PASS();
#endif
}
TEST(subprocess_windows_job_object_enforces_memory_limit) {
#ifndef _WIN32
SKIP_PLATFORM("native Windows Job Object memory-limit probe");
#else
char *self_path = cbm_module_path_utf8();
ASSERT_TRUE(self_path != NULL);
const char *argv[] = {self_path, "__cbm_windows_memory_limit_probe", NULL};
cbm_proc_opts_t opts = {0};
opts.bin = self_path;
opts.argv = argv;
opts.quiet_timeout_ms = 5000;
/* Without the cap the SAME allocation must succeed. A machine-wide commit
* shortage must fail this test, not masquerade as Job Object enforcement. */
cbm_proc_result_t uncapped = {0};
int uncapped_rc = cbm_subprocess_run(&opts, &uncapped);
opts.memory_limit_bytes = (size_t)1024U * 1024U * 1024U;
cbm_proc_result_t result = {0};
int run_rc = cbm_subprocess_run(&opts, &result);
free(self_path);
ASSERT_EQ(uncapped_rc, 0);
ASSERT_EQ(uncapped.outcome, CBM_PROC_CLEAN);
ASSERT_EQ(uncapped.exit_code, 0);
ASSERT_TRUE(uncapped.tree_quiesced);
ASSERT_FALSE(uncapped.supervision_failed);
ASSERT_TRUE(uncapped.job_memory_limit_bytes == 0);
ASSERT_EQ(run_rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_EXIT_NONZERO);
ASSERT_EQ(result.exit_code, 73);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_FALSE(result.supervision_failed);
ASSERT_TRUE(result.job_memory_available);
ASSERT_TRUE(result.job_memory_limit_bytes == opts.memory_limit_bytes);
ASSERT_TRUE(result.peak_job_memory_bytes > 0);
/* Windows may include the denied reservation in its peak counter, so peak
* can exceed the cap. The uncapped/capped exit codes prove enforcement. */
PASS();
#endif
}
TEST(subprocess_cancel_grace_is_hard_capped) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process-group grace cap probe; native Windows coverage pending");
#else
char pid_path[64];
ASSERT_TRUE(make_tree_pid_path(pid_path));
cbm_subprocess_t *process = NULL;
ASSERT_EQ(spawn_ignoring_tree(pid_path, 0, INT_MAX, &process), 0);
ASSERT_NOT_NULL(process);
pid_t parent_pid = -1;
pid_t grandchild_pid = -1;
bool ready = wait_for_tree_pids(pid_path, process, &parent_pid, &grandchild_pid, 1000);
bool cancel_accepted = ready && cbm_subprocess_request_cancel(process);
cbm_proc_result_t result = {0};
bool terminal = cancel_accepted && poll_until_terminal(process, 3500, &result);
if (!terminal) {
force_probe_cleanup(parent_pid, grandchild_pid);
cbm_proc_result_t cleanup_result;
if (poll_until_terminal(process, 1000, &cleanup_result)) {
cbm_subprocess_destroy(process);
}
} else {
cbm_subprocess_destroy(process);
}
(void)unlink(pid_path);
ASSERT_TRUE(ready);
ASSERT_TRUE(cancel_accepted);
ASSERT_TRUE(terminal);
ASSERT_TRUE(result.forced);
ASSERT_TRUE(result.tree_quiesced);
PASS();
#endif
}
TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX shell log budget probe; native Windows coverage pending");
#else
char log_path[] = "/tmp/cbm-subprocess-log-budget-XXXXXX";
int log_fd = cbm_mkstemp(log_path);
ASSERT_TRUE(log_fd >= 0);
(void)close(log_fd);
const char *script =
"i=0; while [ $i -lt 399 ]; do echo line-$i; i=$((i+1)); done; printf line-399";
const char *argv[] = {"/bin/sh", "-c", script, NULL};
subprocess_log_capture_t capture = {.ordered = true};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
opts.log_file = log_path;
opts.on_log_line = ordered_log_callback;
opts.log_ud = &capture;
opts.cancel_grace_ms = 100;
opts.delete_log_on_exit = true;
cbm_subprocess_t *process = NULL;
ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0);
ASSERT_NOT_NULL(process);
capture.process = process;
bool backlog_ready = wait_for_log_marker(log_path, "line-399", cbm_now_ms() + 2000U);
cbm_proc_result_t result = {0};
bool terminal = false;
int max_poll_delivery = 0;
uint64_t deadline = cbm_now_ms() + 5000U;
while (backlog_ready && cbm_now_ms() < deadline) {
int before = capture.count;
cbm_proc_poll_t state = cbm_subprocess_poll(process, &result);
int delivered = capture.count - before;
if (delivered > max_poll_delivery) {
max_poll_delivery = delivered;
}
if (state == CBM_PROC_POLL_TERMINAL) {
terminal = true;
break;
}
if (state == CBM_PROC_POLL_ERROR) {
break;
}
subprocess_test_pause();
}
bool log_deleted = access(log_path, F_OK) != 0 && errno == ENOENT;
if (terminal) {
cbm_subprocess_destroy(process);
} else {
(void)cbm_subprocess_request_cancel(process);
cbm_proc_result_t cleanup_result;
if (poll_until_terminal(process, 1000, &cleanup_result)) {
cbm_subprocess_destroy(process);
}
}
(void)unlink(log_path);
ASSERT_TRUE(backlog_ready);
ASSERT_TRUE(terminal);
ASSERT_EQ(max_poll_delivery, 64);
ASSERT_EQ(capture.count, 400);
ASSERT_TRUE(capture.ordered);
ASSERT_TRUE(capture.late_cancel_attempted);
ASSERT_FALSE(capture.late_cancel_accepted);
ASSERT_FALSE(result.cancellation_requested);
ASSERT_TRUE(log_deleted);
PASS();
#endif
}
TEST(subprocess_final_log_drain_error_is_terminal_and_preserves_classification) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX log-rename final-drain probe; UTF-8 Windows open uses cbm_fopen");
#else
char log_path[] = "/tmp/cbm-subprocess-log-drain-XXXXXX";
int log_fd = cbm_mkstemp(log_path);
ASSERT_TRUE(log_fd >= 0);
(void)close(log_fd);
char saved_path[sizeof(log_path) + 16];
int saved_written = snprintf(saved_path, sizeof(saved_path), "%s.saved", log_path);
ASSERT_TRUE(saved_written > 0 && (size_t)saved_written < sizeof(saved_path));
const char *script = "i=0; while [ $i -lt 130 ]; do echo line-$i; i=$((i+1)); done";
const char *argv[] = {"/bin/sh", "-c", script, NULL};
subprocess_log_capture_t capture = {.ordered = true};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
opts.log_file = log_path;
opts.on_log_line = ordered_log_callback;
opts.log_ud = &capture;
opts.cancel_grace_ms = 100;
opts.delete_log_on_exit = true;
cbm_subprocess_t *process = NULL;
ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0);
ASSERT_NOT_NULL(process);
capture.process = process;
bool backlog_ready = wait_for_log_marker(log_path, "line-129", cbm_now_ms() + 2000U);
cbm_proc_result_t result = {0};
cbm_proc_poll_t first =
backlog_ready ? cbm_subprocess_poll(process, &result) : CBM_PROC_POLL_ERROR;
int first_delivery = capture.count;
bool moved = first == CBM_PROC_POLL_RUNNING && rename(log_path, saved_path) == 0;
bool terminal = moved && poll_until_terminal(process, 2000, &result);
int callbacks_at_terminal = capture.count;
cbm_proc_result_t cached = {0};
cbm_proc_poll_t cached_state =
terminal ? cbm_subprocess_poll(process, &cached) : CBM_PROC_POLL_ERROR;
bool callbacks_stable = capture.count == callbacks_at_terminal;
bool saved_preserved = access(saved_path, F_OK) == 0;
bool original_absent = access(log_path, F_OK) != 0 && errno == ENOENT;
if (terminal) {
cbm_subprocess_destroy(process);
} else {
(void)cbm_subprocess_request_cancel(process);
cbm_proc_result_t cleanup_result;
if (poll_until_terminal(process, 1000, &cleanup_result)) {
cbm_subprocess_destroy(process);
}
}
(void)unlink(log_path);
(void)unlink(saved_path);
ASSERT_TRUE(backlog_ready);
ASSERT_EQ(first, CBM_PROC_POLL_RUNNING);
ASSERT_EQ(first_delivery, 64);
ASSERT_TRUE(moved);
ASSERT_TRUE(terminal);
ASSERT_TRUE(capture.ordered);
ASSERT_EQ(callbacks_at_terminal, 64);
ASSERT_EQ(cached_state, CBM_PROC_POLL_TERMINAL);
ASSERT_TRUE(callbacks_stable);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_EQ(result.exit_code, 0);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_FALSE(result.supervision_failed);
ASSERT_EQ(cached.outcome, result.outcome);
ASSERT_EQ(cached.exit_code, result.exit_code);
ASSERT_EQ(cached.tree_quiesced, result.tree_quiesced);
ASSERT_TRUE(saved_preserved);
ASSERT_TRUE(original_absent);
PASS();
#endif
}
TEST(subprocess_posix_child_closes_unrelated_descriptors) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX descriptor-inheritance probe; Windows uses a handle allow-list");
#else
char sentinel_path[] = "/tmp/cbm-subprocess-sentinel-XXXXXX";
int sentinel = cbm_mkstemp(sentinel_path);
ASSERT_TRUE(sentinel > STDERR_FILENO);
int flags = fcntl(sentinel, F_GETFD);
ASSERT_TRUE(flags >= 0);
ASSERT_EQ(fcntl(sentinel, F_SETFD, flags & ~FD_CLOEXEC), 0);
char fd_text[32];
snprintf(fd_text, sizeof(fd_text), "%d", sentinel);
const char *script = "if [ -e /dev/fd/$1 ]; then exit 42; else exit 0; fi";
const char *argv[] = {"/bin/sh", "-c", script, "cbm-fd-probe", fd_text, NULL};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
cbm_proc_result_t result;
int run_rc = cbm_subprocess_run(&opts, &result);
(void)close(sentinel);
(void)unlink(sentinel_path);
ASSERT_EQ(run_rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_EQ(result.exit_code, 0);
PASS();
#endif
}
/* #1484: the child's close-inherited-descriptors step must not cost
* O(RLIMIT_NOFILE) syscalls. The seam runs the exact child routine in this
* process against two descriptors parked at the TOP of the descriptor table
* (nothing else lives there), and reports which strategy did the work. On
* Linux that must be close_range(2) whenever the kernel has it -- the per-fd
* loop is the bug. The ENOSYS-injected leg proves the fallback still closes. */
#ifndef _WIN32
static bool subprocess_fd_is_closed(int fd) {
return fcntl(fd, F_GETFD) == -1 && errno == EBADF;
}
static cbm_fd_close_strategy_t subprocess_expected_close_strategy(void) {
#if defined(__linux__) && defined(SYS_close_range)
/* Probe the kernel on an empty range: an old kernel (< 5.9) or a seccomp
* filter without it answers ENOSYS/EPERM, and then the loop is correct. */
long probe = syscall(SYS_close_range, ~0U, ~0U, 0U);
return (probe == 0 || errno == EINVAL) ? CBM_FD_CLOSE_RANGE : CBM_FD_CLOSE_LOOP;
#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__)
return CBM_FD_CLOSEFROM;
#else
return CBM_FD_CLOSE_LOOP;
#endif
}
/* Park two non-CLOEXEC descriptors near the top of the table; returns the lower
* of the two and hands back the original (low) descriptor in *keep_low. The
* kernel may cap the table below _SC_OPEN_MAX (macOS: kern.maxfilesperproc),
* so the start point halves until F_DUPFD accepts it. */
static int subprocess_park_high_fds(int *keep_low) {
char path[] = "/tmp/cbm-subprocess-highfd-XXXXXX";
int low = cbm_mkstemp(path);
if (low < 0) {
return -1;
}
(void)unlink(path);
long top = sysconf(_SC_OPEN_MAX);
if (top <= 0 || top > 1048576L) {
top = 1048576L;
}
int high = -1;
for (long base = top - 4; high < 0 && base > 64; base /= 2) {
high = fcntl(low, F_DUPFD, (int)base);
}
int higher = high >= 0 ? fcntl(low, F_DUPFD, high + 1) : -1;
if (higher != high + 1) {
(void)close(low);
if (high >= 0) {
(void)close(high);
}
if (higher >= 0) {
(void)close(higher);
}
return -1;
}
*keep_low = low;
return high;
}
#endif
TEST(subprocess_child_close_fds_uses_one_syscall_not_rlimit_loop) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX fork+exec descriptor closing; Windows uses a handle allow-list");
#else
for (int inject_enosys = 0; inject_enosys <= 1; inject_enosys++) {
int keep_low = -1;
int high = subprocess_park_high_fds(&keep_low);
ASSERT_TRUE(high > STDERR_FILENO);
cbm_subprocess_force_close_range_enosys_for_testing(inject_enosys != 0);
/* The loop bound covers exactly the parked pair; close_range has none. */
cbm_fd_close_strategy_t used =
cbm_subprocess_close_fds_from_for_testing(high, (long)high + 2);
cbm_subprocess_force_close_range_enosys_for_testing(false);
bool closed = subprocess_fd_is_closed(high) && subprocess_fd_is_closed(high + 1);
bool low_survived = fcntl(keep_low, F_GETFD) >= 0;
(void)close(keep_low);
ASSERT_TRUE(closed);
ASSERT_TRUE(low_survived); /* only descriptors >= lowfd are touched */
cbm_fd_close_strategy_t expected = subprocess_expected_close_strategy();
if (inject_enosys && expected == CBM_FD_CLOSE_RANGE) {
expected = CBM_FD_CLOSE_LOOP; /* kernel "lacks" it: the fallback must run */
}
ASSERT_EQ((int)used, (int)expected);
}
PASS();
#endif
}
TEST(subprocess_root_exit_drains_surviving_descendant) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process-group descendant probe; native Windows coverage pending");
#else
char pid_path[64];
ASSERT_TRUE(make_tree_pid_path(pid_path));
const char *script = "sleep 30 & child=$!; echo $child > \"$1\"; exit 0";
const char *argv[] = {"/bin/sh", "-c", script, "cbm-root-exit", pid_path, NULL};
cbm_proc_opts_t opts = {0};
opts.bin = "/bin/sh";
opts.argv = argv;
opts.cancel_grace_ms = 100;
cbm_subprocess_t *process = NULL;
ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0);
ASSERT_NOT_NULL(process);
pid_t descendant = -1;
uint64_t deadline = cbm_now_ms() + 1000;
while (descendant <= 1 && cbm_now_ms() < deadline) {
FILE *file = fopen(pid_path, "r");
long value = -1;
if (file) {
if (fscanf(file, "%ld", &value) == 1) {
descendant = (pid_t)value;
}
(void)fclose(file);
}
subprocess_test_pause();
}
cbm_proc_result_t result = {0};
bool terminal = descendant > 1 && poll_until_terminal(process, 2500, &result);
bool descendant_gone = terminal && wait_pid_gone(descendant, 1000);
if (!terminal) {
force_probe_cleanup(-1, descendant);
cbm_proc_result_t cleanup_result;
if (poll_until_terminal(process, 1000, &cleanup_result)) {
cbm_subprocess_destroy(process);
}
} else {
cbm_subprocess_destroy(process);
}
(void)unlink(pid_path);
ASSERT_TRUE(descendant > 1);
ASSERT_TRUE(terminal);
ASSERT_TRUE(descendant_gone);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(result.tree_quiesced);
PASS();
#endif
}
/* ── Layer 4: Windows command-line quoting (pure; every platform) ─────────────
*
* The Windows index-worker "crash" was a quoting bug: the Windows spawn wrapped each
* argv element in bare quotes without escaping, so a JSON argument like
* {"repo_path":"C:/r"} lost its inner quotes when the child re-parsed the command
* line — the worker then failed at JSON-arg parse and exited non-zero, which the
* supervisor misreported as a per-file crash. We guard cbm_build_win_cmdline by
* ROUND-TRIP: a reference implementation of the Windows CommandLineToArgvW rules
* (the inverse of the builder) must re-parse the emitted line back into the exact
* original argv. Testing the invariant — not a hand-computed escaped string — keeps
* the guard honest and readable, and runs on Linux/macOS CI (the builder is pure). */
/* Reference re-parser: the subset of CommandLineToArgvW our builder emits (every
* arg quote-wrapped; \" for embedded quotes; backslashes doubled before a quote). */
static int parse_win_cmdline(const char *cmd, char out[][256], int max_args) {
int argc = 0;
const char *p = cmd;
while (*p) {
while (*p == ' ' || *p == '\t') {
p++;
}
if (!*p || argc >= max_args) {
break;
}
char *o = out[argc];
size_t oi = 0;
bool in_quotes = false;
/* Guard every write: each out[] row is 256 bytes; test args stay well under
* that, but cap defensively so a future longer arg fails a length assertion
* rather than smashing the stack. */
#define PUTO(ch) \
do { \
if (oi < 255) { \
o[oi++] = (ch); \
} \
} while (0)
for (;;) {
size_t nbs = 0;
while (*p == '\\') {
nbs++;
p++;
}
if (*p == '"') {
for (size_t k = 0; k < nbs / 2; k++) {
PUTO('\\');
}
if (nbs % 2) {
PUTO('"'); /* odd run → the quote is an escaped literal */
} else {
in_quotes = !in_quotes; /* even run → the quote is a delimiter */
}
p++;
} else {
for (size_t k = 0; k < nbs; k++) {
PUTO('\\');
}
if (*p == '\0' || (!in_quotes && (*p == ' ' || *p == '\t'))) {
break;
}
PUTO(*p);
p++;
}
}
#undef PUTO
o[oi] = '\0';
argc++;
}
return argc;
}
static bool cmdline_roundtrips(const char *const *argv) {
char cmd[4096];
if (!cbm_build_win_cmdline(cmd, sizeof(cmd), argv)) {
return false;
}
char parsed[16][256];
int pc = parse_win_cmdline(cmd, parsed, 16);
int oc = 0;
while (argv[oc]) {
oc++;
}
if (pc != oc) {
return false;
}
for (int i = 0; i < oc; i++) {
if (strcmp(argv[i], parsed[i]) != 0) {
return false;
}
}
return true;
}
/* The exact index-worker argv: the command line with a JSON arg full of quotes.
* Round-trips, AND the emitted line must contain an ESCAPED quote (\") — the bare
* `"%s"` wrap that caused the bug never would. */
TEST(win_cmdline_index_worker_json) {
const char *const argv[] = {
"C:/bin/cbm.exe",
"cli",
"--index-worker",
"--index-worker-build",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"index_repository",
"{\"repo_path\":\"C:/r\"}",
"--response-out",
"C:/c/w.response",
NULL,
};
ASSERT(cmdline_roundtrips(argv));
char cmd[4096];
ASSERT(cbm_build_win_cmdline(cmd, sizeof(cmd), argv));
ASSERT(strstr(cmd, "\\\"repo_path\\\"") != NULL); /* inner quotes are escaped */
PASS();
}
/* A battery of adversarial argv (spaces, tabs, embedded quotes, backslash runs,
* trailing backslashes, backslash-before-quote, real Windows paths) must all
* round-trip byte-for-byte through the builder + reference parser. */
TEST(win_cmdline_roundtrip_battery) {
const char *const a1[] = {"a", "b", NULL};
const char *const a2[] = {"has space", "tab\there", NULL};
const char *const a3[] = {"trailing\\", "a\\b\\c", NULL};
const char *const a4[] = {"a\\\"b", "\"", "\\\\\"", NULL};
const char *const a5[] = {"C:\\Users\\me\\my repo", "{\"name\":\"a b\",\"x\":\"y\\\\z\"}",
NULL};
const char *const a6[] = {"", "plain", NULL};
ASSERT(cmdline_roundtrips(a1));
ASSERT(cmdline_roundtrips(a2));
ASSERT(cmdline_roundtrips(a3));
ASSERT(cmdline_roundtrips(a4));
ASSERT(cmdline_roundtrips(a5));
ASSERT(cmdline_roundtrips(a6));
PASS();
}
/* Overflow is reported (false), never a silent truncation that would spawn a
* corrupted command line. */
TEST(win_cmdline_overflow_rejected) {
const char *const argv[] = {"aaaaaaaaaa", "bbbbbbbbbb", NULL};
char tiny[8];
ASSERT_FALSE(cbm_build_win_cmdline(tiny, sizeof(tiny), argv));
PASS();
}
/* Reproduce-first guard for the overflow CONTRACT (subprocess.h): on overflow buf
* must be left a valid (empty) string. RED on the pre-fix code — the overflow path
* returned without terminating buf, so buf[0] held the first quoted byte ('"') —
* and GREEN once the overflow path sets buf[0] = '\0'. */
TEST(win_cmdline_overflow_leaves_empty_string) {
char buf[8];
const char *const argv[] = {"averylongprogramname", "x", NULL};
ASSERT_FALSE(cbm_build_win_cmdline(buf, sizeof(buf), argv)); /* overflows cap */
ASSERT_EQ(buf[0], '\0'); /* pre-fix: buf[0] == '"' (a partial byte), not NUL */
PASS();
}
/* cmd.exe /C receives command-language text, not another CRT argv element.
* Preserve its quoted Windows paths byte-for-byte: generic argv quoting would
* turn the payload's quotes into backslash-quote sequences before cmd sees it. */
TEST(win_cmd_payload_is_verbatim_and_capacity_checked) {
const char *cmd = "C:\\Windows\\System32\\cmd.exe";
const char *payload =
"git -C \"C:\\Users\\test\\source repo\" diff --name-only \"main\"...HEAD 2>NUL";
const char *expected =
"\"C:\\Windows\\System32\\cmd.exe\" /D /S /V:OFF /C "
"git -C \"C:\\Users\\test\\source repo\" diff --name-only \"main\"...HEAD 2>NUL";
char result[512];
ASSERT(cbm_build_win_cmd_payload(result, strlen(expected) + 1, cmd, payload));
ASSERT_STR_EQ(result, expected);
ASSERT_NULL(strstr(result, "\\\"C:\\Users"));
memset(result, 'x', sizeof(result));
ASSERT_FALSE(cbm_build_win_cmd_payload(result, strlen(expected), cmd, payload));
ASSERT_EQ(result[0], '\0');
PASS();
}
/* Input validation must inspect no bytes beyond short strings and must never
* permit PATH lookup or a different executable behind the raw-payload mode. */
TEST(win_cmd_payload_rejects_short_relative_and_non_cmd_paths) {
const char *payload = "echo ok";
char result[256];
ASSERT_FALSE(cbm_build_win_cmd_payload(result, sizeof(result), "", payload));
ASSERT_FALSE(cbm_build_win_cmd_payload(result, sizeof(result), "C", payload));
ASSERT_FALSE(cbm_build_win_cmd_payload(result, sizeof(result), "C:", payload));
ASSERT_FALSE(cbm_build_win_cmd_payload(result, sizeof(result), "cmd.exe", payload));
ASSERT_FALSE(
cbm_build_win_cmd_payload(result, sizeof(result), "C:\\tools\\other.exe", payload));
ASSERT_FALSE(
cbm_build_win_cmd_payload(result, sizeof(result), "C:\\tools\\cmd.exe\\child", payload));
ASSERT(
cbm_build_win_cmd_payload(result, sizeof(result), "c:/Windows/System32/CMD.EXE", payload));
PASS();
}
/* ── Git child environment (#2003) ────────────────────────────────────────────
* strip_git_repo_env / cbm_popen_git drop every variable of
* `git rev-parse --local-env-vars` from the CHILD only: the parent keeps its
* environment, other variables pass through, and without the flag a child
* still inherits everything. The test sets the variables itself (a runner may
* clear them at startup) and restores them before asserting. */
#ifndef _WIN32
typedef struct {
char *saved[CBM_GIT_REPO_ENV_VAR_COUNT];
bool present[CBM_GIT_REPO_ENV_VAR_COUNT];
} gitenv_snapshot_t;
static void gitenv_enter(gitenv_snapshot_t *snap) {
for (int i = 0; i < CBM_GIT_REPO_ENV_VAR_COUNT; i++) {
const char *v = getenv(cbm_git_repo_env_vars[i]);
snap->present[i] = v != NULL;
snap->saved[i] = v ? strdup(v) : NULL;
setenv(cbm_git_repo_env_vars[i], "/cbm-decoy-repo", 1);
}
setenv("CBM_GITENV_KEEP", "kept", 1);
}
static void gitenv_leave(gitenv_snapshot_t *snap) {
for (int i = 0; i < CBM_GIT_REPO_ENV_VAR_COUNT; i++) {
if (snap->present[i]) {
setenv(cbm_git_repo_env_vars[i], snap->saved[i], 1);
} else {
unsetenv(cbm_git_repo_env_vars[i]);
}
free(snap->saved[i]);
}
unsetenv("CBM_GITENV_KEEP");
}
/* exit 0: no git repo-local var is set and CBM_GITENV_KEEP=kept; exit 3: a git
* var leaked; exit 4: the unrelated variable was lost. */
static void gitenv_probe_script(char *buf, size_t cap) {
size_t n = (size_t)snprintf(buf, cap, "[ \"$CBM_GITENV_KEEP\" = kept ] || exit 4;");
for (int i = 0; i < CBM_GIT_REPO_ENV_VAR_COUNT && n < cap; i++) {
n += (size_t)snprintf(buf + n, cap - n, " [ -z \"${%s+x}\" ] || exit 3;",
cbm_git_repo_env_vars[i]);
}
if (n < cap) {
(void)snprintf(buf + n, cap - n, " exit 0");
}
}
static int gitenv_spawn_probe(bool strip) {
char script[2048];
gitenv_probe_script(script, sizeof(script));
const char *argv[] = {"/bin/sh", "-c", script, NULL};
cbm_proc_opts_t opts = {.bin = "/bin/sh", .argv = argv, .strip_git_repo_env = strip};
cbm_proc_result_t r;
if (cbm_subprocess_run(&opts, &r) != 0) {
return -1;
}
return r.exit_code;
}
#endif
TEST(subprocess_strip_git_repo_env_is_per_child) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX /bin/sh spawn");
#else
gitenv_snapshot_t snap;
gitenv_enter(&snap);
int stripped = gitenv_spawn_probe(true);
int inherited = gitenv_spawn_probe(false);
const char *parent_git_dir = getenv("GIT_DIR");
bool parent_kept = parent_git_dir && strcmp(parent_git_dir, "/cbm-decoy-repo") == 0;
gitenv_leave(&snap);
ASSERT_EQ(stripped, 0); /* git vars gone, CBM_GITENV_KEEP passed through */
ASSERT_EQ(inherited, 3); /* without the flag the child inherits as before */
ASSERT_TRUE(parent_kept); /* the parent environment is never modified */
PASS();
#endif
}
TEST(popen_git_strips_repo_env_and_reports_exit_status) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX /bin/sh spawn");
#else
char script[2048];
gitenv_probe_script(script, sizeof(script));
char cmd[2200];
snprintf(cmd, sizeof(cmd), "echo probe; %s", script);
gitenv_snapshot_t snap;
gitenv_enter(&snap);
FILE *fp = cbm_popen_git(cmd);
char line[64] = {0};
bool got_line = fp && fgets(line, sizeof(line), fp) != NULL;
int status = fp ? cbm_pclose(fp) : -1;
FILE *fail = cbm_popen_git("exit 7");
int fail_status = fail ? cbm_pclose(fail) : -1;
const char *parent_git_dir = getenv("GIT_DIR");
bool parent_kept = parent_git_dir && strcmp(parent_git_dir, "/cbm-decoy-repo") == 0;
gitenv_leave(&snap);
ASSERT_TRUE(got_line);
ASSERT_STR_EQ(line, "probe\n");
ASSERT_TRUE(status >= 0 && WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
ASSERT_TRUE(fail_status >= 0 && WIFEXITED(fail_status));
ASSERT_EQ(WEXITSTATUS(fail_status), 7); /* pclose() semantics: raw wait status */
ASSERT_TRUE(parent_kept);
PASS();
#endif
}
/* Native fixed-byte child and independent stdout-capture contract tests. */
enum { SP_STDOUT_BYTES = 2305, SP_STDOUT_PATH = 1024 };
static const char *sp_stdout_test_binary;
static const char sp_stderr_text[] = "stderr-first\nstderr-tail";
static const char sp_stdout_text[] = "stdout-only\n";
static void sp_stdout_payload(unsigned char bytes[SP_STDOUT_BYTES]) {
for (int i = 0; i < SP_STDOUT_BYTES; i++)
bytes[i] = (unsigned char)('a' + i % 26);
bytes[0] = 'B';
bytes[1] = '\0';
bytes[2] = '\r';
bytes[3] = '\n';
bytes[4] = '\n';
bytes[1022] = 'X';
bytes[1023] = '\r';
bytes[1024] = '\n';
bytes[2048] = '\0';
bytes[SP_STDOUT_BYTES - 1] = 'Z'; /* final byte has no newline */
}
/* Normal entry (test_main.c) remembers the runner image, whose storage lasts
* through main. */
void tf_subprocess_set_binary(const char *path);
void tf_subprocess_set_binary(const char *path) {
sp_stdout_test_binary = path;
}
/* Runner roles (test_main.c's role table), before normal harness setup; the
* private exact argv grammar never runs suites. */
static int sp_stdin_probe(int argc, char **argv);
int tf_role_subprocess_stdin_probe(int argc, char **argv);
int tf_role_subprocess_stdin_probe(int argc, char **argv) {
return sp_stdin_probe(argc, argv);
}
int tf_role_subprocess_stdout_probe(int argc, char **argv);
int tf_role_subprocess_stdout_probe(int argc, char **argv) {
if (argc != 4 || (strcmp(argv[2], "binary") != 0 && strcmp(argv[2], "text") != 0 &&
strcmp(argv[2], "empty") != 0))
return 91;
#ifdef _WIN32
if (_setmode(cbm_fileno(stdout), _O_BINARY) == -1 ||
_setmode(cbm_fileno(stderr), _O_BINARY) == -1)
return 92;
#endif
if (argv[3][0]) {
/* A state barrier, not a transient timing assertion. The deadline only
* bounds cleanup if the parent fails before releasing this child. */
uint64_t deadline = cbm_now_ms() + 30000U;
while (!cbm_file_exists(argv[3])) {
if (cbm_now_ms() >= deadline)
return 93;
cbm_usleep(1000);
}
}
if (strcmp(argv[2], "binary") == 0) {
unsigned char bytes[SP_STDOUT_BYTES];
sp_stdout_payload(bytes);
if (fwrite(bytes, 1, sizeof(bytes), stdout) != sizeof(bytes))
return 94;
} else if (strcmp(argv[2], "text") == 0) {
if (fwrite(sp_stdout_text, 1, sizeof(sp_stdout_text) - 1, stdout) !=
sizeof(sp_stdout_text) - 1)
return 94;
}
if (fflush(stdout) != 0)
return 94;
if (fwrite(sp_stderr_text, 1, sizeof(sp_stderr_text) - 1, stderr) !=
sizeof(sp_stderr_text) - 1 ||
fflush(stderr) != 0)
return 95;
return 0;
}
typedef struct {
char root[SP_STDOUT_PATH];
char out[SP_STDOUT_PATH];
char log[SP_STDOUT_PATH];
char other[SP_STDOUT_PATH];
char gate[SP_STDOUT_PATH];
} sp_stdout_fixture_t;
static bool sp_stdout_path(char *out, const char *root, const char *leaf) {
int n = snprintf(out, SP_STDOUT_PATH, "%s/%s", root, leaf);
return n > 0 && n < SP_STDOUT_PATH;
}
static bool sp_stdout_fixture_open(sp_stdout_fixture_t *fx) {
memset(fx, 0, sizeof(*fx));
if (!sp_stdout_test_binary || !sp_stdout_test_binary[0])
return false;
const char *root = th_mktempdir("cbm-sp-stdout");
if (!root)
return false;
int n = snprintf(fx->root, sizeof(fx->root), "%s", root);
bool ok = n > 0 && (size_t)n < sizeof(fx->root) && sp_stdout_path(fx->out, root, "out.bin") &&
sp_stdout_path(fx->log, root, "err.log") &&
sp_stdout_path(fx->other, root, "other.bin") &&
sp_stdout_path(fx->gate, root, "release");
if (!ok)
(void)th_rmtree(root);
return ok;
}
static bool sp_stdout_file_equals(const char *path, const void *bytes, size_t count) {
FILE *file = cbm_fopen(path, "rb");
if (!file)
return false;
const unsigned char *expected = bytes;
bool equal = true;
for (size_t i = 0; i < count; i++) {
int byte = fgetc(file);
if (byte == EOF || (unsigned char)byte != expected[i]) {
equal = false;
break;
}
}
if (equal)
equal = fgetc(file) == EOF && !ferror(file);
if (fclose(file) != 0)
equal = false;
return equal;
}
typedef struct {
char lines[4][64];
int count;
bool overflow;
} sp_stdout_logs_t;
static void sp_stdout_log(const char *line, void *opaque) {
sp_stdout_logs_t *log = opaque;
if (log->count >= 4 || strlen(line) >= sizeof(log->lines[0])) {
log->overflow = true;
} else {
memcpy(log->lines[log->count], line, strlen(line) + 1);
}
log->count++;
}
static bool sp_stdout_stderr_only(const sp_stdout_logs_t *log) {
return !log->overflow && log->count == 2 && strcmp(log->lines[0], "stderr-first") == 0 &&
strcmp(log->lines[1], "stderr-tail") == 0;
}
static int sp_stdout_run(const char *mode, cbm_proc_opts_t *opts, cbm_proc_result_t *result) {
const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdout_probe", mode, "", NULL};
opts->bin = sp_stdout_test_binary;
opts->argv = argv;
return cbm_subprocess_run(opts, result);
}
static bool sp_stdout_poll_terminal(cbm_subprocess_t *process, cbm_proc_result_t *result,
uint64_t deadline) {
do {
cbm_proc_poll_t state = cbm_subprocess_poll(process, result);
if (state == CBM_PROC_POLL_TERMINAL)
return true;
if (state == CBM_PROC_POLL_ERROR)
return false;
cbm_usleep(1000);
} while (cbm_now_ms() < deadline);
return false;
}
/* The finite child normally exits immediately. On a RED path, cancel and drain
* before destroying; never pass a live handle to destroy. */
static bool sp_stdout_reap(cbm_subprocess_t *process, cbm_proc_result_t *result) {
bool terminal = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 10000U);
bool cleaned = terminal;
if (!cleaned) {
(void)cbm_subprocess_request_cancel(process);
cleaned = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 4000U);
}
if (cleaned)
cbm_subprocess_destroy(process);
return terminal;
}
TEST(subprocess_stdout_null_preserves_merged_log) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {
.log_file = fx.log, .stdout_file = NULL, .on_log_line = sp_stdout_log, .log_ud = &logs};
cbm_proc_result_t result = {0};
int rc = sp_stdout_run("text", &opts, &result);
const char expected[] = "stdout-only\nstderr-first\nstderr-tail";
bool exact = sp_stdout_file_equals(fx.log, expected, sizeof(expected) - 1);
bool callbacks =
!logs.overflow && logs.count == 3 && strcmp(logs.lines[0], "stdout-only") == 0 &&
strcmp(logs.lines[1], "stderr-first") == 0 && strcmp(logs.lines[2], "stderr-tail") == 0;
int cleanup = th_rmtree(fx.root);
ASSERT_EQ(cleanup, 0);
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_TRUE(exact);
ASSERT_TRUE(callbacks);
PASS();
}
TEST(subprocess_stdout_capture_exact_binary_and_stderr_isolation) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {
.stdout_file = fx.out, .log_file = fx.log, .on_log_line = sp_stdout_log, .log_ud = &logs};
cbm_proc_result_t result = {0};
int rc = sp_stdout_run("binary", &opts, &result);
unsigned char expected[SP_STDOUT_BYTES];
sp_stdout_payload(expected);
bool exact = sp_stdout_file_equals(fx.out, expected, sizeof(expected));
bool stderr_exact = sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1);
bool callbacks = sp_stdout_stderr_only(&logs);
int cleanup = th_rmtree(fx.root);
ASSERT_EQ(cleanup, 0);
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_TRUE(exact);
ASSERT_TRUE(stderr_exact);
ASSERT_TRUE(callbacks);
PASS();
}
TEST(subprocess_stdout_capture_without_log_discards_only_stderr) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {
.stdout_file = fx.out, .log_file = NULL, .on_log_line = sp_stdout_log, .log_ud = &logs};
cbm_proc_result_t result = {0};
int rc = sp_stdout_run("binary", &opts, &result);
unsigned char expected[SP_STDOUT_BYTES];
sp_stdout_payload(expected);
bool exact = sp_stdout_file_equals(fx.out, expected, sizeof(expected));
bool absent_log = !cbm_file_exists(fx.log);
int cleanup = th_rmtree(fx.root);
ASSERT_EQ(cleanup, 0);
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(exact);
ASSERT_TRUE(absent_log);
ASSERT_EQ(logs.count, 0);
PASS();
}
TEST(subprocess_stdout_empty_capture_survives_log_deletion) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {.stdout_file = fx.out,
.log_file = fx.log,
.on_log_line = sp_stdout_log,
.log_ud = &logs,
.delete_log_on_exit = true};
cbm_proc_result_t result = {0};
int rc = sp_stdout_run("empty", &opts, &result);
bool exact_empty = sp_stdout_file_equals(fx.out, "", 0);
bool deleted_log = !cbm_file_exists(fx.log);
bool callbacks = sp_stdout_stderr_only(&logs);
int cleanup = th_rmtree(fx.root);
ASSERT_EQ(cleanup, 0);
ASSERT_EQ(rc, 0);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(exact_empty);
ASSERT_TRUE(deleted_log);
ASSERT_TRUE(callbacks);
PASS();
}
TEST(subprocess_stdout_capture_owns_caller_path) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
char caller_path[SP_STDOUT_PATH];
memcpy(caller_path, fx.out, strlen(fx.out) + 1);
const char sentinel[] = "untouched-alternate-file";
int sentinel_rc = th_write_file(fx.other, sentinel);
const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdout_probe", "binary", fx.gate,
NULL};
cbm_proc_opts_t opts = {
.bin = sp_stdout_test_binary, .argv = argv, .stdout_file = caller_path, .log_file = fx.log};
cbm_subprocess_t *process = NULL;
int spawn_rc = sentinel_rc == 0 ? cbm_subprocess_spawn(&opts, &process) : -1;
bool got_process = process != NULL;
/* The child cannot emit before this mutation: its gate is still absent. */
memcpy(caller_path, fx.other, strlen(fx.other) + 1);
int gate_rc = th_write_file(fx.gate, "release");
cbm_proc_result_t result = {0};
bool terminal = process && sp_stdout_reap(process, &result);
unsigned char expected[SP_STDOUT_BYTES];
sp_stdout_payload(expected);
bool exact = sp_stdout_file_equals(fx.out, expected, sizeof(expected));
bool alternate_unchanged = sp_stdout_file_equals(fx.other, sentinel, sizeof(sentinel) - 1);
int cleanup = th_rmtree(fx.root);
ASSERT_EQ(cleanup, 0);
ASSERT_EQ(sentinel_rc, 0);
ASSERT_EQ(spawn_rc, 0);
ASSERT_TRUE(got_process);
ASSERT_EQ(gate_rc, 0);
ASSERT_TRUE(terminal);
ASSERT_EQ(result.outcome, CBM_PROC_CLEAN);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_TRUE(exact);
ASSERT_TRUE(alternate_unchanged);
PASS();
}
TEST(subprocess_stdout_rejects_invalid_capture_paths_and_cleans_up) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
char missing_parent[SP_STDOUT_PATH];
bool path_ok = sp_stdout_path(missing_parent, fx.root, "missing-parent/out.bin");
const char *paths[] = {"", fx.log, fx.root, missing_parent};
bool rejected[4] = {false};
bool null_handle[4] = {false};
bool cleanup_terminal[4] = {true, true, true, true};
if (path_ok) {
for (int i = 0; i < 4; i++) {
const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdout_probe", "text",
"", NULL};
cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary,
.argv = argv,
.stdout_file = paths[i],
.log_file = fx.log};
cbm_subprocess_t *process = NULL;
int rc = cbm_subprocess_spawn(&opts, &process);
rejected[i] = rc == -1;
null_handle[i] = process == NULL;
if (process) {
cbm_proc_result_t ignored;
cleanup_terminal[i] = sp_stdout_reap(process, &ignored);
}
}
}
int cleanup = th_rmtree(fx.root);
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(path_ok);
for (int i = 0; i < 4; i++) {
ASSERT_TRUE(cleanup_terminal[i]);
ASSERT_TRUE(rejected[i]);
ASSERT_TRUE(null_handle[i]);
}
PASS();
}
/* Independent stdin_file contract fixtures. No shell and no owned heap allocation. */
enum { SP_STDIN_BYTES = 4099 };
static const char sp_stdin_out_sentinel[] = "output-sentinel";
static const char sp_stdin_log_sentinel[] = "log-sentinel";
static void sp_stdin_payload(unsigned char bytes[SP_STDIN_BYTES]) {
for (int i = 0; i < SP_STDIN_BYTES; i++)
bytes[i] = (unsigned char)(i % 256);
bytes[0] = 'I';
bytes[1] = 0;
bytes[2] = '\r';
bytes[3] = '\n';
bytes[4] = '\n';
bytes[5] = 0x1a; /* Windows text-mode EOF must not truncate the fixture. */
bytes[1023] = 0;
bytes[SP_STDIN_BYTES - 1] = 0x7f; /* No final LF. */
}
static bool sp_stdin_write(const char *path, const void *bytes, size_t count) {
FILE *file = cbm_fopen(path, "wb");
if (!file)
return false;
bool ok = count == 0 || fwrite(bytes, 1, count, file) == count;
if (fclose(file) != 0)
ok = false;
return ok;
}
static bool sp_stdin_clean(int rc, const cbm_proc_result_t *result) {
return rc == 0 && result->outcome == CBM_PROC_CLEAN && result->exit_code == 0 &&
result->tree_quiesced && !result->supervision_failed;
}
/* Do not remove inputs/captures following failed containment. Terminal handles
* may be destroyed, but a nonquiescent tree's fixture is intentionally retained. */
static bool sp_stdin_safe_run(int rc, const cbm_proc_result_t *result) {
return result->tree_quiesced ||
(rc == -1 && result->outcome == CBM_PROC_SPAWN_FAILED && !result->supervision_failed);
}
static bool sp_stdin_finish(cbm_subprocess_t *process, cbm_proc_result_t *result) {
bool terminal = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 10000U);
if (!terminal) {
(void)cbm_subprocess_request_cancel(process);
terminal = sp_stdout_poll_terminal(process, result, cbm_now_ms() + 4000U);
}
if (terminal)
cbm_subprocess_destroy(process);
return terminal && result->tree_quiesced && !result->supervision_failed;
}
static int sp_stdin_run(cbm_proc_opts_t *opts, cbm_proc_result_t *result) {
const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "echo", "", NULL};
opts->bin = sp_stdout_test_binary;
opts->argv = argv;
opts->quiet_timeout_ms = 10000; /* Finite containment guard, not a timing oracle. */
return cbm_subprocess_run(opts, result);
}
typedef struct {
bool prepared;
bool rejected;
bool no_handle;
bool outputs_unchanged;
bool no_child_marker;
bool reusable;
bool safe_cleanup;
} sp_stdin_rejection_t;
static sp_stdin_rejection_t sp_stdin_check_rejection(const char *binary, const char *input,
const char *out, const char *log,
const char *marker,
const char *valid_empty_input) {
sp_stdin_rejection_t check = {.safe_cleanup = true};
(void)cbm_unlink(marker);
check.prepared =
sp_stdin_write(out, sp_stdin_out_sentinel, sizeof(sp_stdin_out_sentinel) - 1) &&
sp_stdin_write(log, sp_stdin_log_sentinel, sizeof(sp_stdin_log_sentinel) - 1) &&
sp_stdout_file_equals(valid_empty_input, "", 0) && !cbm_file_exists(marker);
if (!check.prepared)
return check;
const char *argv[] = {binary, "__cbm_subprocess_stdin_probe", "stamp", marker, NULL};
cbm_proc_opts_t opts = {.bin = binary,
.argv = argv,
.stdin_file = input,
.stdout_file = out,
.log_file = log,
.quiet_timeout_ms = 10000};
cbm_subprocess_t *process = NULL;
int rc = cbm_subprocess_spawn(&opts, &process);
check.rejected = rc == -1;
check.no_handle = process == NULL;
if (process) {
cbm_proc_result_t result = {0};
check.safe_cleanup = sp_stdin_finish(process, &result);
}
if (!check.safe_cleanup)
return check;
check.outputs_unchanged =
sp_stdout_file_equals(out, sp_stdin_out_sentinel, sizeof(sp_stdin_out_sentinel) - 1) &&
sp_stdout_file_equals(log, sp_stdin_log_sentinel, sizeof(sp_stdin_log_sentinel) - 1);
check.no_child_marker = !cbm_file_exists(marker);
/* A real empty regular file remains a usable input after each rejection.
* This control also passes when the API-only baseline ignores stdin_file. */
const char *valid_argv[] = {binary, "__cbm_subprocess_stdin_probe", "echo", "", NULL};
opts.argv = valid_argv;
opts.stdin_file = valid_empty_input;
cbm_proc_result_t result = {0};
rc = cbm_subprocess_run(&opts, &result);
check.safe_cleanup = sp_stdin_safe_run(rc, &result);
check.reusable = sp_stdin_clean(rc, &result) && sp_stdout_file_equals(out, "", 0) &&
sp_stdout_file_equals(log, sp_stderr_text, sizeof(sp_stderr_text) - 1) &&
sp_stdout_file_equals(valid_empty_input, "", 0);
return check;
}
/* Dispatched through the existing early native probe entry, before harness setup. */
static int sp_stdin_probe(int argc, char **argv) {
#ifdef _WIN32
if (_setmode(cbm_fileno(stdin), _O_BINARY) == -1 ||
_setmode(cbm_fileno(stdout), _O_BINARY) == -1 ||
_setmode(cbm_fileno(stderr), _O_BINARY) == -1)
return 101;
#endif
if (argc == 8 && strcmp(argv[2], "reject") == 0) {
sp_stdin_rejection_t check =
sp_stdin_check_rejection(argv[0], argv[3], argv[4], argv[5], argv[6], argv[7]);
if (!check.safe_cleanup)
return 125; /* Parent must retain files if nested containment failed. */
if (!check.prepared)
return 102;
if (!check.reusable)
return 103;
return check.rejected && check.no_handle && check.outputs_unchanged && check.no_child_marker
? 0
: 104;
}
if (argc == 6 && strcmp(argv[2], "parent-eof") == 0) {
/* Isolated process only: furnish genuinely readable parent stdin, then
* verify a NULL stdin_file child still gets the null device. */
FILE *file = cbm_fopen(argv[3], "rb");
if (!file)
return 105;
#ifdef _WIN32
int duplicated = _dup2(cbm_fileno(file), cbm_fileno(stdin));
#else
int duplicated = dup2(cbm_fileno(file), cbm_fileno(stdin));
#endif
bool closed = fclose(file) == 0;
if (duplicated < 0 || !closed)
return 106;
#ifdef _WIN32
if (!SetStdHandle(STD_INPUT_HANDLE, (HANDLE)_get_osfhandle(cbm_fileno(stdin))))
return 106;
#endif
/* Exercise the descriptor directly: a stdio-buffer-only rewind must not
* leave the inherited OS offset at EOF and make the control vacuous. */
unsigned char witness;
#ifdef _WIN32
if (_read(cbm_fileno(stdin), &witness, 1) != 1 ||
_lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0)
return 107;
#else
if (read(cbm_fileno(stdin), &witness, 1) != 1 || lseek(cbm_fileno(stdin), 0, SEEK_SET) != 0)
return 107;
#endif
const char *child_argv[] = {argv[0], "__cbm_subprocess_stdin_probe", "echo", "", NULL};
cbm_proc_opts_t opts = {.bin = argv[0],
.argv = child_argv,
.stdin_file = NULL,
.stdout_file = argv[4],
.log_file = argv[5],
.quiet_timeout_ms = 10000};
cbm_proc_result_t result = {0};
int rc = cbm_subprocess_run(&opts, &result);
if (!sp_stdin_safe_run(rc, &result))
return 125;
return sp_stdin_clean(rc, &result) && sp_stdout_file_equals(argv[4], "", 0) &&
sp_stdout_file_equals(argv[5], sp_stderr_text, sizeof(sp_stderr_text) - 1)
? 0
: 108;
}
if (argc != 4)
return 109;
if (strcmp(argv[2], "stamp") == 0) {
if (!sp_stdin_write(argv[3], "child-started", 13) ||
fwrite(sp_stdout_text, 1, sizeof(sp_stdout_text) - 1, stdout) !=
sizeof(sp_stdout_text) - 1)
return 110;
} else if (strcmp(argv[2], "echo") == 0) {
if (argv[3][0]) {
/* The parent releases this state barrier only after mutating its
* borrowed path. The deadline merely bounds an abandoned fixture. */
uint64_t deadline = cbm_now_ms() + 30000U;
while (!cbm_file_exists(argv[3])) {
if (cbm_now_ms() >= deadline)
return 111;
cbm_usleep(1000);
}
}
unsigned char bytes[512];
size_t count;
while ((count = fread(bytes, 1, sizeof(bytes), stdin)) != 0) {
if (fwrite(bytes, 1, count, stdout) != count)
return 112;
}
if (ferror(stdin))
return 112;
} else {
return 109;
}
if (fflush(stdout) != 0 ||
fwrite(sp_stderr_text, 1, sizeof(sp_stderr_text) - 1, stderr) !=
sizeof(sp_stderr_text) - 1 ||
fflush(stderr) != 0)
return 113;
return 0;
}
TEST(subprocess_stdin_null_is_eof_even_with_readable_parent_input) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
unsigned char input[SP_STDIN_BYTES];
sp_stdin_payload(input);
bool prepared = sp_stdin_write(fx.other, input, sizeof(input));
const char *argv[] = {sp_stdout_test_binary,
"__cbm_subprocess_stdin_probe",
"parent-eof",
fx.other,
fx.out,
fx.log,
NULL};
cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary, .argv = argv, .quiet_timeout_ms = 20000};
cbm_proc_result_t result = {0};
int rc = prepared ? cbm_subprocess_run(&opts, &result) : -1;
bool safe = !prepared || (sp_stdin_safe_run(rc, &result) && result.exit_code != 125);
bool unchanged = sp_stdout_file_equals(fx.other, input, sizeof(input));
bool empty = sp_stdout_file_equals(fx.out, "", 0);
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
ASSERT_TRUE(sp_stdin_clean(rc, &result));
ASSERT_TRUE(unchanged);
ASSERT_TRUE(empty);
PASS();
}
TEST(subprocess_stdin_empty_regular_file_is_success) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
bool prepared = sp_stdin_write(fx.other, "", 0);
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {.stdin_file = fx.other,
.stdout_file = fx.out,
.log_file = fx.log,
.on_log_line = sp_stdout_log,
.log_ud = &logs};
cbm_proc_result_t result = {0};
int rc = prepared ? sp_stdin_run(&opts, &result) : -1;
bool safe = !prepared || sp_stdin_safe_run(rc, &result);
bool exact = sp_stdout_file_equals(fx.out, "", 0) && sp_stdout_file_equals(fx.other, "", 0) &&
sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1);
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
ASSERT_TRUE(sp_stdin_clean(rc, &result));
ASSERT_TRUE(exact);
ASSERT_TRUE(sp_stdout_stderr_only(&logs));
PASS();
}
TEST(subprocess_stdin_binary_roundtrip_and_stderr_isolation) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
unsigned char input[SP_STDIN_BYTES];
sp_stdin_payload(input);
bool prepared = sp_stdin_write(fx.other, input, sizeof(input));
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {.stdin_file = fx.other,
.stdout_file = fx.out,
.log_file = fx.log,
.on_log_line = sp_stdout_log,
.log_ud = &logs};
cbm_proc_result_t result = {0};
int rc = prepared ? sp_stdin_run(&opts, &result) : -1;
bool safe = !prepared || sp_stdin_safe_run(rc, &result);
bool exact = sp_stdout_file_equals(fx.out, input, sizeof(input));
bool unchanged = sp_stdout_file_equals(fx.other, input, sizeof(input));
bool stderr_exact = sp_stdout_file_equals(fx.log, sp_stderr_text, sizeof(sp_stderr_text) - 1);
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
ASSERT_TRUE(sp_stdin_clean(rc, &result));
ASSERT_TRUE(exact);
ASSERT_TRUE(unchanged);
ASSERT_TRUE(stderr_exact);
ASSERT_TRUE(sp_stdout_stderr_only(&logs));
PASS();
}
TEST(subprocess_stdin_stream_combinations_preserve_input) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
unsigned char input[SP_STDIN_BYTES];
sp_stdin_payload(input);
bool prepared = sp_stdin_write(fx.other, input, sizeof(input));
bool all_clean = true, all_exact = true, all_unchanged = true, all_logs = true;
bool safe = true;
for (int mode = 0; prepared && safe && mode < 3; mode++) {
(void)cbm_unlink(fx.log);
sp_stdout_logs_t logs = {0};
cbm_proc_opts_t opts = {.stdin_file = fx.other,
.stdout_file = mode == 2 ? NULL : fx.out,
.log_file = mode == 1 ? NULL : fx.log,
.delete_log_on_exit = mode == 0,
.on_log_line = mode == 2 ? NULL : sp_stdout_log,
.log_ud = &logs};
cbm_proc_result_t result = {0};
int rc = sp_stdin_run(&opts, &result);
safe = sp_stdin_safe_run(rc, &result);
all_clean = all_clean && sp_stdin_clean(rc, &result);
all_unchanged = all_unchanged && sp_stdout_file_equals(fx.other, input, sizeof(input));
if (mode == 2) {
unsigned char merged[SP_STDIN_BYTES + sizeof(sp_stderr_text) - 1];
memcpy(merged, input, sizeof(input));
memcpy(merged + sizeof(input), sp_stderr_text, sizeof(sp_stderr_text) - 1);
all_exact = all_exact && sp_stdout_file_equals(fx.log, merged, sizeof(merged));
} else {
all_exact = all_exact && sp_stdout_file_equals(fx.out, input, sizeof(input));
all_logs = all_logs && !cbm_file_exists(fx.log) &&
(mode == 0 ? sp_stdout_stderr_only(&logs) : logs.count == 0);
}
}
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
ASSERT_TRUE(all_clean);
ASSERT_TRUE(all_exact);
ASSERT_TRUE(all_unchanged);
ASSERT_TRUE(all_logs);
PASS();
}
TEST(subprocess_stdin_caller_path_mutation_before_gate_release) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
char alternate[SP_STDOUT_PATH], caller_path[SP_STDOUT_PATH];
unsigned char input[SP_STDIN_BYTES];
sp_stdin_payload(input);
static const char alternate_bytes[] = "different-input-must-not-be-read";
bool prepared = sp_stdout_path(alternate, fx.root, "alternate.bin") &&
sp_stdin_write(fx.other, input, sizeof(input)) &&
sp_stdin_write(alternate, alternate_bytes, sizeof(alternate_bytes) - 1);
memcpy(caller_path, fx.other, strlen(fx.other) + 1);
const char *argv[] = {sp_stdout_test_binary, "__cbm_subprocess_stdin_probe", "echo", fx.gate,
NULL};
cbm_proc_opts_t opts = {.bin = sp_stdout_test_binary,
.argv = argv,
.stdin_file = caller_path,
.stdout_file = fx.out,
.log_file = fx.log};
cbm_subprocess_t *process = NULL;
int rc = prepared ? cbm_subprocess_spawn(&opts, &process) : -1;
bool got_process = process != NULL;
if (prepared)
memcpy(caller_path, alternate, strlen(alternate) + 1);
int gate_rc = prepared ? th_write_file(fx.gate, "release") : -1;
cbm_proc_result_t result = {0};
bool safe = !process || sp_stdin_finish(process, &result);
bool exact = sp_stdout_file_equals(fx.out, input, sizeof(input));
bool original_kept = sp_stdout_file_equals(fx.other, input, sizeof(input));
bool alternate_kept =
prepared && sp_stdout_file_equals(alternate, alternate_bytes, sizeof(alternate_bytes) - 1);
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
ASSERT_EQ(rc, 0);
ASSERT_TRUE(got_process);
ASSERT_EQ(gate_rc, 0);
ASSERT_TRUE(sp_stdin_clean(rc, &result));
ASSERT_TRUE(exact);
ASSERT_TRUE(original_kept);
ASSERT_TRUE(alternate_kept);
PASS();
}
TEST(subprocess_stdin_rejects_invalid_paths_before_outputs) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
char missing[SP_STDOUT_PATH];
bool prepared =
sp_stdout_path(missing, fx.root, "missing-input") && sp_stdin_write(fx.other, "", 0);
const char *paths[] = {"", missing, fx.root};
sp_stdin_rejection_t checks[3] = {{0}};
bool safe = true;
for (int i = 0; prepared && safe && i < 3; i++) {
checks[i] = sp_stdin_check_rejection(sp_stdout_test_binary, paths[i], fx.out, fx.log,
fx.gate, fx.other);
safe = checks[i].safe_cleanup;
}
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
for (int i = 0; i < 3; i++) {
ASSERT_TRUE(checks[i].prepared);
ASSERT_TRUE(checks[i].reusable);
ASSERT_TRUE(checks[i].rejected);
ASSERT_TRUE(checks[i].no_handle);
ASSERT_TRUE(checks[i].outputs_unchanged);
ASSERT_TRUE(checks[i].no_child_marker);
}
PASS();
}
TEST(subprocess_stdin_rejects_exact_output_aliases_before_outputs) {
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
bool prepared = sp_stdin_write(fx.other, "", 0);
const char *paths[] = {fx.log, fx.out};
sp_stdin_rejection_t checks[2] = {{0}};
bool safe = true;
for (int i = 0; prepared && safe && i < 2; i++) {
checks[i] = sp_stdin_check_rejection(sp_stdout_test_binary, paths[i], fx.out, fx.log,
fx.gate, fx.other);
safe = checks[i].safe_cleanup;
}
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
for (int i = 0; i < 2; i++) {
ASSERT_TRUE(checks[i].prepared);
ASSERT_TRUE(checks[i].reusable);
ASSERT_TRUE(checks[i].rejected);
ASSERT_TRUE(checks[i].no_handle);
ASSERT_TRUE(checks[i].outputs_unchanged);
ASSERT_TRUE(checks[i].no_child_marker);
}
PASS();
}
TEST(subprocess_stdin_posix_fifo_and_symlink_reject_without_blocking_runner) {
#ifdef _WIN32
/* WHITELIST: POSIX FIFO/symlink semantics only. Directory rejection is covered
* above on Windows. No verified unprivileged Windows reparse fixture exists
* in this package; that branch is a recorded coverage limit, not a PASS. */
SKIP_PLATFORM("POSIX FIFO and symlink fixture; Windows directory covered separately");
#else
sp_stdout_fixture_t fx;
ASSERT_TRUE(sp_stdout_fixture_open(&fx));
char fifo[SP_STDOUT_PATH], link[SP_STDOUT_PATH];
bool prepared = sp_stdout_path(fifo, fx.root, "input.fifo") &&
sp_stdout_path(link, fx.root, "input.link") &&
sp_stdin_write(fx.other, "", 0) && mkfifo(fifo, 0600) == 0 &&
symlink(fx.other, link) == 0;
struct stat fifo_stat, link_stat;
bool kinds = prepared && lstat(fifo, &fifo_stat) == 0 && S_ISFIFO(fifo_stat.st_mode) &&
lstat(link, &link_stat) == 0 && S_ISLNK(link_stat.st_mode);
const char *paths[] = {fifo, link};
bool clean[2] = {false}, no_child[2] = {false};
bool safe = true;
for (int i = 0; kinds && safe && i < 2; i++) {
(void)cbm_unlink(fx.gate);
const char *argv[] = {sp_stdout_test_binary,
"__cbm_subprocess_stdin_probe",
"reject",
paths[i],
fx.out,
fx.log,
fx.gate,
fx.other,
NULL};
/* The probe can itself block inside spawn on a broken blocking FIFO
* open. Only that isolated process is then killed by this safety guard.
* No elapsed-time threshold is used as evidence of correct rejection. */
cbm_proc_opts_t opts = {
.bin = sp_stdout_test_binary, .argv = argv, .quiet_timeout_ms = 20000};
cbm_proc_result_t result = {0};
int rc = cbm_subprocess_run(&opts, &result);
safe = sp_stdin_safe_run(rc, &result) && result.exit_code != 125;
clean[i] = sp_stdin_clean(rc, &result);
no_child[i] = !cbm_file_exists(fx.gate);
}
int cleanup = safe ? th_rmtree(fx.root) : -1;
ASSERT_EQ(cleanup, 0);
ASSERT_TRUE(prepared);
ASSERT_TRUE(kinds);
for (int i = 0; i < 2; i++) {
ASSERT_TRUE(clean[i]);
ASSERT_TRUE(no_child[i]);
}
PASS();
#endif
}
SUITE(subprocess) {
RUN_TEST(subprocess_stdin_null_is_eof_even_with_readable_parent_input);
RUN_TEST(subprocess_stdin_empty_regular_file_is_success);
RUN_TEST(subprocess_stdin_binary_roundtrip_and_stderr_isolation);
RUN_TEST(subprocess_stdin_stream_combinations_preserve_input);
RUN_TEST(subprocess_stdin_caller_path_mutation_before_gate_release);
RUN_TEST(subprocess_stdin_rejects_invalid_paths_before_outputs);
RUN_TEST(subprocess_stdin_rejects_exact_output_aliases_before_outputs);
RUN_TEST(subprocess_stdin_posix_fifo_and_symlink_reject_without_blocking_runner);
RUN_TEST(subprocess_stdout_null_preserves_merged_log);
RUN_TEST(subprocess_stdout_capture_exact_binary_and_stderr_isolation);
RUN_TEST(subprocess_stdout_capture_without_log_discards_only_stderr);
RUN_TEST(subprocess_stdout_empty_capture_survives_log_deletion);
RUN_TEST(subprocess_stdout_capture_owns_caller_path);
RUN_TEST(subprocess_stdout_rejects_invalid_capture_paths_and_cleans_up);
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_environment_is_child_local);
RUN_TEST(subprocess_environment_core_ownership);
RUN_TEST(subprocess_environment_rejects_invalid_override);
RUN_TEST(subprocess_environment_overrides_preserve_git_isolation);
#ifndef _WIN32
RUN_TEST(subprocess_environment_path_preserves_caller_lookup);
#endif
RUN_TEST(subprocess_run_clean);
RUN_TEST(subprocess_run_exit_nonzero);
RUN_TEST(subprocess_run_child_exited_before_group_check);
RUN_TEST(subprocess_run_child_exiting_at_group_check);
RUN_TEST(subprocess_run_resolves_literal_binary_name_from_path);
RUN_TEST(subprocess_run_crash_is_crash);
RUN_TEST(subprocess_run_hang_is_hang);
RUN_TEST(subprocess_retries_transient_spawn_refusal);
RUN_TEST(subprocess_spawn_backoff_resumes_after_eintr);
RUN_TEST(subprocess_gives_up_after_the_retry_budget);
RUN_TEST(subprocess_run_spawn_failure);
RUN_TEST(subprocess_run_null_bin_rejected);
RUN_TEST(subprocess_spawn_returns_while_child_is_running);
RUN_TEST(subprocess_natural_completion_is_cached_across_polls);
RUN_TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree);
RUN_TEST(subprocess_quiet_timeout_kills_ignoring_tree);
RUN_TEST(subprocess_windows_job_object_cancellation_quiesces_descendant_tree);
RUN_TEST(subprocess_windows_job_object_enforces_memory_limit);
RUN_TEST(subprocess_cancel_grace_is_hard_capped);
RUN_TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless);
RUN_TEST(subprocess_final_log_drain_error_is_terminal_and_preserves_classification);
RUN_TEST(subprocess_posix_child_closes_unrelated_descriptors);
RUN_TEST(subprocess_child_close_fds_uses_one_syscall_not_rlimit_loop);
RUN_TEST(subprocess_root_exit_drains_surviving_descendant);
RUN_TEST(subprocess_strip_git_repo_env_is_per_child);
RUN_TEST(popen_git_strips_repo_env_and_reports_exit_status);
RUN_TEST(win_cmdline_index_worker_json);
RUN_TEST(win_cmdline_roundtrip_battery);
RUN_TEST(win_cmdline_overflow_rejected);
RUN_TEST(win_cmdline_overflow_leaves_empty_string);
RUN_TEST(win_cmd_payload_is_verbatim_and_capacity_checked);
RUN_TEST(win_cmd_payload_rejects_short_relative_and_non_cmd_paths);
}