[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
|
and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source
|
||||||
paths emitted relative to `build/` (i.e. prefixed with `../`).
|
paths emitted relative to `build/` (i.e. prefixed with `../`).
|
||||||
Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases.
|
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`,
|
- `cargoxx build --no-build` end-to-end. Reads `Cargoxx.toml`,
|
||||||
discovers the layout, opens the curated linkdb, resolves a `Recipe`
|
discovers the layout, opens the curated linkdb, resolves a `Recipe`
|
||||||
per manifest dep, synthesizes a fresh `Cargoxx.lock`, and writes
|
per manifest dep, synthesizes a fresh `Cargoxx.lock`, and writes
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ if(CARGOXX_WERROR)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
find_package(SQLite3 REQUIRED)
|
find_package(SQLite3 REQUIRED)
|
||||||
|
find_package(reproc REQUIRED)
|
||||||
|
|
||||||
# ----- cargoxx library: module units + implementation units -----
|
# ----- cargoxx library: module units + implementation units -----
|
||||||
add_library(cargoxx STATIC)
|
add_library(cargoxx STATIC)
|
||||||
@@ -50,6 +51,7 @@ target_sources(cargoxx
|
|||||||
src/linkdb/overlay.cpp
|
src/linkdb/overlay.cpp
|
||||||
src/codegen/flake.cpp
|
src/codegen/flake.cpp
|
||||||
src/codegen/cmake.cpp
|
src/codegen/cmake.cpp
|
||||||
|
src/exec/subprocess.cpp
|
||||||
src/cli/cmd_new.cpp
|
src/cli/cmd_new.cpp
|
||||||
src/cli/cmd_build.cpp
|
src/cli/cmd_build.cpp
|
||||||
src/cli/run.cpp
|
src/cli/run.cpp
|
||||||
@@ -67,7 +69,7 @@ target_sources(cargoxx
|
|||||||
src/cli/cli.cppm
|
src/cli/cli.cppm
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(cargoxx PRIVATE SQLite::SQLite3)
|
target_link_libraries(cargoxx PRIVATE SQLite::SQLite3 reproc)
|
||||||
|
|
||||||
# ----- cargoxx binary -----
|
# ----- cargoxx binary -----
|
||||||
add_executable(cargoxx_bin src/main.cpp)
|
add_executable(cargoxx_bin src/main.cpp)
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
export module cargoxx.exec;
|
export module cargoxx.exec;
|
||||||
|
|
||||||
|
import std;
|
||||||
import cargoxx.util;
|
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(util_error)
|
||||||
cargoxx_add_test(semver_satisfies)
|
cargoxx_add_test(semver_satisfies)
|
||||||
|
cargoxx_add_test(exec_run)
|
||||||
cargoxx_add_test(manifest_parse)
|
cargoxx_add_test(manifest_parse)
|
||||||
cargoxx_add_test(manifest_write)
|
cargoxx_add_test(manifest_write)
|
||||||
cargoxx_add_test(layout_discovery)
|
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