// calogProc.c -- calog subprocess library (see calogProc.h). Spawns a child, feeds its stdin while // draining stdout/stderr so a large transfer cannot deadlock, and returns its exit code + captured // output. POSIX spawns with posix_spawn (NOT fork -- calog runs many pthreads) and multiplexes the // three pipes with one poll loop. Windows spawns with CreateProcess and, because anonymous pipes // have no poll, writes stdin on a helper thread while the main thread drains stdout/stderr. #define _GNU_SOURCE #include "calogProc.h" #include "calogInternal.h" #include #include #include #ifdef _WIN32 #include #else #include #include #include #include #include #include #include extern char **environ; #endif // Upper bound on captured stdout/stderr, so a runaway child cannot exhaust memory. #define PROC_MAX (64 * 1024 * 1024) // Transfer granularity for a single read/write while draining the child's pipes. #define PROC_CHUNK (64 * 1024) typedef struct ProcBufT { char *data; size_t len; size_t cap; } ProcBufT; #ifdef _WIN32 // Payload handed to the stdin-writer thread (see procWinStdinThread). typedef struct ProcWinStdinT { HANDLE handle; const char *bytes; size_t length; } ProcWinStdinT; #endif static int32_t procBufAppend(ProcBufT *buffer, const void *bytes, size_t length); static CalogValueT *procOpt(CalogAggT *opts, const char *name); static int32_t procResult(CalogValueT *result, int32_t exitCode, ProcBufT *out, ProcBufT *err); static int32_t procRun(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); #ifdef _WIN32 static int32_t procWinAppendArg(ProcBufT *cmd, const char *arg); static DWORD WINAPI procWinStdinThread(LPVOID param); #endif int32_t calogProcRegister(CalogT *calog) { return calogRegisterInline(calog, "procRun", procRun, NULL); } static int32_t procBufAppend(ProcBufT *buffer, const void *bytes, size_t length) { if (buffer->len + length > buffer->cap) { size_t wanted; char *grown; wanted = buffer->cap ? buffer->cap * 2 : 4096; while (wanted < buffer->len + length) { wanted *= 2; } grown = (char *)realloc(buffer->data, wanted); if (grown == NULL) { return calogErrOomE; } buffer->data = grown; buffer->cap = wanted; } memcpy(buffer->data + buffer->len, bytes, length); buffer->len += length; return calogOkE; } static CalogValueT *procOpt(CalogAggT *opts, const char *name) { CalogValueT key; CalogValueT *field; if (opts == NULL) { return NULL; } if (calogValueString(&key, name, (int64_t)strlen(name)) != calogOkE) { return NULL; } field = calogAggGet(opts, &key); calogValueFree(&key); return field; } // Build a result map { exit, stdout, stderr } and hand ownership to result. static int32_t procResult(CalogValueT *result, int32_t exitCode, ProcBufT *out, ProcBufT *err) { CalogAggT *map; int32_t status; status = calogAggCreate(&map, calogMapE); if (status != calogOkE) { return calogFail(result, status, "procRun: out of memory"); } status = calogMapSetInt(map, "exit", exitCode); if (status == calogOkE) { status = calogMapSetStr(map, "stdout", out->data ? out->data : "", (int64_t)out->len); } if (status == calogOkE) { status = calogMapSetStr(map, "stderr", err->data ? err->data : "", (int64_t)err->len); } if (status != calogOkE) { calogAggFree(map); return calogFail(result, status, "procRun: failed to build the result"); } calogValueAgg(result, map); return calogOkE; } #ifdef _WIN32 // Spawn a child with CreateProcess. argvList is quoted into a single command line; opts may carry a // stdin string, a cwd, and an env map. stdin is written on a helper thread (anonymous pipes have no // poll, and a blocking write would otherwise deadlock the stdout/stderr drain done here). Output is // capped at PROC_MAX. Returns { exit, stdout, stderr }; exit is the process exit code. Build-verified // via the zig Windows cross build; not run-verified (no Windows host here). static int32_t procRun(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { STARTUPINFOA si; PROCESS_INFORMATION pi; SECURITY_ATTRIBUTES sa; ProcWinStdinT stdinJob; ProcBufT cmd; ProcBufT envBlock; ProcBufT outBuf; ProcBufT errBuf; HANDLE inRead; HANDLE inWrite; HANDLE outRead; HANDLE outWrite; HANDLE errRead; HANDLE errWrite; HANDLE stdinThread; const char *stdinBytes; const char *cwd; char *envArg; CalogValueT *field; int64_t stdinLen; int64_t argListCount; int64_t i; int32_t status; DWORD exitStatus; bool outOpen; bool errOpen; (void)userData; calogValueNil(result); if (argCount < 1 || argCount > 2 || args[0].type != calogAggE || calogAggIsKeyed(args[0].as.agg)) { return calogFail(result, calogErrArgE, "procRun expects (argvList [, opts])"); } if (argCount == 2 && (args[1].type != calogAggE || !calogAggIsKeyed(args[1].as.agg))) { return calogFail(result, calogErrArgE, "procRun: opts must be a map"); } argListCount = args[0].as.agg->arrayCount; if (argListCount < 1) { return calogFail(result, calogErrArgE, "procRun: argvList must have at least the program"); } // Quote each argv element into one command line (CreateProcess takes a single string). memset(&cmd, 0, sizeof(cmd)); for (i = 0; i < argListCount; i++) { if (args[0].as.agg->array[i].type != calogStringE) { free(cmd.data); return calogFail(result, calogErrArgE, "procRun: every argv element must be a string"); } if ((i > 0 && procBufAppend(&cmd, " ", 1) != calogOkE) || procWinAppendArg(&cmd, args[0].as.agg->array[i].as.s.bytes) != calogOkE) { free(cmd.data); return calogFail(result, calogErrOomE, "procRun: out of memory"); } } if (procBufAppend(&cmd, "\0", 1) != calogOkE) { free(cmd.data); return calogFail(result, calogErrOomE, "procRun: out of memory"); } stdinBytes = NULL; stdinLen = 0; cwd = NULL; envArg = NULL; memset(&envBlock, 0, sizeof(envBlock)); if (argCount == 2) { field = procOpt(args[1].as.agg, "stdin"); if (field != NULL && field->type == calogStringE) { stdinBytes = field->as.s.bytes; stdinLen = field->as.s.length; } field = procOpt(args[1].as.agg, "cwd"); if (field != NULL && field->type == calogStringE) { cwd = field->as.s.bytes; } field = procOpt(args[1].as.agg, "env"); if (field != NULL && field->type == calogAggE && calogAggIsKeyed(field->as.agg)) { CalogAggT *envMap; int64_t e; envMap = field->as.agg; for (e = 0; e < envMap->pairCount; e++) { if (envMap->pairs[e].key.type != calogStringE || envMap->pairs[e].value.type != calogStringE) { continue; } if (procBufAppend(&envBlock, envMap->pairs[e].key.as.s.bytes, (size_t)envMap->pairs[e].key.as.s.length) != calogOkE || procBufAppend(&envBlock, "=", 1) != calogOkE || procBufAppend(&envBlock, envMap->pairs[e].value.as.s.bytes, (size_t)envMap->pairs[e].value.as.s.length) != calogOkE || procBufAppend(&envBlock, "\0", 1) != calogOkE) { free(cmd.data); free(envBlock.data); return calogFail(result, calogErrOomE, "procRun: out of memory"); } } // An environment block is terminated by a final extra NUL (a double NUL overall). if (procBufAppend(&envBlock, "\0", 1) != calogOkE) { free(cmd.data); free(envBlock.data); return calogFail(result, calogErrOomE, "procRun: out of memory"); } envArg = envBlock.data; } } // Three inheritable pipes; the parent's own ends are made non-inheritable so the child cannot // keep them open (which would keep our reads from ever seeing end-of-file). sa.nLength = sizeof(sa); sa.bInheritHandle = TRUE; sa.lpSecurityDescriptor = NULL; // Create the three pipes one at a time so a partial failure can close the ends already opened // (a single short-circuited guard would leak them). if (!CreatePipe(&inRead, &inWrite, &sa, 0)) { free(cmd.data); free(envBlock.data); return calogFail(result, calogErrArgE, "procRun: could not create pipes"); } if (!CreatePipe(&outRead, &outWrite, &sa, 0)) { CloseHandle(inRead); CloseHandle(inWrite); free(cmd.data); free(envBlock.data); return calogFail(result, calogErrArgE, "procRun: could not create pipes"); } if (!CreatePipe(&errRead, &errWrite, &sa, 0)) { CloseHandle(inRead); CloseHandle(inWrite); CloseHandle(outRead); CloseHandle(outWrite); free(cmd.data); free(envBlock.data); return calogFail(result, calogErrArgE, "procRun: could not create pipes"); } SetHandleInformation(inWrite, HANDLE_FLAG_INHERIT, 0); SetHandleInformation(outRead, HANDLE_FLAG_INHERIT, 0); SetHandleInformation(errRead, HANDLE_FLAG_INHERIT, 0); memset(&si, 0, sizeof(si)); si.cb = sizeof(si); si.dwFlags = STARTF_USESTDHANDLES; si.hStdInput = inRead; si.hStdOutput = outWrite; si.hStdError = errWrite; memset(&pi, 0, sizeof(pi)); if (!CreateProcessA(NULL, cmd.data, NULL, NULL, TRUE, 0, envArg, cwd, &si, &pi)) { CloseHandle(inRead); CloseHandle(inWrite); CloseHandle(outRead); CloseHandle(outWrite); CloseHandle(errRead); CloseHandle(errWrite); free(cmd.data); free(envBlock.data); return calogFail(result, calogErrArgE, "procRun: CreateProcess failed"); } free(cmd.data); free(envBlock.data); // The child owns the far ends now; close ours so end-of-file becomes observable. CloseHandle(inRead); CloseHandle(outWrite); CloseHandle(errWrite); stdinThread = NULL; if (stdinLen > 0) { stdinJob.handle = inWrite; stdinJob.bytes = stdinBytes; stdinJob.length = (size_t)stdinLen; stdinThread = CreateThread(NULL, 0, procWinStdinThread, &stdinJob, 0, NULL); } if (stdinThread == NULL) { CloseHandle(inWrite); // nothing to send, or the thread could not start } memset(&outBuf, 0, sizeof(outBuf)); memset(&errBuf, 0, sizeof(errBuf)); status = calogOkE; outOpen = true; errOpen = true; while (status == calogOkE && (outOpen || errOpen)) { bool didRead; HANDLE pipes[2]; bool *open[2]; ProcBufT *bufs[2]; int p; didRead = false; pipes[0] = outRead; pipes[1] = errRead; open[0] = &outOpen; open[1] = &errOpen; bufs[0] = &outBuf; bufs[1] = &errBuf; for (p = 0; p < 2; p++) { DWORD avail; DWORD got; char chunk[PROC_CHUNK]; if (!*open[p]) { continue; } if (!PeekNamedPipe(pipes[p], NULL, 0, NULL, &avail, NULL)) { *open[p] = false; // broken pipe: the child closed this stream and it is drained continue; } if (avail == 0) { continue; // open but idle; the Sleep below yields before we retry } if (!ReadFile(pipes[p], chunk, (avail > PROC_CHUNK) ? PROC_CHUNK : avail, &got, NULL) || got == 0) { *open[p] = false; continue; } if (bufs[p]->len + got > PROC_MAX || procBufAppend(bufs[p], chunk, got) != calogOkE) { status = calogFail(result, calogErrRangeE, (p == 0) ? "procRun: stdout exceeds the size cap" : "procRun: stderr exceeds the size cap"); break; } didRead = true; } if (status == calogOkE && !didRead && (outOpen || errOpen)) { Sleep(1); } } if (status != calogOkE) { TerminateProcess(pi.hProcess, 1); } WaitForSingleObject(pi.hProcess, INFINITE); GetExitCodeProcess(pi.hProcess, &exitStatus); if (stdinThread != NULL) { WaitForSingleObject(stdinThread, INFINITE); CloseHandle(stdinThread); } CloseHandle(outRead); CloseHandle(errRead); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); if (status != calogOkE) { free(outBuf.data); free(errBuf.data); return status; } status = procResult(result, (int32_t)exitStatus, &outBuf, &errBuf); free(outBuf.data); free(errBuf.data); return status; } // Append arg to cmd as one command-line token, quoted per the CommandLineToArgvW rules: a run of // backslashes is doubled only when it precedes a double quote or ends the (quoted) token, and the // token is wrapped in quotes when it is empty or contains whitespace or a quote. static int32_t procWinAppendArg(ProcBufT *cmd, const char *arg) { size_t len; size_t i; bool quote; len = strlen(arg); quote = (len == 0); for (i = 0; i < len && !quote; i++) { if (arg[i] == ' ' || arg[i] == '\t' || arg[i] == '\n' || arg[i] == '\v' || arg[i] == '"') { quote = true; } } if (!quote) { return procBufAppend(cmd, arg, len); } if (procBufAppend(cmd, "\"", 1) != calogOkE) { return calogErrOomE; } i = 0; while (i < len) { size_t slashes; size_t s; slashes = 0; while (i < len && arg[i] == '\\') { slashes++; i++; } if (i == len) { slashes *= 2; // trailing backslashes precede the closing quote } else if (arg[i] == '"') { slashes = slashes * 2 + 1; // backslashes then the escaped quote } for (s = 0; s < slashes; s++) { if (procBufAppend(cmd, "\\", 1) != calogOkE) { return calogErrOomE; } } if (i < len) { if (procBufAppend(cmd, &arg[i], 1) != calogOkE) { return calogErrOomE; } i++; } } return procBufAppend(cmd, "\"", 1); } // Write the whole stdin payload, then close the pipe. Runs on its own thread so a blocking WriteFile // on a full pipe cannot stall the main thread's stdout/stderr drain. static DWORD WINAPI procWinStdinThread(LPVOID param) { ProcWinStdinT *job; size_t pos; job = (ProcWinStdinT *)param; pos = 0; while (pos < job->length) { DWORD wrote; DWORD chunk; chunk = (job->length - pos > PROC_CHUNK) ? PROC_CHUNK : (DWORD)(job->length - pos); if (!WriteFile(job->handle, job->bytes + pos, chunk, &wrote, NULL) || wrote == 0) { break; // the child closed its stdin (or exited); stop feeding it } pos += wrote; } CloseHandle(job->handle); return 0; } #else static int32_t procRun(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { posix_spawn_file_actions_t actions; ProcBufT outBuf; ProcBufT errBuf; char **argv; char **builtEnv; char **envp; const char *stdinBytes; const char *cwd; CalogValueT *field; int64_t stdinLen; int64_t stdinPos; int64_t argCountList; int64_t i; int inPipe[2]; int outPipe[2]; int errPipe[2]; int spawnRc; int waitStatus; int32_t exitCode; int32_t status; pid_t pid; bool actionsReady; (void)userData; calogValueNil(result); if (argCount < 1 || argCount > 2 || args[0].type != calogAggE || calogAggIsKeyed(args[0].as.agg)) { return calogFail(result, calogErrArgE, "procRun expects (argvList [, opts])"); } if (argCount == 2 && (args[1].type != calogAggE || !calogAggIsKeyed(args[1].as.agg))) { return calogFail(result, calogErrArgE, "procRun: opts must be a map"); } argCountList = args[0].as.agg->arrayCount; if (argCountList < 1) { return calogFail(result, calogErrArgE, "procRun: argvList must have at least the program"); } argv = (char **)calloc((size_t)argCountList + 1, sizeof(char *)); if (argv == NULL) { return calogFail(result, calogErrOomE, "procRun: out of memory"); } for (i = 0; i < argCountList; i++) { if (args[0].as.agg->array[i].type != calogStringE) { free(argv); return calogFail(result, calogErrArgE, "procRun: every argv element must be a string"); } argv[i] = args[0].as.agg->array[i].as.s.bytes; } stdinBytes = NULL; stdinLen = 0; cwd = NULL; builtEnv = NULL; envp = environ; if (argCount == 2) { field = procOpt(args[1].as.agg, "stdin"); if (field != NULL && field->type == calogStringE) { stdinBytes = field->as.s.bytes; stdinLen = field->as.s.length; } field = procOpt(args[1].as.agg, "cwd"); if (field != NULL && field->type == calogStringE) { cwd = field->as.s.bytes; } field = procOpt(args[1].as.agg, "env"); if (field != NULL && field->type == calogAggE && calogAggIsKeyed(field->as.agg)) { CalogAggT *envMap; int64_t envCount; int64_t e; envMap = field->as.agg; envCount = envMap->pairCount; builtEnv = (char **)calloc((size_t)envCount + 1, sizeof(char *)); if (builtEnv == NULL) { free(argv); return calogFail(result, calogErrOomE, "procRun: out of memory"); } for (e = 0; e < envCount; e++) { const char *k; const char *v; size_t klen; size_t vlen; if (envMap->pairs[e].key.type != calogStringE || envMap->pairs[e].value.type != calogStringE) { continue; } k = envMap->pairs[e].key.as.s.bytes; v = envMap->pairs[e].value.as.s.bytes; klen = (size_t)envMap->pairs[e].key.as.s.length; vlen = (size_t)envMap->pairs[e].value.as.s.length; builtEnv[e] = (char *)malloc(klen + vlen + 2); if (builtEnv[e] == NULL) { continue; } memcpy(builtEnv[e], k, klen); builtEnv[e][klen] = '='; memcpy(builtEnv[e] + klen + 1, v, vlen); builtEnv[e][klen + 1 + vlen] = '\0'; } envp = builtEnv; } } if (pipe(inPipe) != 0 || pipe(outPipe) != 0 || pipe(errPipe) != 0) { free(argv); if (builtEnv != NULL) { for (i = 0; builtEnv[i] != NULL; i++) { free(builtEnv[i]); } free(builtEnv); } return calogFail(result, calogErrArgE, "procRun: could not create pipes"); } posix_spawn_file_actions_init(&actions); actionsReady = true; if (cwd != NULL) { posix_spawn_file_actions_addchdir_np(&actions, cwd); } posix_spawn_file_actions_adddup2(&actions, inPipe[0], 0); posix_spawn_file_actions_adddup2(&actions, outPipe[1], 1); posix_spawn_file_actions_adddup2(&actions, errPipe[1], 2); posix_spawn_file_actions_addclose(&actions, inPipe[1]); posix_spawn_file_actions_addclose(&actions, outPipe[0]); posix_spawn_file_actions_addclose(&actions, errPipe[0]); spawnRc = posix_spawnp(&pid, argv[0], &actions, NULL, argv, envp); posix_spawn_file_actions_destroy(&actions); (void)actionsReady; // Parent keeps the outer ends: write inPipe[1], read outPipe[0]/errPipe[0]. close(inPipe[0]); close(outPipe[1]); close(errPipe[1]); free(argv); if (builtEnv != NULL) { for (i = 0; builtEnv[i] != NULL; i++) { free(builtEnv[i]); } free(builtEnv); } if (spawnRc != 0) { close(inPipe[1]); close(outPipe[0]); close(errPipe[0]); return calogFail(result, calogErrArgE, strerror(spawnRc)); } fcntl(inPipe[1], F_SETFL, fcntl(inPipe[1], F_GETFL, 0) | O_NONBLOCK); fcntl(outPipe[0], F_SETFL, fcntl(outPipe[0], F_GETFL, 0) | O_NONBLOCK); fcntl(errPipe[0], F_SETFL, fcntl(errPipe[0], F_GETFL, 0) | O_NONBLOCK); memset(&outBuf, 0, sizeof(outBuf)); memset(&errBuf, 0, sizeof(errBuf)); stdinPos = 0; if (stdinLen == 0) { close(inPipe[1]); inPipe[1] = -1; } status = calogOkE; while (inPipe[1] >= 0 || outPipe[0] >= 0 || errPipe[0] >= 0) { struct pollfd fds[3]; int nfds; int inIdx; int outIdx; int errIdx; nfds = 0; inIdx = -1; outIdx = -1; errIdx = -1; if (inPipe[1] >= 0) { fds[nfds].fd = inPipe[1]; fds[nfds].events = POLLOUT; inIdx = nfds++; } if (outPipe[0] >= 0) { fds[nfds].fd = outPipe[0]; fds[nfds].events = POLLIN; outIdx = nfds++; } if (errPipe[0] >= 0) { fds[nfds].fd = errPipe[0]; fds[nfds].events = POLLIN; errIdx = nfds++; } if (poll(fds, (nfds_t)nfds, -1) < 0) { if (errno == EINTR) { continue; } status = calogFail(result, calogErrArgE, strerror(errno)); break; } if (inIdx >= 0 && (fds[inIdx].revents & (POLLOUT | POLLERR | POLLHUP))) { ssize_t wrote; wrote = write(inPipe[1], stdinBytes + stdinPos, (size_t)(stdinLen - stdinPos)); if (wrote > 0) { stdinPos += wrote; } if (wrote < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { close(inPipe[1]); inPipe[1] = -1; } else if (stdinPos >= stdinLen) { close(inPipe[1]); inPipe[1] = -1; } } if (outIdx >= 0 && (fds[outIdx].revents & (POLLIN | POLLERR | POLLHUP))) { char chunk[PROC_CHUNK]; ssize_t got; got = read(outPipe[0], chunk, sizeof(chunk)); if (got > 0) { if (outBuf.len + (size_t)got > PROC_MAX || procBufAppend(&outBuf, chunk, (size_t)got) != calogOkE) { status = calogFail(result, calogErrRangeE, "procRun: stdout exceeds the size cap"); close(outPipe[0]); outPipe[0] = -1; } } else if (got == 0 || (got < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) { close(outPipe[0]); outPipe[0] = -1; } } if (errIdx >= 0 && (fds[errIdx].revents & (POLLIN | POLLERR | POLLHUP))) { char chunk[PROC_CHUNK]; ssize_t got; got = read(errPipe[0], chunk, sizeof(chunk)); if (got > 0) { if (errBuf.len + (size_t)got > PROC_MAX || procBufAppend(&errBuf, chunk, (size_t)got) != calogOkE) { status = calogFail(result, calogErrRangeE, "procRun: stderr exceeds the size cap"); close(errPipe[0]); errPipe[0] = -1; } } else if (got == 0 || (got < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) { close(errPipe[0]); errPipe[0] = -1; } } if (status != calogOkE) { break; } } if (inPipe[1] >= 0) { close(inPipe[1]); } if (outPipe[0] >= 0) { close(outPipe[0]); } if (errPipe[0] >= 0) { close(errPipe[0]); } // If we aborted early (e.g. the output cap was exceeded), the child may still be running and // blocked writing to the pipe ends we just closed; kill it so waitpid cannot hang forever. if (status != calogOkE) { kill(pid, SIGKILL); } while (waitpid(pid, &waitStatus, 0) < 0 && errno == EINTR) { continue; } if (status != calogOkE) { free(outBuf.data); free(errBuf.data); return status; } if (WIFEXITED(waitStatus)) { exitCode = (int32_t)WEXITSTATUS(waitStatus); } else if (WIFSIGNALED(waitStatus)) { exitCode = -(int32_t)WTERMSIG(waitStatus); } else { exitCode = -1; } status = procResult(result, exitCode, &outBuf, &errBuf); free(outBuf.data); free(errBuf.data); return status; } #endif