ADR-0004: Synchronous CommandRunner as the only process seam
Status: accepted · Date: 2026-09-29 · Decision makers: James Hebden
Context and Problem Statement
Xianyun drives other programs: helm, kubectl, kustomize, git, k3d, colima, docker, mirrord, skaffold, and sudo. Every one of those calls is a place where behaviour depends on the host, where tests need to avoid the real tool, and where a careless argument can change what runs. The Go original handled this with an interface plus mockery-generated mocks per backend. The Rust port needed a single, uniform approach.
Decision Drivers
- Unit tests must run with no helm, kubectl, or docker installed
- Argument construction is the main injection surface, so it should be reviewable in one place
- No shell: values reach argv as separate elements
- Porting the Go test suite one-for-one is easier if signatures don’t change
- Backends stay stateless; dependencies arrive through constructors (project rule in
AGENTS.md)
Considered Options
- A synchronous
CommandRunnertrait, bridged totokio::processthrough one process-wide runtime, injected asArc<dyn CommandRunner> - An
async fntrait withtokio::processthroughout - Call
std::process::Commanddirectly in each backend and test with fake binaries onPATH - Per-backend mock traits generated with
mockall
Decision Outcome
Chosen option: “Synchronous CommandRunner trait”, because it keeps every ported call site and test signature identical to Go’s and gives one mock (xianyun-testutil::MockCommandRunner, with FIFO, predicate, and repeating expectations) for the whole workspace. Option 2 is cleaner in the long run and was explicitly deferred: it would be a breaking change across every crate during the parity phase.
The trait is executor::exec::CommandRunner. Backends in xianyun-backends hold only an injected runner. TokioCommand::new(name).args(args) is the primary spawn path; six direct std::process::Command call sites bypass it (see Consequences). sh -c appears only in tests.
Consequences
Good:
- Hermetic unit tests across all backends and cluster drivers
- No shell anywhere, so metacharacters in config values don’t expand
Bad:
- Calling any runner method from inside a Tokio runtime panics (“Cannot start a runtime from within a runtime”). Nothing does today;
spawn_blockingis the workaround. - The seam is leaky. Six raw
std::process::Commandcall sites in five files bypass it:pki/truststore/nss.rs:36,68(certutil),cluster/clustertool.rs:215(k9s, kubectl, stern, tool),deploy/registrycache/process.rs:77(zot),cli/commands/editmodeprocesssupervisor.rs:221, andexec/mod.rs:509(run_sudo). Those paths can’t be mocked at this boundary and are the main source of the roughly 10 percent of lines without coverage. - Temporary kubeconfigs for helm and kubectl are written with
std::fs::writeand a predictable name (TM-KUBE-002) sudoandzotare resolved throughPATH(TM-EXEC-005)- Positional arguments are passed as given.
git clone <url>,helm pull <ref>, andkustomize build <target>have no--separator (TM-CFG-005). - If
sudoisn’t found,apply_sudologs a warning and runs the command unprivileged rather than failing - Two different shell-word splitters exist (TM-EXEC-002)
Neutral:
run_in_repositorywraps commands inmise exec --whenmiseis onPATH
Implementation Location
crates/xianyun-executor/src/exec/mod.rscrates/xianyun-testutil/src/lib.rscrates/xianyun-backends/src/lib.rs
More Information
- Retroactively documented on 2026-10-13. The date is that of the shared mock crate,
xianyun-testutil. The trait itself is a port of Go’sCommandRunner(introduced 2026-02-25); seecrates/xianyun-executor/PORT_NOTES.mdfor the architecture note. - The architecture page and
AGENTS.mddescribed the Go mockery approach until they were rewritten for Rust on 2026-10-13. - Related: NFR-002, SR-009, TM-HOSTS-001.