[M4] add exec::run subprocess wrapper

This commit is contained in:
2026-05-09 23:54:01 +00:00
parent 219254a1dd
commit 807158b8cc
6 changed files with 264 additions and 1 deletions

View File

@@ -78,6 +78,15 @@ All notable changes to cargoxx will be documented in this file.
and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source
paths emitted relative to `build/` (i.e. prefixed with `../`).
Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases.
- `cargoxx.exec`: `ExecResult`, `ExecOptions`, and
`run(program, args, opts)` — argv-only subprocess wrapper around
reproc 14.2.4. Captures stdout/stderr (or inherits stdio when
`opts.inherit_stdio` is set), supports `cwd`, `env_overrides`, and a
`timeout` (enforced via `reproc_options.deadline` so drains return
`REPROC_ETIMEDOUT` instead of blocking on long-lived streams). On
destruction reproc terminates and then kills any still-running child.
Returns `ExecToolNotFound` for `ENOENT` and `ExecCommandFailed` for
other failures including timeouts. `tests/exec_run.cpp` covers 9 cases.
- `cargoxx build --no-build` end-to-end. Reads `Cargoxx.toml`,
discovers the layout, opens the curated linkdb, resolves a `Recipe`
per manifest dep, synthesizes a fresh `Cargoxx.lock`, and writes

View File

@@ -30,6 +30,7 @@ if(CARGOXX_WERROR)
endif()
find_package(SQLite3 REQUIRED)
find_package(reproc REQUIRED)
# ----- cargoxx library: module units + implementation units -----
add_library(cargoxx STATIC)
@@ -50,6 +51,7 @@ target_sources(cargoxx
src/linkdb/overlay.cpp
src/codegen/flake.cpp
src/codegen/cmake.cpp
src/exec/subprocess.cpp
src/cli/cmd_new.cpp
src/cli/cmd_build.cpp
src/cli/run.cpp
@@ -67,7 +69,7 @@ target_sources(cargoxx
src/cli/cli.cppm
)
target_link_libraries(cargoxx PRIVATE SQLite::SQLite3)
target_link_libraries(cargoxx PRIVATE SQLite::SQLite3 reproc)
# ----- cargoxx binary -----
add_executable(cargoxx_bin src/main.cpp)

View File

@@ -1,3 +1,31 @@
export module cargoxx.exec;
import std;
import cargoxx.util;
export namespace cargoxx::exec {
struct ExecResult {
int exit_code;
std::string stdout_text;
std::string stderr_text;
};
struct ExecOptions {
std::filesystem::path cwd;
std::vector<std::pair<std::string, std::string>> env_overrides;
std::optional<std::chrono::seconds> timeout;
bool inherit_stdio = false; // true for `cargoxx run` and `cargoxx test`
};
// Spawns `program` with `args` (argv only — never a shell string), waits for
// completion, and returns its exit code plus captured stdout/stderr. With
// `opts.inherit_stdio = true`, the child's stdio is wired straight to the
// parent's; the returned ExecResult has empty stdout_text/stderr_text.
//
// Returns `ExecToolNotFound` when the program cannot be located, and
// `ExecCommandFailed` for any other start/wait failure (including timeout).
auto run(const std::string& program, const std::vector<std::string>& args,
const ExecOptions& opts = {}) -> util::Result<ExecResult>;
} // namespace cargoxx::exec

145
src/exec/subprocess.cpp Normal file
View File

@@ -0,0 +1,145 @@
module;
#include <cerrno>
#include <reproc/drain.h>
#include <reproc/reproc.h>
module cargoxx.exec;
import std;
import cargoxx.util;
namespace cargoxx::exec {
namespace {
auto string_sink_fn(REPROC_STREAM /*stream*/, const uint8_t* buffer, size_t size, void* ctx)
-> int {
if (size == 0) {
return 0;
}
auto* s = static_cast<std::string*>(ctx);
s->append(reinterpret_cast<const char*>(buffer), size);
return 0;
}
auto exec_error(util::ErrorCode code, std::string msg) -> util::Error {
return util::Error{code, std::move(msg), "", std::nullopt, std::nullopt};
}
} // namespace
auto run(const std::string& program, const std::vector<std::string>& args,
const ExecOptions& opts) -> util::Result<ExecResult> {
std::vector<const char*> argv;
argv.reserve(args.size() + 2);
argv.push_back(program.c_str());
for (const auto& a : args) {
argv.push_back(a.c_str());
}
argv.push_back(nullptr);
std::vector<std::string> env_storage;
std::vector<const char*> env_ptrs;
env_storage.reserve(opts.env_overrides.size());
for (const auto& [k, v] : opts.env_overrides) {
env_storage.push_back(std::format("{}={}", k, v));
}
env_ptrs.reserve(env_storage.size() + 1);
for (const auto& e : env_storage) {
env_ptrs.push_back(e.c_str());
}
env_ptrs.push_back(nullptr);
reproc_options ropts{};
std::string cwd_storage;
if (!opts.cwd.empty()) {
cwd_storage = opts.cwd.string();
ropts.working_directory = cwd_storage.c_str();
}
ropts.env.behavior = REPROC_ENV_EXTEND;
ropts.env.extra = env_storage.empty() ? nullptr : env_ptrs.data();
if (opts.inherit_stdio) {
ropts.redirect.in.type = REPROC_REDIRECT_PARENT;
ropts.redirect.out.type = REPROC_REDIRECT_PARENT;
ropts.redirect.err.type = REPROC_REDIRECT_PARENT;
} else {
ropts.redirect.in.type = REPROC_REDIRECT_DISCARD;
ropts.redirect.out.type = REPROC_REDIRECT_PIPE;
ropts.redirect.err.type = REPROC_REDIRECT_PIPE;
}
// The deadline applies to drain/read/write; reproc returns REPROC_ETIMEDOUT
// once it is hit. Without this, draining can outlive the wait timeout.
if (opts.timeout) {
ropts.deadline =
static_cast<int>(std::chrono::milliseconds{*opts.timeout}.count());
}
// If reproc_destroy runs while the child is still alive (e.g. after a
// timeout), gracefully terminate then kill rather than leaking the process.
ropts.stop.first = {REPROC_STOP_TERMINATE, 100};
ropts.stop.second = {REPROC_STOP_KILL, 100};
ropts.stop.third = {REPROC_STOP_NOOP, 0};
reproc_t* p = reproc_new();
if (!p) {
return std::unexpected(exec_error(util::ErrorCode::ExecCommandFailed,
"reproc_new failed (out of memory)"));
}
int rc = reproc_start(p, argv.data(), ropts);
if (rc < 0) {
reproc_destroy(p);
if (rc == -ENOENT) {
return std::unexpected(exec_error(
util::ErrorCode::ExecToolNotFound,
std::format("program not found: {}", program)));
}
return std::unexpected(exec_error(
util::ErrorCode::ExecCommandFailed,
std::format("failed to start '{}': {} (errno {})", program,
std::strerror(-rc), -rc)));
}
ExecResult result{};
if (!opts.inherit_stdio) {
reproc_sink out_sink{string_sink_fn, &result.stdout_text};
reproc_sink err_sink{string_sink_fn, &result.stderr_text};
rc = reproc_drain(p, out_sink, err_sink);
if (rc < 0 && rc != REPROC_ETIMEDOUT) {
reproc_destroy(p);
return std::unexpected(exec_error(
util::ErrorCode::ExecCommandFailed,
std::format("drain failed for '{}': {}", program, std::strerror(-rc))));
}
}
int timeout_ms = REPROC_INFINITE;
if (opts.timeout) {
timeout_ms =
static_cast<int>(std::chrono::milliseconds{*opts.timeout}.count());
}
int wait_rc = reproc_wait(p, timeout_ms);
if (wait_rc == REPROC_ETIMEDOUT) {
reproc_destroy(p);
return std::unexpected(exec_error(
util::ErrorCode::ExecCommandFailed,
std::format("'{}' timed out after {} seconds", program,
opts.timeout ? opts.timeout->count() : 0)));
}
if (wait_rc < 0) {
reproc_destroy(p);
return std::unexpected(exec_error(
util::ErrorCode::ExecCommandFailed,
std::format("wait failed for '{}': {}", program, std::strerror(-wait_rc))));
}
result.exit_code = wait_rc;
reproc_destroy(p);
return result;
}
} // namespace cargoxx::exec

View File

@@ -9,6 +9,7 @@ endfunction()
cargoxx_add_test(util_error)
cargoxx_add_test(semver_satisfies)
cargoxx_add_test(exec_run)
cargoxx_add_test(manifest_parse)
cargoxx_add_test(manifest_write)
cargoxx_add_test(layout_discovery)

78
tests/exec_run.cpp Normal file
View File

@@ -0,0 +1,78 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.exec;
import cargoxx.util;
import std;
using cargoxx::exec::ExecOptions;
using cargoxx::exec::ExecResult;
using cargoxx::exec::run;
using cargoxx::util::ErrorCode;
TEST_CASE("run captures stdout from echo", "[exec]") {
auto r = run("echo", {"hello", "world"});
REQUIRE(r.has_value());
REQUIRE(r->exit_code == 0);
REQUIRE(r->stdout_text == "hello world\n");
REQUIRE(r->stderr_text.empty());
}
TEST_CASE("run returns 0 for true", "[exec]") {
auto r = run("true", {});
REQUIRE(r.has_value());
REQUIRE(r->exit_code == 0);
}
TEST_CASE("run returns non-zero for false", "[exec]") {
auto r = run("false", {});
REQUIRE(r.has_value());
REQUIRE(r->exit_code != 0);
}
TEST_CASE("run reports ExecToolNotFound for missing program", "[exec]") {
auto r = run("definitely-not-a-real-command-xyz-12345", {});
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ExecToolNotFound);
}
TEST_CASE("run honors cwd", "[exec]") {
auto cwd = std::filesystem::temp_directory_path();
auto r = run("pwd", {}, ExecOptions{.cwd = cwd});
REQUIRE(r.has_value());
REQUIRE(r->exit_code == 0);
// pwd may print the canonical path; at minimum the result starts with /tmp
// (or whatever the temp dir's prefix is)
REQUIRE_FALSE(r->stdout_text.empty());
}
TEST_CASE("run captures stderr", "[exec]") {
auto r = run("ls", {"/this/path/does/not/exist/cargoxx-xyz"});
REQUIRE(r.has_value());
REQUIRE(r->exit_code != 0);
REQUIRE_FALSE(r->stderr_text.empty());
}
TEST_CASE("run propagates env_overrides", "[exec]") {
auto r = run("env", {}, ExecOptions{
.env_overrides = {{"CARGOXX_TEST_VAR", "lemonade"}},
});
REQUIRE(r.has_value());
REQUIRE(r->exit_code == 0);
REQUIRE(r->stdout_text.find("CARGOXX_TEST_VAR=lemonade") != std::string::npos);
}
TEST_CASE("run enforces a timeout", "[exec]") {
auto r = run("sleep", {"5"},
ExecOptions{.timeout = std::chrono::seconds{1}});
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ExecCommandFailed);
}
TEST_CASE("run handles many output bytes without deadlocking", "[exec]") {
// 64 KiB of zeros — exercises pipe-pumping past the typical 4 KiB buffer
// without producing unbounded output.
auto r = run("dd", {"if=/dev/zero", "bs=1024", "count=64", "status=none"});
REQUIRE(r.has_value());
REQUIRE(r->exit_code == 0);
REQUIRE(r->stdout_text.size() == 64 * 1024);
}