diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d1cde..3263671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index ec94144..7a0aee4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/exec/exec.cppm b/src/exec/exec.cppm index 70c99a2..cbc5f0a 100644 --- a/src/exec/exec.cppm +++ b/src/exec/exec.cppm @@ -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> env_overrides; + std::optional 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& args, + const ExecOptions& opts = {}) -> util::Result; + +} // namespace cargoxx::exec diff --git a/src/exec/subprocess.cpp b/src/exec/subprocess.cpp new file mode 100644 index 0000000..a66307c --- /dev/null +++ b/src/exec/subprocess.cpp @@ -0,0 +1,145 @@ +module; + +#include +#include +#include + +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(ctx); + s->append(reinterpret_cast(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& args, + const ExecOptions& opts) -> util::Result { + std::vector 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 env_storage; + std::vector 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(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(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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e568e41..f50ea33 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/exec_run.cpp b/tests/exec_run.cpp new file mode 100644 index 0000000..b051f33 --- /dev/null +++ b/tests/exec_run.cpp @@ -0,0 +1,78 @@ +#include + +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); +}