Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. A synchronous CommandRunner trait, bridged to tokio::process through one process-wide runtime, injected as Arc<dyn CommandRunner>
  2. An async fn trait with tokio::process throughout
  3. Call std::process::Command directly in each backend and test with fake binaries on PATH
  4. 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_blocking is the workaround.
  • The seam is leaky. Six raw std::process::Command call 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, and exec/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::write and a predictable name (TM-KUBE-002)
  • sudo and zot are resolved through PATH (TM-EXEC-005)
  • Positional arguments are passed as given. git clone <url>, helm pull <ref>, and kustomize build <target> have no -- separator (TM-CFG-005).
  • If sudo isn’t found, apply_sudo logs a warning and runs the command unprivileged rather than failing
  • Two different shell-word splitters exist (TM-EXEC-002)

Neutral:

  • run_in_repository wraps commands in mise exec -- when mise is on PATH

Implementation Location

  • crates/xianyun-executor/src/exec/mod.rs
  • crates/xianyun-testutil/src/lib.rs
  • crates/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’s CommandRunner (introduced 2026-02-25); see crates/xianyun-executor/PORT_NOTES.md for the architecture note.
  • The architecture page and AGENTS.md described the Go mockery approach until they were rewritten for Rust on 2026-10-13.
  • Related: NFR-002, SR-009, TM-HOSTS-001.