Files
codebase-memory-mcp/src/foundation/subprocess.c
T
Martin Vogel e775c7f511 fix(subprocess): a child that is exiting at the group check is a finished run
macOS answers ESRCH to setpgid and getpgid for our own unreaped child from
the moment it starts to exit, before waitid can report it. a5c7ae97 accepted
a child that had already exited, probed with waitid(WNOHANG|WNOWAIT); in the
window before the child is reapable that probe said "not exited", and the
spawn was reported as SPAWN_FAILED errno 3. macos-15-intel CI kept hitting it
in a different Git-backed fixture on each run; with the new diagnostics it
named itself: `git config --local core.protectNTFS false`, outcome=5 errno=3.

A child of ours that setpgid/getpgid cannot find is not running: it has
exited or is exiting. The parent now waits until it is reapable (WNOWAIT
leaves it for the supervision, which reaps it with its real exit status);
the wait ends with the child's exit. EACCES/EPERM keep the group check.

A test seam makes the parent observe the exiting state (ESRCH, not yet
reapable): subprocess_run_child_exiting_at_group_check is SPAWN_FAILED
without this change and exit status 3 with it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-10-06 21:42:18 +02:00

1816 lines
66 KiB
C

/*
* subprocess.c — cross-platform spawn + supervise + classify.
* See subprocess.h. The spawn/reap skeleton mirrors src/ui/http_server.c's
* index subprocess; this generalizes it and adds crash/hang classification.
*/
#include "subprocess.h"
#include "arena.h"
#include "compat.h" /* cbm_nanosleep */
#include "compat_fs.h"
#include "git_env.h" /* strip_git_repo_env: scrubbed child environment for git */
#include "log.h"
#include "platform.h" /* cbm_now_ms */
#include "sanitized.h" /* CBM_SANITIZED — spawn-retry budget */
#include <stdio.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#include "win_utf8.h" /* cbm_utf8_to_wide — spawn the worker with a wide command line so a
* non-ASCII repo path survives CreateProcess (#423/#20) */
#include <stdlib.h> /* free */
#else
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#ifdef __APPLE__
#include <spawn.h>
#endif
extern char **environ;
#ifdef __linux__
#include <sys/syscall.h> /* SYS_close_range: raw syscall, see cbm_posix_close_fds_from */
#endif
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
/* NTSTATUS severity ERROR (top two bits set) covers the Windows crash exception
* exit codes: 0xC0000005 (access violation), 0xC00000FD (stack overflow),
* 0xC000001D (illegal instruction), 0xC0000094 (integer divide by zero), … */
#define CBM_WIN_CRASH_CODE_MIN 0xC0000000u
#define CBM_WIN_CONTROL_C_EXIT 0xC000013Au
#ifndef _WIN32
static bool cbm_is_fault_signal(int sig) {
switch (sig) {
case SIGSEGV:
case SIGBUS:
case SIGILL:
case SIGFPE:
case SIGABRT:
case SIGSYS:
return true;
default:
return false;
}
}
#endif
cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int term_signal,
bool timed_out) {
if (timed_out) {
return CBM_PROC_HANG;
}
if (!exited_normally) {
/* POSIX signal death. */
#ifndef _WIN32
if (cbm_is_fault_signal(term_signal)) {
return CBM_PROC_CRASH;
}
#else
(void)term_signal;
#endif
return CBM_PROC_KILLED;
}
/* Exited with a code. A Windows NTSTATUS exception code is a crash; on POSIX
* exit codes are 0..255 so this branch never misfires there. */
if ((unsigned)exit_code == CBM_WIN_CONTROL_C_EXIT) {
return CBM_PROC_KILLED;
}
if ((unsigned)exit_code >= CBM_WIN_CRASH_CODE_MIN) {
return CBM_PROC_CRASH;
}
return (exit_code == 0) ? CBM_PROC_CLEAN : CBM_PROC_EXIT_NONZERO;
}
const char *cbm_proc_outcome_str(cbm_proc_outcome_t o) {
switch (o) {
case CBM_PROC_CLEAN:
return "clean";
case CBM_PROC_EXIT_NONZERO:
return "exit_nonzero";
case CBM_PROC_CRASH:
return "crash";
case CBM_PROC_HANG:
return "hang";
case CBM_PROC_KILLED:
return "killed";
case CBM_PROC_SPAWN_FAILED:
default:
return "spawn_failed";
}
}
typedef enum {
CBM_TAIL_MORE = 0,
CBM_TAIL_CAUGHT_UP,
CBM_TAIL_ERROR,
} cbm_tail_status_t;
typedef struct {
cbm_tail_status_t status;
bool progressed;
} cbm_tail_result_t;
/* Tail one bounded batch from the child log. While the owned tree can still
* write, a partial final line remains buffered. Once the tree is quiescent,
* final=true delivers that last fragment exactly once. */
static cbm_tail_result_t cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb cb,
void *ud, bool final) {
cbm_tail_result_t result = {.status = CBM_TAIL_ERROR, .progressed = false};
if (!log_file || !tail_pos) {
return result;
}
#ifdef _WIN32
FILE *lf = cbm_fopen(log_file, "r");
#else
int open_flags = O_RDONLY | O_NONBLOCK;
#ifdef O_CLOEXEC
open_flags |= O_CLOEXEC;
#endif
#ifdef O_NOFOLLOW
open_flags |= O_NOFOLLOW;
#endif
int log_fd = open(log_file, open_flags);
if (log_fd < 0) {
return result;
}
struct stat log_status;
if (fstat(log_fd, &log_status) != 0 || !S_ISREG(log_status.st_mode)) {
(void)close(log_fd);
return result;
}
FILE *lf = fdopen(log_fd, "r");
#endif
if (!lf) {
#ifndef _WIN32
(void)close(log_fd);
#endif
return result;
}
if (fseek(lf, *tail_pos, SEEK_SET) == 0) {
char line[1024];
size_t delivered_lines = 0;
size_t delivered_bytes = 0;
enum {
CBM_TAIL_MAX_LINES_PER_POLL = 64,
CBM_TAIL_MAX_BYTES_PER_POLL = 64 * 1024,
};
while (delivered_lines < CBM_TAIL_MAX_LINES_PER_POLL &&
delivered_bytes < CBM_TAIL_MAX_BYTES_PER_POLL) {
long before = ftell(lf);
if (!fgets(line, sizeof(line), lf)) {
result.status = ferror(lf) ? CBM_TAIL_ERROR : CBM_TAIL_CAUGHT_UP;
break;
}
size_t l = strlen(line);
delivered_bytes += l;
bool complete = (l > 0 && line[l - 1] == '\n');
if (complete) {
line[l - 1] = '\0';
*tail_pos = ftell(lf);
result.progressed = true;
delivered_lines++;
if (line[0] && cb) {
cb(line, ud);
}
} else if (l == sizeof(line) - 1) {
/* Oversized line filled the buffer without a newline — consume it
* anyway (counts as progress) so we never stall on one long line. */
*tail_pos = ftell(lf);
result.progressed = true;
delivered_lines++;
if (cb) {
cb(line, ud);
}
} else if (!final) {
/* Genuine partial final line — keep it buffered for next poll. */
*tail_pos = before;
result.status = CBM_TAIL_MORE;
break;
} else {
/* No writer remains: deliver the final unterminated fragment. */
*tail_pos = ftell(lf);
result.progressed = true;
if (line[0] && cb) {
cb(line, ud);
}
result.status = CBM_TAIL_CAUGHT_UP;
break;
}
}
if (result.status == CBM_TAIL_ERROR && !ferror(lf)) {
/* Reaching either work cap is conservatively MORE. An exact batch
* boundary needs one empty follow-up poll to prove EOF. */
result.status = CBM_TAIL_MORE;
}
}
if (fclose(lf) != 0) {
result.status = CBM_TAIL_ERROR;
}
return result;
}
/* ── Windows command-line quoting (pure; unit-tested on every platform) ─────── */
/* Append char `c` to buf[cap], reserving the final byte for a NUL terminator.
* On overflow: sets *ovf, stops writing, and returns pos UNCHANGED — callers detect
* the overflow via the *ovf flag (not via the return value). */
static size_t cbm_cmdline_put(char *buf, size_t cap, size_t pos, char c, bool *ovf) {
if (pos + 1 >= cap) {
*ovf = true;
return pos;
}
buf[pos] = c;
return pos + 1;
}
/* Append one argv element to the command line using the Microsoft C runtime
* quoting rules (see MS "Parsing C Command-Line Arguments"). CreateProcess takes
* a SINGLE string that the child re-parses back into argv, so any element with a
* space, tab or double-quote must be wrapped in quotes and its embedded quotes /
* preceding backslashes escaped. Without this a JSON argument like
* {"repo_path":"C:/r"} loses its inner quotes and the child receives the invalid
* {repo_path:C:/r} — the Windows-only index-worker cmdline-quoting bug (the worker exited
* non-zero at JSON-arg parse, misattributed to the last-marked file). POSIX is
* unaffected: the POSIX spawn path passes the argv array straight to execv. */
static size_t cbm_cmdline_append_arg(char *buf, size_t cap, size_t pos, const char *arg, bool first,
bool *ovf) {
if (!first) {
pos = cbm_cmdline_put(buf, cap, pos, ' ', ovf);
}
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
for (const char *p = arg; *p;) {
size_t nbs = 0;
while (*p == '\\') {
nbs++;
p++;
}
if (*p == '\0') {
/* Trailing backslashes precede the closing quote: double them so the
* quote stays a delimiter, not an escaped literal. */
for (size_t k = 0; k < nbs * 2; k++) {
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
}
break;
}
if (*p == '"') {
/* N backslashes then a quote -> 2N+1 backslashes then an escaped quote. */
for (size_t k = 0; k < nbs * 2 + 1; k++) {
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
}
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
p++;
} else {
for (size_t k = 0; k < nbs; k++) {
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
}
pos = cbm_cmdline_put(buf, cap, pos, *p, ovf);
p++;
}
}
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
return pos;
}
/* Build a full Windows CreateProcess command line from a NULL-terminated argv,
* applying the MS C runtime quoting rules so the child re-parses byte-identical
* argv. Returns true on success, false if the result would overflow `buf`.
*
* Defined unconditionally (pure string logic, no Windows headers) so the quoting
* contract is unit-tested on Linux/macOS CI too — even though the real spawn path
* only runs on Windows. Shared by cbm_subprocess_spawn_win AND the UI http_server
* index spawn so both escape identically; a naive `"%s"` wrap corrupts any argument
* containing a quote (e.g. the index JSON {"repo_path":"…"}), corrupting the
* spawned child's argv. */
bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) {
if (!buf || cap == 0 || !argv) {
return false;
}
size_t pos = 0;
bool ovf = false;
for (int i = 0; argv[i]; i++) {
pos = cbm_cmdline_append_arg(buf, cap, pos, argv[i], i == 0, &ovf);
if (ovf) {
buf[0] = '\0'; /* overflow: leave buf a valid (empty) string, never unterminated */
return false;
}
}
buf[pos] = '\0';
return true;
}
static bool cbm_ascii_case_equal(const char *left, const char *right) {
if (!left || !right) {
return false;
}
while (*left && *right) {
unsigned char left_ch = (unsigned char)*left++;
unsigned char right_ch = (unsigned char)*right++;
if (left_ch >= 'A' && left_ch <= 'Z') {
left_ch = (unsigned char)(left_ch + ('a' - 'A'));
}
if (right_ch >= 'A' && right_ch <= 'Z') {
right_ch = (unsigned char)(right_ch + ('a' - 'A'));
}
if (left_ch != right_ch) {
return false;
}
}
return *left == '\0' && *right == '\0';
}
static bool cbm_win_cmd_path_is_absolute(const char *path) {
if (!path) {
return false;
}
size_t path_len = strlen(path);
if (path_len < 3) {
return false;
}
bool drive_absolute =
((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) &&
path[1] == ':' && (path[2] == '\\' || path[2] == '/');
bool unc_absolute = (path[0] == '\\' || path[0] == '/') && path[1] == path[0] && path[2];
if (!drive_absolute && !unc_absolute) {
return false;
}
const char *basename = path;
for (const char *cursor = path; *cursor; cursor++) {
if (*cursor == '"' || *cursor == '\r' || *cursor == '\n') {
return false;
}
if (*cursor == '\\' || *cursor == '/') {
basename = cursor + 1;
}
}
return cbm_ascii_case_equal(basename, "cmd.exe");
}
bool cbm_build_win_cmd_payload(char *buf, size_t cap, const char *cmd_executable,
const char *payload) {
if (!buf || cap == 0) {
return false;
}
buf[0] = '\0';
if (!cbm_win_cmd_path_is_absolute(cmd_executable) || !payload || !payload[0]) {
return false;
}
static const char prefix[] = "\" /D /S /V:OFF /C ";
size_t executable_len = strlen(cmd_executable);
size_t payload_len = strlen(payload);
size_t remaining = cap - 1; /* reserve the final NUL */
if (remaining < 1) {
return false;
}
remaining--; /* opening quote */
if (executable_len > remaining) {
return false;
}
remaining -= executable_len;
if (sizeof(prefix) - 1 > remaining) {
return false;
}
remaining -= sizeof(prefix) - 1;
if (payload_len > remaining) {
return false;
}
size_t pos = 0;
buf[pos++] = '"';
memcpy(buf + pos, cmd_executable, executable_len);
pos += executable_len;
memcpy(buf + pos, prefix, sizeof(prefix) - 1);
pos += sizeof(prefix) - 1;
memcpy(buf + pos, payload, payload_len);
pos += payload_len;
buf[pos] = '\0';
return true;
}
/* ── Nonblocking contained-process supervisor ─────────────────────────────── */
enum { CBM_SUBPROCESS_ARGV_LIMIT = 4096 };
typedef enum {
CBM_SUBPROCESS_ACTIVE = 0,
CBM_SUBPROCESS_CANCEL_REQUESTED,
CBM_SUBPROCESS_DRAINING,
CBM_SUBPROCESS_TERMINAL,
} cbm_subprocess_lifecycle_t;
struct cbm_subprocess {
char *bin;
char **argv;
size_t argc;
char *windows_cmd_payload;
char *log_file;
CBMArena stdout_arena;
char *stdout_file;
char *stdin_file;
cbm_proc_log_cb on_log_line;
void *log_ud;
int quiet_timeout_ms;
int cancel_grace_ms;
size_t memory_limit_bytes;
bool delete_log_on_exit;
bool strip_git_repo_env;
#ifndef _WIN32
char **envp; /* NULL => inherit environ; else the scrubbed git child env */
#endif
long tail_pos;
uint64_t last_activity_ms;
uint64_t termination_started_ms;
atomic_int lifecycle;
bool timed_out;
bool termination_started;
bool force_sent;
bool root_reaped;
uint64_t force_started_ms;
bool containment_failed;
cbm_proc_result_t result;
#ifdef _WIN32
HANDLE process;
HANDLE job;
DWORD process_id;
bool root_forced;
#else
pid_t pid;
pid_t pgid;
#endif
};
static void cbm_subprocess_result_init(cbm_proc_result_t *result) {
result->outcome = CBM_PROC_SPAWN_FAILED;
result->exit_code = -1;
result->term_signal = 0;
result->cancellation_requested = false;
result->forced = false;
result->tree_quiesced = false;
result->supervision_failed = false;
result->job_memory_limit_bytes = 0;
result->peak_job_memory_bytes = 0;
result->job_memory_available = false;
}
static void cbm_subprocess_free_config(cbm_subprocess_t *process) {
if (!process) {
return;
}
if (process->argv) {
for (size_t i = 0; i < process->argc; i++) {
free(process->argv[i]);
}
}
free(process->argv);
free(process->bin);
free(process->windows_cmd_payload);
free(process->log_file);
cbm_arena_destroy(&process->stdout_arena);
#ifndef _WIN32
cbm_git_child_env_free(process->envp);
#endif
free(process);
}
static cbm_subprocess_t *cbm_subprocess_copy_opts(const cbm_proc_opts_t *opts) {
if (!opts || !opts->bin || !opts->bin[0]) {
return NULL;
}
if (opts->stdout_file && (!opts->stdout_file[0] ||
(opts->log_file && strcmp(opts->stdout_file, opts->log_file) == 0))) {
return NULL;
}
if (opts->stdin_file &&
(!opts->stdin_file[0] ||
(opts->log_file && strcmp(opts->stdin_file, opts->log_file) == 0) ||
(opts->stdout_file && strcmp(opts->stdin_file, opts->stdout_file) == 0))) {
return NULL;
}
#ifdef _WIN32
if (opts->windows_cmd_payload &&
(!opts->windows_cmd_payload[0] || opts->argv || !cbm_win_cmd_path_is_absolute(opts->bin))) {
return NULL;
}
#else
if (opts->windows_cmd_payload) {
return NULL;
}
#endif
size_t argc = 1;
if (opts->argv) {
argc = 0;
while (argc < CBM_SUBPROCESS_ARGV_LIMIT && opts->argv[argc]) {
argc++;
}
if (argc == 0 || argc == CBM_SUBPROCESS_ARGV_LIMIT) {
return NULL;
}
}
cbm_subprocess_t *process = (cbm_subprocess_t *)calloc(1, sizeof(*process));
if (!process) {
return NULL;
}
cbm_arena_init_lazy(&process->stdout_arena, 256);
process->bin = cbm_strdup(opts->bin);
process->windows_cmd_payload =
opts->windows_cmd_payload ? cbm_strdup(opts->windows_cmd_payload) : NULL;
process->argv = (char **)calloc(argc + 1, sizeof(*process->argv));
process->argc = argc;
if (!process->bin || !process->argv ||
(opts->windows_cmd_payload && !process->windows_cmd_payload)) {
cbm_subprocess_free_config(process);
return NULL;
}
for (size_t i = 0; i < argc; i++) {
const char *arg = opts->argv ? opts->argv[i] : opts->bin;
process->argv[i] = cbm_strdup(arg);
if (!process->argv[i]) {
cbm_subprocess_free_config(process);
return NULL;
}
}
if (opts->log_file) {
process->log_file = cbm_strdup(opts->log_file);
if (!process->log_file) {
cbm_subprocess_free_config(process);
return NULL;
}
}
if (opts->stdout_file) {
process->stdout_file = cbm_arena_strdup(&process->stdout_arena, opts->stdout_file);
if (!process->stdout_file) {
cbm_subprocess_free_config(process);
return NULL;
}
}
if (opts->stdin_file) {
process->stdin_file = cbm_arena_strdup(&process->stdout_arena, opts->stdin_file);
if (!process->stdin_file) {
cbm_subprocess_free_config(process);
return NULL;
}
}
process->on_log_line = opts->on_log_line;
process->log_ud = opts->log_ud;
process->quiet_timeout_ms = opts->quiet_timeout_ms;
process->cancel_grace_ms =
opts->cancel_grace_ms > 0 ? opts->cancel_grace_ms : CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS;
if (process->cancel_grace_ms > CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS) {
process->cancel_grace_ms = CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS;
}
process->memory_limit_bytes = opts->memory_limit_bytes;
process->delete_log_on_exit = opts->delete_log_on_exit;
process->strip_git_repo_env = opts->strip_git_repo_env;
#ifndef _WIN32
/* Built before the spawn: the fork child may only assign it (malloc is
* not async-signal-safe there). Fail closed rather than inherit GIT_DIR. */
if (opts->strip_git_repo_env) {
process->envp = cbm_git_child_envp();
if (!process->envp) {
cbm_subprocess_free_config(process);
return NULL;
}
}
#endif
atomic_init(&process->lifecycle, CBM_SUBPROCESS_ACTIVE);
cbm_subprocess_result_init(&process->result);
return process;
}
static cbm_tail_result_t cbm_subprocess_poll_log(cbm_subprocess_t *process, bool final) {
cbm_tail_result_t result = cbm_tail_log(process->log_file, &process->tail_pos,
process->on_log_line, process->log_ud, final);
if (result.progressed) {
process->last_activity_ms = cbm_now_ms();
}
return result;
}
static bool cbm_subprocess_cancellation_requested(const cbm_subprocess_t *process) {
return atomic_load_explicit(&process->lifecycle, memory_order_acquire) ==
CBM_SUBPROCESS_CANCEL_REQUESTED;
}
static void cbm_subprocess_delete_log(cbm_subprocess_t *process) {
if (!process->log_file || !process->delete_log_on_exit) {
return;
}
#ifdef _WIN32
wchar_t *path = cbm_path_to_wide(process->log_file);
if (path) {
(void)DeleteFileW(path);
free(path);
}
#else
(void)unlink(process->log_file);
#endif
}
static bool cbm_subprocess_begin_terminal_transition(cbm_subprocess_t *process) {
int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire);
for (;;) {
if (lifecycle == CBM_SUBPROCESS_DRAINING) {
return true;
}
if (lifecycle == CBM_SUBPROCESS_TERMINAL) {
return false;
}
int desired = CBM_SUBPROCESS_DRAINING;
if (atomic_compare_exchange_weak_explicit(&process->lifecycle, &lifecycle, desired,
memory_order_acq_rel, memory_order_acquire)) {
process->result.cancellation_requested = lifecycle == CBM_SUBPROCESS_CANCEL_REQUESTED;
return true;
}
}
}
static cbm_proc_poll_t cbm_subprocess_publish_terminal(cbm_subprocess_t *process,
cbm_proc_result_t *out, bool delete_log) {
if (delete_log) {
cbm_subprocess_delete_log(process);
}
atomic_store_explicit(&process->lifecycle, CBM_SUBPROCESS_TERMINAL, memory_order_release);
if (out) {
*out = process->result;
}
return CBM_PROC_POLL_TERMINAL;
}
static cbm_proc_poll_t cbm_subprocess_finish(cbm_subprocess_t *process, cbm_proc_result_t *out) {
if (!cbm_subprocess_begin_terminal_transition(process)) {
return CBM_PROC_POLL_ERROR;
}
process->result.tree_quiesced = true;
process->result.supervision_failed = false;
if (process->log_file && process->on_log_line) {
return CBM_PROC_POLL_RUNNING;
}
return cbm_subprocess_publish_terminal(process, out, true);
}
static cbm_proc_poll_t cbm_subprocess_finish_failed(cbm_subprocess_t *process,
cbm_proc_result_t *out) {
if (!cbm_subprocess_begin_terminal_transition(process)) {
return CBM_PROC_POLL_ERROR;
}
process->result.tree_quiesced = false;
process->result.supervision_failed = true;
process->result.forced = true;
if (process->timed_out) {
process->result.outcome = CBM_PROC_HANG;
} else if (process->result.outcome == CBM_PROC_SPAWN_FAILED) {
process->result.outcome = CBM_PROC_KILLED;
}
process->containment_failed = true;
return cbm_subprocess_publish_terminal(process, out, false);
}
#ifdef _WIN32
/* Match cbm_path_to_wide's file-path spelling using the process arena. Existing
* option conversions retain their legacy heap ownership. */
static wchar_t *cbm_win_stdin_path(cbm_subprocess_t *process) {
int length =
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, process->stdin_file, -1, NULL, 0);
if (length <= 0 || length > 32767) {
return NULL;
}
wchar_t *wide = cbm_arena_alloc(&process->stdout_arena, (size_t)length * sizeof(*wide));
if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, process->stdin_file, -1, wide,
length) != length) {
return NULL;
}
size_t input_length = (size_t)length - 1U;
if (cbm_win_path_has_namespace_prefix(wide, input_length)) {
cbm_win_path_normalize_wide_separators(wide);
return wide;
}
if (input_length < 240U) {
return wide;
}
DWORD needed = GetFullPathNameW(wide, 0, NULL, NULL);
if (needed == 0U || needed > 32767U) {
return NULL;
}
wchar_t *full = cbm_arena_alloc(&process->stdout_arena, (size_t)needed * sizeof(*full));
DWORD copied = full ? GetFullPathNameW(wide, needed, full, NULL) : 0U;
if (copied == 0U || copied >= needed) {
return NULL;
}
cbm_win_path_normalize_wide_separators(full);
bool drive_absolute =
copied >= 3U &&
((full[0] >= L'A' && full[0] <= L'Z') || (full[0] >= L'a' && full[0] <= L'z')) &&
full[1] == L':' && full[2] == L'\\';
bool unc_absolute = copied >= 5U && full[0] == L'\\' && full[1] == L'\\';
if (!drive_absolute && !unc_absolute) {
return NULL;
}
const wchar_t *prefix = drive_absolute ? L"\\\\?\\" : L"\\\\?\\UNC\\";
size_t prefix_length = drive_absolute ? 4U : 8U;
size_t tail_offset = drive_absolute ? 0U : 2U;
size_t tail_length = (size_t)copied - tail_offset;
size_t extended_length = prefix_length + tail_length;
if (extended_length >= 32767U) {
return NULL;
}
wchar_t *prefixed =
cbm_arena_alloc(&process->stdout_arena, (extended_length + 1U) * sizeof(*prefixed));
if (!prefixed) {
return NULL;
}
wmemcpy(prefixed, prefix, prefix_length);
wmemcpy(prefixed + prefix_length, full + tail_offset, tail_length + 1U);
return prefixed;
}
static void cbm_win_close_spawn_handles(HANDLE nul, HANDLE input, HANDLE log, HANDLE stdout_capture,
LPPROC_THREAD_ATTRIBUTE_LIST attrs, bool attrs_init) {
if (attrs) {
if (attrs_init) {
DeleteProcThreadAttributeList(attrs);
}
free(attrs);
}
if (stdout_capture != INVALID_HANDLE_VALUE) {
CloseHandle(stdout_capture);
}
if (log != INVALID_HANDLE_VALUE) {
CloseHandle(log);
}
if (input != INVALID_HANDLE_VALUE) {
CloseHandle(input);
}
if (nul != INVALID_HANDLE_VALUE) {
CloseHandle(nul);
}
}
static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) {
char cmdline[8192];
bool built =
process->windows_cmd_payload
? cbm_build_win_cmd_payload(cmdline, sizeof(cmdline), process->bin,
process->windows_cmd_payload)
: cbm_build_win_cmdline(cmdline, sizeof(cmdline), (const char *const *)process->argv);
if (!built) {
return -1;
}
/* Everything a failure must release, so every failure leaves through
* the one exit at the end. */
HANDLE job = NULL;
HANDLE nul = INVALID_HANDLE_VALUE;
HANDLE input = INVALID_HANDLE_VALUE;
HANDLE log = INVALID_HANDLE_VALUE;
HANDLE stdout_capture = INVALID_HANDLE_VALUE;
LPPROC_THREAD_ATTRIBUTE_LIST attrs = NULL;
bool attrs_init = false;
wchar_t *wbin = cbm_utf8_to_wide(process->bin);
wchar_t *wcmdline = cbm_utf8_to_wide(cmdline);
if (!wbin || !wcmdline) {
goto fail;
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits;
ZeroMemory(&limits, sizeof(limits));
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (process->memory_limit_bytes > 0) {
limits.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_JOB_MEMORY;
limits.JobMemoryLimit = (SIZE_T)process->memory_limit_bytes;
}
job = CreateJobObjectW(NULL, NULL);
if (!job ||
!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) {
goto fail;
}
SECURITY_ATTRIBUTES security;
ZeroMemory(&security, sizeof(security));
security.nLength = sizeof(security);
security.bInheritHandle = TRUE;
nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
&security, OPEN_EXISTING, 0, NULL);
if (nul == INVALID_HANDLE_VALUE) {
goto fail;
}
if (process->stdin_file) {
wchar_t *winput = cbm_win_stdin_path(process);
/* Validate before any output can be truncated; recheck the opened
* handle rather than relying only on path attributes. */
DWORD attributes = winput ? GetFileAttributesW(winput) : INVALID_FILE_ATTRIBUTES;
if (attributes != INVALID_FILE_ATTRIBUTES &&
!(attributes &
(FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE))) {
input = CreateFileW(winput, GENERIC_READ, FILE_SHARE_READ, &security, OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL);
}
BY_HANDLE_FILE_INFORMATION input_info;
if (input == INVALID_HANDLE_VALUE || GetFileType(input) != FILE_TYPE_DISK ||
!GetFileInformationByHandle(input, &input_info) ||
(input_info.dwFileAttributes &
(FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE))) {
goto fail;
}
}
if (process->log_file) {
wchar_t *wlog = cbm_path_to_wide(process->log_file);
if (wlog) {
/* FILE_SHARE_WRITE keeps POSIX parity: on unix nothing stops a
* second producer from appending to the redirected log while the
* child holds it (supervision wrappers rely on that), and the
* log's owner-only directory is what gates who can. Without it
* the child's handle mandatory-locks every other writer out. */
log = CreateFileW(wlog, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &security,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
free(wlog);
}
if (log == INVALID_HANDLE_VALUE) {
goto fail;
}
}
if (process->stdout_file) {
wchar_t *wstdout = cbm_path_to_wide(process->stdout_file);
if (wstdout) {
stdout_capture = CreateFileW(wstdout, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
&security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
free(wstdout);
}
if (stdout_capture == INVALID_HANDLE_VALUE) {
goto fail;
}
}
HANDLE inherit[4];
SIZE_T inherit_count = 0;
inherit[inherit_count++] = nul;
if (input != INVALID_HANDLE_VALUE) {
inherit[inherit_count++] = input;
}
if (log != INVALID_HANDLE_VALUE) {
inherit[inherit_count++] = log;
}
if (stdout_capture != INVALID_HANDLE_VALUE) {
inherit[inherit_count++] = stdout_capture;
}
SIZE_T attrs_size = 0;
(void)InitializeProcThreadAttributeList(NULL, 1, 0, &attrs_size);
attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attrs_size);
attrs_init = attrs && InitializeProcThreadAttributeList(attrs, 1, 0, &attrs_size);
bool attrs_ready = attrs_init && UpdateProcThreadAttribute(
attrs, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit,
inherit_count * sizeof(inherit[0]), NULL, NULL);
if (!attrs_ready) {
goto fail;
}
STARTUPINFOEXW startup;
ZeroMemory(&startup, sizeof(startup));
startup.StartupInfo.cb = sizeof(startup);
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = input != INVALID_HANDLE_VALUE ? input : nul;
startup.StartupInfo.hStdOutput = stdout_capture != INVALID_HANDLE_VALUE
? stdout_capture
: (log != INVALID_HANDLE_VALUE ? log : nul);
startup.StartupInfo.hStdError = log != INVALID_HANDLE_VALUE ? log : nul;
startup.lpAttributeList = attrs;
PROCESS_INFORMATION child;
ZeroMemory(&child, sizeof(child));
DWORD flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP |
CREATE_NO_WINDOW;
/* Fail closed: a git child must never inherit a caller's GIT_DIR. */
wchar_t *env = process->strip_git_repo_env ? cbm_git_child_env_block() : NULL;
if (env) {
flags |= CREATE_UNICODE_ENVIRONMENT;
}
BOOL created = (!process->strip_git_repo_env || env) &&
CreateProcessW(wbin, wcmdline, NULL, NULL, TRUE, flags, (LPVOID)env, NULL,
&startup.StartupInfo, &child);
cbm_git_child_env_free(env); /* CreateProcessW copied the block into the child */
cbm_win_close_spawn_handles(nul, input, log, stdout_capture, attrs, attrs_init);
free(wbin);
free(wcmdline);
if (!created) {
CloseHandle(job);
return -1;
}
/* Assignment while suspended closes the process-creation race: no worker
* instruction executes unless all descendants are already contained. */
if (!AssignProcessToJobObject(job, child.hProcess)) {
(void)TerminateProcess(child.hProcess, 1);
(void)WaitForSingleObject(child.hProcess, 5000);
CloseHandle(child.hThread);
CloseHandle(child.hProcess);
CloseHandle(job);
return -1;
}
if (ResumeThread(child.hThread) == (DWORD)-1) {
/* KILL_ON_JOB_CLOSE terminates the still-suspended contained process. */
CloseHandle(job);
(void)WaitForSingleObject(child.hProcess, 5000);
CloseHandle(child.hThread);
CloseHandle(child.hProcess);
return -1;
}
CloseHandle(child.hThread);
process->process = child.hProcess;
process->process_id = child.dwProcessId;
process->job = job;
return 0;
fail:
cbm_win_close_spawn_handles(nul, input, log, stdout_capture, attrs, attrs_init);
if (job) {
CloseHandle(job);
}
free(wbin);
free(wcmdline);
return -1;
}
static bool cbm_win_job_active(cbm_subprocess_t *process, bool *known) {
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting;
ZeroMemory(&accounting, sizeof(accounting));
if (!QueryInformationJobObject(process->job, JobObjectBasicAccountingInformation, &accounting,
sizeof(accounting), NULL)) {
*known = false;
return true; /* fail closed: an unqueryable job is never declared quiescent */
}
*known = true;
return accounting.ActiveProcesses != 0;
}
static void cbm_win_capture_job_memory(cbm_subprocess_t *process) {
process->result.job_memory_limit_bytes = process->memory_limit_bytes;
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits;
ZeroMemory(&limits, sizeof(limits));
if (QueryInformationJobObject(process->job, JobObjectExtendedLimitInformation, &limits,
sizeof(limits), NULL)) {
process->result.peak_job_memory_bytes = (size_t)limits.PeakJobMemoryUsed;
process->result.job_memory_available = true;
}
}
static void cbm_win_begin_termination(cbm_subprocess_t *process, uint64_t now) {
if (process->termination_started) {
return;
}
process->termination_started = true;
process->termination_started_ms = now;
/* Best effort only: services and detached parents may have no shared console.
* The Job Object remains the authoritative force-containment boundary. */
(void)GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, process->process_id);
}
static void cbm_win_force_tree(cbm_subprocess_t *process, uint64_t now) {
if (process->force_sent) {
return;
}
if (process->force_started_ms == 0) {
process->force_started_ms = now;
}
if (TerminateJobObject(process->job, 1)) {
if (!process->root_reaped) {
process->root_forced = true;
}
process->result.forced = true;
process->force_sent = true;
} else {
process->containment_failed = true;
}
}
static void cbm_win_capture_root(cbm_subprocess_t *process, DWORD code) {
process->root_reaped = true;
process->result.exit_code = (int)code;
process->result.term_signal = 0;
if (process->timed_out) {
process->result.outcome = CBM_PROC_HANG;
} else if (process->root_forced ||
(cbm_subprocess_cancellation_requested(process) && code == CBM_WIN_CONTROL_C_EXIT)) {
process->result.outcome = CBM_PROC_KILLED;
} else {
process->result.outcome = cbm_proc_classify(true, (int)code, 0, false);
}
}
static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_proc_result_t *out) {
uint64_t now = cbm_now_ms();
if (!process->root_reaped) {
DWORD waited = WaitForSingleObject(process->process, 0);
if (waited == WAIT_OBJECT_0) {
DWORD code = 1;
(void)GetExitCodeProcess(process->process, &code);
cbm_win_capture_root(process, code);
} else if (waited == WAIT_FAILED) {
DWORD code = STILL_ACTIVE;
if (GetExitCodeProcess(process->process, &code) && code != STILL_ACTIVE) {
cbm_win_capture_root(process, code);
} else {
cbm_win_begin_termination(process, now);
cbm_win_force_tree(process, now);
}
}
}
bool job_known = false;
bool job_active = cbm_win_job_active(process, &job_known);
if (!process->termination_started) {
if (cbm_subprocess_cancellation_requested(process)) {
cbm_win_begin_termination(process, now);
} else if (!process->root_reaped && process->quiet_timeout_ms > 0 &&
now - process->last_activity_ms >= (uint64_t)process->quiet_timeout_ms) {
process->timed_out = true;
cbm_win_begin_termination(process, now);
} else if (process->root_reaped && job_active) {
/* Preserve the root's classification while draining escaped work. */
cbm_win_begin_termination(process, now);
}
}
if (!job_known) {
cbm_win_begin_termination(process, now);
cbm_win_force_tree(process, now);
}
if (process->termination_started && job_active && !process->force_sent &&
now - process->termination_started_ms >= (uint64_t)process->cancel_grace_ms) {
cbm_win_force_tree(process, now);
}
if (process->force_started_ms != 0 &&
now - process->force_started_ms >= CBM_SUBPROCESS_FORCE_SETTLE_MS &&
(!job_known || job_active || !process->root_reaped)) {
cbm_win_capture_job_memory(process);
return cbm_subprocess_finish_failed(process, out);
}
if (process->root_reaped && !job_active) {
cbm_win_capture_job_memory(process);
return cbm_subprocess_finish(process, out);
}
return CBM_PROC_POLL_RUNNING;
}
#else /* POSIX */
/* Transient spawn-failure retry (see the EAGAIN note in cbm_posix_spawn_apple):
* long enough to ride out a burst of process creation, short enough that a
* genuinely exhausted system still fails fast. The budget below is the single
* source of truth for both — see cbm_spawn_backoff for the resulting waits.
*
* A sanitized build needs a wider window than an ordinary one, and only a
* sanitized one does.
*
* The exponential backoff (10/20/40/80/160/320ms, ~0.6s) fixed the ordinary
* case. `subprocess_run_spawn_failure` then kept failing on `test-tsan` — twice
* on one SHA, on a PR whose diff was a shell contract and a line in test.sh, so
* causation was impossible. ThreadSanitizer runs several times slower and holds
* far more process state, so the pressure window it creates is simply longer
* than 0.6s.
*
* Raising the budget for everyone would be the wrong fix: an unsanitized
* machine that is genuinely out of capacity should still fail fast rather than
* hang for seconds. So the extra patience is scoped to the builds that need it,
* the same way the daemon announce backstop is (test_daemon_frontend.c). Three
* more doublings take the sanitized ceiling to roughly 5s. */
#if CBM_SANITIZED
enum { CBM_SPAWN_RETRY = 2, CBM_SPAWN_RETRY_ATTEMPTS = 9 };
#else
enum { CBM_SPAWN_RETRY = 2, CBM_SPAWN_RETRY_ATTEMPTS = 6 };
#endif
/* Exponential backoff, doubling from 10ms: 10, 20, 40, 80, 160, 320 — ~630ms of
* total patience on an ordinary build, and three further doublings (640, 1280,
* 2560) to roughly 5s on a sanitized one. The waits follow from
* CBM_SPAWN_RETRY_ATTEMPTS above and from the per-wait ceiling at
* cbm_spawn_backoff, rather than being listed separately here, so changing the
* budget cannot leave this description behind.
*
* The first version waited a flat 3 x 10ms, which was enough for a momentary
* dip and NOT enough for the real thing: a CI runner building and testing in
* parallel stays process-starved for hundreds of milliseconds at a stretch, and
* `subprocess_run_spawn_failure` kept failing with the retry shipped (macos-15-
* intel on a release matrix, macos-14 under ThreadSanitizer, which is itself
* slow enough to create the pressure). A fixed short delay samples the same
* congested instant repeatedly; doubling walks out of it.
*
* The ceiling is deliberate. ~0.6s is invisible next to spawning a process that
* does real work, and a machine still refusing after that is genuinely out of
* capacity — at which point failing IS the correct answer, and failing fast
* beats hanging. The sanitized ceiling is ~5s for the same reason in reverse:
* under instrumentation the starved window really does last that long, and only
* a build that already accepts a large slowdown pays for the extra wait. */
#ifdef CBM_ENABLE_TEST_SEAMS
/* Deterministic EAGAIN injection: see the header. Counts DOWN, so a test asks
* for N simulated refusals and the (N+1)th attempt proceeds for real. */
static int g_force_spawn_eagain = 0;
void cbm_subprocess_force_spawn_eagain_for_testing(int attempts) {
g_force_spawn_eagain = attempts > 0 ? attempts : 0;
}
static int g_hold_until_exit = 0;
void cbm_subprocess_hold_parent_until_child_exits_for_testing(int spawns) {
g_hold_until_exit = spawns > 0 ? spawns : 0;
}
static int g_observe_exiting = 0;
void cbm_subprocess_observe_exiting_child_for_testing(int spawns) {
g_observe_exiting = spawns > 0 ? spawns : 0;
}
int cbm_subprocess_pending_spawn_eagain_for_testing(void) {
return g_force_spawn_eagain;
}
static bool cbm_spawn_eagain_injected(void) {
if (g_force_spawn_eagain > 0) {
g_force_spawn_eagain--;
return true;
}
return false;
}
#endif
/* The bound is on a single WAIT, and 2560ms (10ms << 8) is the largest wait
* either budget produces today: 10..320 on an ordinary build, 10..2560 on a
* sanitized one. So this changes nothing now — it changes what happens next.
*
* The clamp it replaces bounded `attempt` by CBM_SPAWN_RETRY_ATTEMPTS, which no
* caller can reach: both retry loops return before passing the budget, so the
* clamp never fired and the comment claiming "the budget is the only bound
* needed" described a bound that did not exist. Doubling with nothing to stop
* it is the actual risk, and it grows fast — a budget of 12 would make the last
* wait ~20s and the total ~41s, which is precisely the "hang instead of fail
* fast" the retry was written to avoid. Flattening at the ceiling keeps a
* budget increase linear.
*
* Spelled as a shift so the value cannot overflow `long` on the way to being
* clamped. */
enum { CBM_SPAWN_BACKOFF_BASE_MS = 10, CBM_SPAWN_BACKOFF_MAX_SHIFT = 8 };
static void cbm_spawn_backoff(int attempt) {
int shift = attempt < CBM_SPAWN_BACKOFF_MAX_SHIFT ? attempt : CBM_SPAWN_BACKOFF_MAX_SHIFT;
long ms = (long)CBM_SPAWN_BACKOFF_BASE_MS << shift;
struct timespec delay = {ms / 1000L, (ms % 1000L) * 1000L * 1000L};
(void)cbm_nanosleep_full(&delay);
}
/* fork() fails with EAGAIN under the same pressure posix_spawn does, and the
* fallback path must not be less robust than the primary one. */
static pid_t cbm_fork_with_retry(void) {
/* CBM_SPAWN_RETRY_ATTEMPTS backoffs means ATTEMPTS+1 tries. Every try goes
* through the same branch — including the last — so the injection seam
* models production exactly rather than leaving a final unguarded fork() the
* tests could never reach. */
for (int attempt = 0;; attempt++) {
#ifdef CBM_ENABLE_TEST_SEAMS
if (cbm_spawn_eagain_injected()) {
if (attempt >= CBM_SPAWN_RETRY_ATTEMPTS) {
errno = EAGAIN;
return -1;
}
cbm_spawn_backoff(attempt);
continue;
}
#endif
pid_t pid = fork();
if (pid >= 0 || (errno != EAGAIN && errno != ENOMEM)) {
return pid;
}
if (attempt >= CBM_SPAWN_RETRY_ATTEMPTS) {
errno = EAGAIN;
return -1;
}
cbm_spawn_backoff(attempt);
}
}
/* Used by the fork+exec child. posix_spawn performs the same reset
* declaratively via SETSIGDEF + SETSIGMASK, but Apple still forks for the
* exec-failure fallback below, so this stays compiled everywhere. */
static void cbm_posix_reset_child_signals(void) {
struct sigaction action = {0};
action.sa_handler = SIG_DFL;
(void)sigemptyset(&action.sa_mask);
for (int sig = 1; sig < NSIG; sig++) {
if (sig != SIGKILL && sig != SIGSTOP) {
(void)sigaction(sig, &action, NULL);
}
}
sigset_t empty;
(void)sigemptyset(&empty);
(void)sigprocmask(SIG_SETMASK, &empty, NULL);
}
/* Close every descriptor >= lowfd in the fork+exec child (#1484).
*
* The portable answer is a close() per possible descriptor, which costs
* O(RLIMIT_NOFILE) syscalls: at nofile=524288 that is ~0.8 s between fork and
* exec for EVERY spawn, independent of how many descriptors are actually open.
* The kernel can do the same in one call:
* - Linux >= 5.9: close_range(lowfd, ~0U, 0). Issued as a raw syscall, not
* through the libc wrapper: the static musl release build has no wrapper
* and the glibc wrapper only exists from 2.34, while the syscall number is
* 436 on the supported x86-64 and AArch64 targets. Other architectures
* use their header's number, or the loop if no number is available.
* An older kernel answers
* ENOSYS (or EPERM under a seccomp filter that predates it) and we fall back.
* - FreeBSD/OpenBSD/NetBSD/DragonFly: closefrom(lowfd).
* - otherwise (and on fallback): the bounded close() loop.
* macOS never reaches this on its primary path: posix_spawn's
* CLOEXEC_DEFAULT does the closing (see cbm_posix_spawn_apple).
*
* Runs between fork and exec, so it is async-signal-safe: raw syscalls and
* close() only, no allocation, no stdio. Returns the strategy that did the
* work so the test seam can assert which one ran. */
#if defined(__linux__) && !defined(SYS_close_range) && (defined(__x86_64__) || defined(__aarch64__))
#define SYS_close_range 436
#endif
#ifdef CBM_ENABLE_TEST_SEAMS
static bool g_force_close_range_enosys = false;
#endif
static cbm_fd_close_strategy_t cbm_posix_close_fds_from(int lowfd, long max_fd) {
#if defined(__linux__) && defined(SYS_close_range)
bool range_unavailable = false;
#ifdef CBM_ENABLE_TEST_SEAMS
range_unavailable = g_force_close_range_enosys;
#endif
if (!range_unavailable && syscall(SYS_close_range, (unsigned int)lowfd, ~0U, 0U) == 0) {
return CBM_FD_CLOSE_RANGE;
}
#elif defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) || defined(__DragonFly__)
(void)max_fd;
closefrom(lowfd);
return CBM_FD_CLOSEFROM;
#endif
for (int fd = lowfd; fd < max_fd; fd++) {
(void)close(fd);
}
return CBM_FD_CLOSE_LOOP;
}
#ifdef CBM_ENABLE_TEST_SEAMS
void cbm_subprocess_force_close_range_enosys_for_testing(bool force) {
g_force_close_range_enosys = force;
}
cbm_fd_close_strategy_t cbm_subprocess_close_fds_from_for_testing(int lowfd, long max_fd) {
return cbm_posix_close_fds_from(lowfd, max_fd);
}
#endif
/* fork+exec child setup. On Apple this runs ONLY for the exec-failure
* fallback (see cbm_posix_spawn_apple), which preserves the documented
* "bogus binary => child exits 127" contract across platforms. */
static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int output, int error,
long max_fd) {
if (setpgid(0, 0) < 0) {
_exit(127);
}
cbm_posix_reset_child_signals();
/* Never let a worker consume the MCP transport inherited as stdin. Only
* async-signal-safe calls are used between fork and exec. */
if (input < 0 || output < 0 || error < 0 || dup2(input, STDIN_FILENO) < 0 ||
dup2(output, STDOUT_FILENO) < 0 || dup2(error, STDERR_FILENO) < 0) {
_exit(127);
}
if (input > STDERR_FILENO) {
(void)close(input);
}
if (output > STDERR_FILENO) {
(void)close(output);
}
(void)cbm_posix_close_fds_from(STDERR_FILENO + 1, max_fd);
if (process->envp) {
environ = process->envp; /* execvp passes environ to the new image */
}
/* A fixed literal tool name (for example "git" or "curl") uses the
* caller's normal PATH without introducing a shell. An explicit path
* still has execvp's exact-path semantics because it contains '/'. */
execvp(process->bin, process->argv);
_exit(127);
}
static int cbm_posix_fd_at_least_three(int fd) {
if (fd < 0 || fd > STDERR_FILENO) {
return fd;
}
#ifdef F_DUPFD_CLOEXEC
int duplicate = fcntl(fd, F_DUPFD_CLOEXEC, STDERR_FILENO + 1);
#else
int duplicate = fcntl(fd, F_DUPFD, STDERR_FILENO + 1);
if (duplicate >= 0) {
(void)fcntl(duplicate, F_SETFD, FD_CLOEXEC);
}
#endif
(void)close(fd);
return duplicate;
}
#ifdef __APPLE__
/* macOS: spawn instead of fork+exec.
*
* fork() duplicates the parent's whole address space bookkeeping, and an
* ASan-instrumented parent carries an enormous shadow mapping. Past a
* footprint threshold the child is killed (jetsam) BEFORE exec replaces the
* image, so the call fails with the child already gone (ESRCH on reap) — a
* spawn failure that looks like the launched tool crashing. The test suite hit
* exactly this: `git init` inside a fixture failed once enough suites had run
* ahead of it, and the symptom was an unrelated-looking assertion. The same
* hazard is already documented in tests/test_daemon_runtime.c, which switched
* to posix_spawn for the same reason.
*
* posix_spawn never copies the parent address space, so the footprint is
* irrelevant. Every guarantee of the fork path is preserved:
* - own process group (SETPGROUP + setpgroup(0)) — the kill-tree contract
* - default signal dispositions and an empty mask (SETSIGDEF/SETSIGMASK)
* - stdin/stdout/stderr wired to the caller's fds (adddup2)
* - every OTHER descriptor closed: CLOEXEC_DEFAULT is Apple's equivalent of
* the child's close-everything loop, and the three dup2'd fds stay open
* because dup2 clears close-on-exec.
* posix_spawnp keeps execvp's PATH semantics for a bare tool name. */
static int cbm_posix_spawn_apple(cbm_subprocess_t *process, int input, int output, int error,
pid_t *pid_out) {
/* posix_spawn is the PRIMARY path on macOS; fork+exec is only the
* exec-class fallback. Injection therefore has to live here too, or a test
* on macOS exercises nothing. */
#ifdef CBM_ENABLE_TEST_SEAMS
if (cbm_spawn_eagain_injected()) {
return CBM_SPAWN_RETRY;
}
#endif
posix_spawn_file_actions_t actions;
posix_spawnattr_t attr;
if (posix_spawn_file_actions_init(&actions) != 0) {
return -1;
}
if (posix_spawnattr_init(&attr) != 0) {
(void)posix_spawn_file_actions_destroy(&actions);
return -1;
}
sigset_t empty_mask;
sigset_t all_signals;
sigemptyset(&empty_mask);
sigfillset(&all_signals);
short flags = (short)(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK |
POSIX_SPAWN_CLOEXEC_DEFAULT);
bool configured = posix_spawnattr_setflags(&attr, flags) == 0 &&
posix_spawnattr_setpgroup(&attr, 0) == 0 &&
posix_spawnattr_setsigmask(&attr, &empty_mask) == 0 &&
posix_spawnattr_setsigdefault(&attr, &all_signals) == 0 &&
posix_spawn_file_actions_adddup2(&actions, input, STDIN_FILENO) == 0 &&
posix_spawn_file_actions_adddup2(&actions, output, STDOUT_FILENO) == 0 &&
posix_spawn_file_actions_adddup2(&actions, error, STDERR_FILENO) == 0;
pid_t pid = -1;
int rc = configured ? posix_spawnp(&pid, process->bin, &actions, &attr, process->argv,
process->envp ? process->envp : environ)
: -1;
(void)posix_spawn_file_actions_destroy(&actions);
(void)posix_spawnattr_destroy(&attr);
if (configured && rc == 0 && pid > 0) {
*pid_out = pid;
return 0;
}
/* posix_spawn reports an unusable binary itself, where fork+exec instead
* produces a child that exits 127. Callers (and tests) rely on the latter:
* "spawn_failed" means the SPAWN mechanism failed, not that the tool was
* missing. Fall back to fork+exec for exec-class errors so macOS and Linux
* classify a bogus binary identically; the ASan-fork hazard does not apply
* here, since this child exits immediately. */
if (configured && (rc == ENOENT || rc == EACCES || rc == ENOEXEC || rc == EISDIR ||
rc == ELOOP || rc == ENAMETOOLONG || rc == ENOTDIR)) {
return 1;
}
/* EAGAIN/ENOMEM are the kernel saying "not right now", not "never": the
* process table or a per-user limit is momentarily full. Reporting
* spawn_failed for that turns transient load into a user-visible error —
* a git or LSP probe failing on a busy laptop for no reason the user can
* see or act on. Retry briefly. Everything else stays a hard failure. */
if (configured && (rc == EAGAIN || rc == ENOMEM)) {
return CBM_SPAWN_RETRY;
}
return -1;
}
#endif
/* Our own unreaped child that setpgid/getpgid cannot find (ESRCH) is not
* running: it has exited, or is exiting -- macOS stops finding a child as it
* starts to exit, before waitid can report it. Wait until it is reapable;
* the wait ends with its exit, and WNOWAIT leaves it for the supervision to
* collect with its exit status. */
static bool cbm_posix_child_reapable(pid_t pid) {
for (;;) {
siginfo_t info;
memset(&info, 0, sizeof(info));
if (waitid(P_PID, (id_t)pid, &info, WEXITED | WNOWAIT) == 0) {
return info.si_pid == pid;
}
if (errno != EINTR) {
return false;
}
}
}
static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) {
int input_flags = O_RDONLY;
#ifdef O_CLOEXEC
input_flags |= O_CLOEXEC;
#endif
if (process->stdin_file) {
/* O_NONBLOCK prevents waiting for a FIFO writer before fstat rejects
* non-regular input. No output has been opened at this point. */
input_flags |= O_NONBLOCK;
#ifdef O_NOFOLLOW
input_flags |= O_NOFOLLOW;
#endif
}
int input = cbm_posix_fd_at_least_three(
open(process->stdin_file ? process->stdin_file : "/dev/null", input_flags));
if (input < 0) {
return -1;
}
if (process->stdin_file) {
struct stat input_status;
if (fstat(input, &input_status) != 0 || !S_ISREG(input_status.st_mode)) {
(void)close(input);
return -1;
}
}
const char *target = process->log_file ? process->log_file : "/dev/null";
int output_flags = O_WRONLY | O_CREAT | O_TRUNC;
#ifdef O_CLOEXEC
output_flags |= O_CLOEXEC;
#endif
#ifdef O_NOFOLLOW
output_flags |= O_NOFOLLOW;
#endif
int output = cbm_posix_fd_at_least_three(open(target, output_flags, 0600));
if (output < 0) {
(void)close(input);
return -1;
}
struct stat output_status;
if (fstat(output, &output_status) != 0 ||
(process->log_file && !S_ISREG(output_status.st_mode))) {
(void)close(input);
(void)close(output);
return -1;
}
if (process->log_file && fchmod(output, 0600) != 0) {
(void)close(input);
(void)close(output);
return -1;
}
int stdout_capture = -1;
if (process->stdout_file) {
stdout_capture =
cbm_posix_fd_at_least_three(open(process->stdout_file, output_flags, 0600));
struct stat stdout_status;
if (stdout_capture < 0 || fstat(stdout_capture, &stdout_status) != 0 ||
!S_ISREG(stdout_status.st_mode) || fchmod(stdout_capture, 0600) != 0) {
if (stdout_capture >= 0) {
(void)close(stdout_capture);
}
(void)close(input);
(void)close(output);
return -1;
}
}
int child_output = stdout_capture >= 0 ? stdout_capture : output;
long max_fd = sysconf(_SC_OPEN_MAX);
if (max_fd < 0 || max_fd > 1048576L) {
max_fd = 65536L;
}
pid_t pid = -1;
#ifdef __APPLE__
int spawn_rc = cbm_posix_spawn_apple(process, input, child_output, output, &pid);
for (int attempt = 0; spawn_rc == CBM_SPAWN_RETRY && attempt < CBM_SPAWN_RETRY_ATTEMPTS;
attempt++) {
cbm_spawn_backoff(attempt);
spawn_rc = cbm_posix_spawn_apple(process, input, child_output, output, &pid);
}
if (spawn_rc == CBM_SPAWN_RETRY) {
spawn_rc = -1; /* still exhausted after backoff: a real failure */
}
if (spawn_rc < 0) {
(void)close(input);
(void)close(output);
if (stdout_capture >= 0) {
(void)close(stdout_capture);
}
return -1;
}
if (spawn_rc > 0) { /* exec-class failure: reproduce the fork+exec 127 */
pid = cbm_fork_with_retry();
if (pid < 0) {
(void)close(input);
(void)close(output);
if (stdout_capture >= 0) {
(void)close(stdout_capture);
}
return -1;
}
if (pid == 0) {
cbm_posix_child_exec(process, input, child_output, output, max_fd);
}
}
#else
pid = cbm_fork_with_retry();
if (pid < 0) {
(void)close(input);
(void)close(output);
if (stdout_capture >= 0) {
(void)close(stdout_capture);
}
return -1;
}
if (pid == 0) {
cbm_posix_child_exec(process, input, child_output, output, max_fd);
}
#endif
(void)close(input);
(void)close(output);
if (stdout_capture >= 0) {
(void)close(stdout_capture);
}
#ifdef CBM_ENABLE_TEST_SEAMS
if (g_hold_until_exit > 0) {
g_hold_until_exit--;
siginfo_t held;
(void)waitid(P_PID, (id_t)pid, &held, WEXITED | WNOWAIT);
}
#endif
/* Parent and child both establish the group, removing scheduler-order races.
* If the child won and already execed, EACCES is accepted only after proving
* that its process group is the expected isolated one. A child that has
* EXITED, or is exiting, is a finished run, not a failed spawn: macOS no
* longer shows such an unreaped child to setpgid/getpgid (ESRCH), and on a
* loaded machine a short command (`git config`) gets there before the
* parent does. Its group was set before exec, by the spawn attributes or by
* the child itself; the parent waits until it is reapable, and the
* supervision below reaps it with its real exit status. */
bool observe_exiting = false;
#ifdef CBM_ENABLE_TEST_SEAMS
if (g_observe_exiting > 0) {
g_observe_exiting--;
observe_exiting = true;
}
#endif
bool contained = !observe_exiting && setpgid(pid, pid) == 0;
if (observe_exiting) {
errno = ESRCH;
}
if (!contained && (errno == EACCES || errno == EPERM || errno == ESRCH)) {
bool missing = errno == ESRCH;
contained = !observe_exiting && getpgid(pid) == pid;
if (!contained && (missing || errno == ESRCH)) {
contained = cbm_posix_child_reapable(pid);
}
}
if (!contained) {
(void)kill(pid, SIGKILL);
int status = 0;
for (int attempt = 0; attempt < 4; attempt++) {
if (waitpid(pid, &status, 0) >= 0 || errno != EINTR) {
break;
}
}
return -1;
}
process->pid = pid;
process->pgid = pid;
return 0;
}
static bool cbm_posix_group_active(cbm_subprocess_t *process) {
if (kill(-process->pgid, 0) == 0) {
return true;
}
return errno != ESRCH; /* EPERM/other errors fail closed as still active */
}
static void cbm_posix_begin_termination(cbm_subprocess_t *process, uint64_t now) {
if (process->termination_started) {
return;
}
process->termination_started = true;
process->termination_started_ms = now;
(void)kill(-process->pgid, SIGTERM);
}
static void cbm_posix_force_tree(cbm_subprocess_t *process, uint64_t now) {
if (process->force_sent) {
return;
}
if (process->force_started_ms == 0) {
process->force_started_ms = now;
}
if (kill(-process->pgid, SIGKILL) == 0) {
process->result.forced = true;
process->force_sent = true;
} else if (errno == ESRCH) {
process->force_sent = true;
} else {
process->containment_failed = true;
}
}
static void cbm_posix_capture_root(cbm_subprocess_t *process, int status) {
process->root_reaped = true;
if (WIFEXITED(status)) {
process->result.exit_code = WEXITSTATUS(status);
process->result.term_signal = 0;
process->result.outcome =
cbm_proc_classify(true, process->result.exit_code, 0, process->timed_out);
} else if (WIFSIGNALED(status)) {
process->result.exit_code = -1;
process->result.term_signal = WTERMSIG(status);
process->result.outcome =
cbm_proc_classify(false, -1, process->result.term_signal, process->timed_out);
} else {
process->result.exit_code = -1;
process->result.term_signal = 0;
process->result.outcome = process->timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED;
}
}
static cbm_proc_poll_t cbm_subprocess_poll_posix(cbm_subprocess_t *process,
cbm_proc_result_t *out) {
uint64_t now = cbm_now_ms();
if (!process->root_reaped) {
int status = 0;
pid_t waited = waitpid(process->pid, &status, WNOHANG);
if (waited == process->pid) {
cbm_posix_capture_root(process, status);
} else if (waited < 0 && errno != EINTR) {
/* ECHILD means another reaper consumed the status. Other permanent
* wait failures are treated the same: retain containment, stop the
* tree, and never spin forever on the failed wait operation. */
process->root_reaped = true;
process->result.outcome = process->timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED;
process->result.exit_code = -1;
process->result.term_signal = 0;
cbm_posix_begin_termination(process, now);
}
}
bool group_active = cbm_posix_group_active(process);
if (!process->termination_started) {
if (cbm_subprocess_cancellation_requested(process)) {
cbm_posix_begin_termination(process, now);
} else if (!process->root_reaped && process->quiet_timeout_ms > 0 &&
now - process->last_activity_ms >= (uint64_t)process->quiet_timeout_ms) {
process->timed_out = true;
cbm_posix_begin_termination(process, now);
} else if (process->root_reaped && group_active) {
/* A root that daemonizes children is not terminal. Preserve its exit
* classification, but drain the descendants through the same path. */
cbm_posix_begin_termination(process, now);
}
}
if (process->termination_started && group_active && !process->force_sent &&
now - process->termination_started_ms >= (uint64_t)process->cancel_grace_ms) {
cbm_posix_force_tree(process, now);
}
group_active = cbm_posix_group_active(process);
if (process->force_started_ms != 0 && group_active &&
now - process->force_started_ms >= CBM_SUBPROCESS_FORCE_SETTLE_MS) {
return cbm_subprocess_finish_failed(process, out);
}
if (process->root_reaped && !group_active) {
return cbm_subprocess_finish(process, out);
}
return CBM_PROC_POLL_RUNNING;
}
#endif /* _WIN32 */
int cbm_subprocess_spawn(const cbm_proc_opts_t *opts, cbm_subprocess_t **out) {
if (!out) {
return -1;
}
*out = NULL;
cbm_subprocess_t *process = cbm_subprocess_copy_opts(opts);
if (!process) {
return -1;
}
#ifdef _WIN32
int spawn_rc = cbm_subprocess_spawn_win(process);
#else
int spawn_rc = cbm_subprocess_spawn_posix(process);
#endif
if (spawn_rc != 0) {
cbm_subprocess_free_config(process);
return -1;
}
process->last_activity_ms = cbm_now_ms();
*out = process;
return 0;
}
cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t *out) {
if (!process) {
return CBM_PROC_POLL_ERROR;
}
int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire);
if (lifecycle == CBM_SUBPROCESS_TERMINAL) {
if (out) {
*out = process->result;
}
return CBM_PROC_POLL_TERMINAL;
}
if (lifecycle == CBM_SUBPROCESS_DRAINING) {
cbm_tail_result_t tail = cbm_subprocess_poll_log(process, true);
if (tail.status == CBM_TAIL_MORE) {
return CBM_PROC_POLL_RUNNING;
}
if (tail.status == CBM_TAIL_ERROR) {
cbm_log_error("subprocess.log_drain_failed", "reason", "io_error");
return cbm_subprocess_publish_terminal(process, out, false);
}
return cbm_subprocess_publish_terminal(process, out, true);
}
/* The one owner-thread tail batch for this public poll. Platform-specific
* reap paths never tail again, preserving the exact per-poll work cap. */
(void)cbm_subprocess_poll_log(process, false);
#ifdef _WIN32
return cbm_subprocess_poll_win(process, out);
#else
return cbm_subprocess_poll_posix(process, out);
#endif
}
bool cbm_subprocess_request_cancel(cbm_subprocess_t *process) {
if (!process) {
return false;
}
int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire);
for (;;) {
if (lifecycle == CBM_SUBPROCESS_CANCEL_REQUESTED) {
return true;
}
if (lifecycle != CBM_SUBPROCESS_ACTIVE) {
return false;
}
int desired = CBM_SUBPROCESS_CANCEL_REQUESTED;
if (atomic_compare_exchange_weak_explicit(&process->lifecycle, &lifecycle, desired,
memory_order_acq_rel, memory_order_acquire)) {
return true;
}
}
}
void cbm_subprocess_destroy(cbm_subprocess_t *process) {
if (!process || atomic_load_explicit(&process->lifecycle, memory_order_acquire) !=
CBM_SUBPROCESS_TERMINAL) {
return;
}
#ifdef _WIN32
CloseHandle(process->process);
CloseHandle(process->job);
#endif
cbm_subprocess_free_config(process);
}
int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
cbm_proc_result_t local;
if (!out) {
out = &local;
}
cbm_subprocess_result_init(out);
cbm_subprocess_t *process = NULL;
if (cbm_subprocess_spawn(opts, &process) != 0) {
return -1;
}
for (;;) {
cbm_proc_poll_t state = cbm_subprocess_poll(process, out);
if (state == CBM_PROC_POLL_TERMINAL) {
bool supervised = out->tree_quiesced && !out->supervision_failed;
cbm_subprocess_destroy(process);
return supervised ? 0 : -1;
}
/* A valid owned handle has no ERROR state; invalid-argument errors are
* rejected before this compatibility loop is entered. */
const struct timespec delay = {0, 10000000L}; /* 10 ms */
(void)cbm_nanosleep(&delay, NULL);
}
}