38b9f9e286
The EAGAIN retry shipped in v0.10.3 was too small to matter. It waited a flat 3 x 10ms, which covers a momentary dip and not the real thing: a CI runner building and testing in parallel stays process-starved for hundreds of milliseconds at a stretch. `subprocess_run_spawn_failure` kept failing WITH the retry in place — macos-15-intel on a release matrix, then macos-14 under ThreadSanitizer, twice on the same SHA, which is what moved this out of the flake bucket. A fixed short delay just samples the same congested instant repeatedly; doubling walks out of it. Now 10/20/40/80/160/320ms — about 0.6s of total patience. That is invisible next to spawning a process that does real work, and a machine still refusing after it is genuinely out of capacity, where failing fast beats hanging. The previous attempt shipped a constant with no way to prove it worked, so this adds the seam that was missing: CBM_ENABLE_TEST_SEAMS builds can force N simulated refusals, and two tests pin the behaviour deterministically — fewer refusals than the budget must still spawn, more must fail rather than retry forever. Both are revert-checked. The seam compiles out entirely in production (cppcheck correctly called the always-false branch dead code, so it is gone rather than suppressed). Two things the tests caught that review had not: - The macOS primary path is posix_spawn, not fork; injecting only into the fork fallback exercised nothing on macOS. Both paths now carry it. - cbm_fork_with_retry ended with an unconditional fork() OUTSIDE the loop, so the final attempt could never be reached by a test. Every attempt now goes through the same branch. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1055 lines
39 KiB
C
1055 lines
39 KiB
C
/*
|
|
* test_subprocess.c — foundation/subprocess: spawn + supervise + classify.
|
|
*
|
|
* Two layers:
|
|
* 1. cbm_proc_classify() — pure, exercised on EVERY platform (so the Windows
|
|
* NTSTATUS crash-code mapping is guarded on Linux/macOS CI too, not just an
|
|
* untested Windows branch).
|
|
* 2. cbm_subprocess_run() — real spawn/reap, exercised on POSIX via /bin/sh
|
|
* (SKIP_PLATFORM on Windows, which lacks it).
|
|
*/
|
|
#include "test_framework.h"
|
|
#include "../src/foundation/subprocess.h"
|
|
#include "../src/foundation/compat.h"
|
|
#include "../src/foundation/platform.h"
|
|
|
|
#include <errno.h>
|
|
#include <limits.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#ifndef _WIN32
|
|
#include <fcntl.h>
|
|
#include <signal.h>
|
|
#include <unistd.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) ─────────────────────────────────────── */
|
|
|
|
#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
|
|
}
|
|
|
|
/* 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
|
|
}
|
|
|
|
/* 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
|
|
}
|
|
|
|
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;
|
|
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();
|
|
}
|
|
|
|
/* ── 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_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
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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_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_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_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_root_exit_drains_surviving_descendant);
|
|
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);
|
|
}
|