[M4] add exec::run subprocess wrapper
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
145
src/exec/subprocess.cpp
Normal 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
|
||||
@@ -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
78
tests/exec_run.cpp
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user