Upgrading processkit

Per-version notes for consumers moving their dependency forward: what breaks, who it affects, and the exact change to make. The CHANGELOG is the full record; this page is the "I depend on it, what do I do" view.

Versioning. From 1.0.0 onward processkit follows Semantic Versioning: the public API is stable, and any breaking change lands only in a new major version, so 2.x upgrades are backward-compatible. The default Cargo requirement processkit = "2" already does the right thing — it allows 2.* but not 3.0. Skim the relevant section here before each major bump. (The mock feature's mockall-generated expect_* surface stays semver-exempt — it tracks the mockall version.)

2.0.0 (from 1.x)

The 2.0 line lands a batch of breaking changes accumulated over the 2.0-series minors (2.1.0/2.1.1). Most are caught by the compiler, but two are behavioral rather than structural — output_bytes's new byte cap and RecordReplayRunner's dropped cwd match — and are worth a deliberate read even if your build still passes after the bump.

Error variants are now #[non_exhaustive]

Every data-carrying variant of ErrorExit, Timeout, Signalled, Spawn, NotFound, Parse, OutputTooLarge, Stdin, and ResourceLimit (limits feature) — is now individually #[non_exhaustive]. A struct-literal construction or a field-exhaustive match/destructuring of any of these variants outside the crate no longer compiles.

Affected if you build one of these variants directly (e.g. in a custom ProcessRunner double or cassette replay) or match on one without a trailing ... The symptom is a compiler error like "cannot construct ... due to private fields" or "pattern does not mention field ...".

Fix:

  • Add .. to every field pattern: Error::Exit { program, code, .. } => ....
  • Read fields through the existing accessors instead of destructuring — program(), stdout()/stderr()/combined(), code(), signal(), is_*().
  • Build a variant through the new #[doc(hidden)] (but pub, semver-covered) constructors instead of a struct literal: Error::exit(program, code, stdout, stderr), Error::timeout(program, timeout, stdout, stderr), Error::signalled(program, signal, stdout, stderr), Error::spawn(program, io_error), Error::not_found(program, searched), Error::stdin(program, io_error). Error::parse(program, message) -> Error::Parse is the one constructor left on the documented surface — building an Error::Parse from your own output parser is a normal production path, not just a test-doubling convenience.

Error::Exit/Timeout/Signalled carry the raw stdout bytes

Each of the three gains a field, stdout_bytes: Option<Vec<u8>>. Since all three are #[non_exhaustive] (see above), this alone breaks nothing by itself — it only matters if you were already matching one of these variants; follow the fix above. Read the bytes through the new accessor: Error::stdout_bytes(&self) -> Option<&[u8]>. It's Some only for an error built over output_bytes (e.g. output_bytes().await?.ensure_success()?); every text-path error (output_string/run/checked/…) reports None — the decoded stdout string there is already the complete capture.

Error::ResourceLimit restructured again

(limits feature.) The 1.0 shape, Error::ResourceLimit { message: String }, is now Error::ResourceLimit { kind: LimitKind, reason: LimitReason, detail: String }kind is Memory / Processes / Cpu (which limit), reason is Invalid / Unsupported / Unenforceable (why), and detail is a human-readable message replacing the old message field 1:1.

Fix a match on the old field — Error::ResourceLimit { message } => ... no longer compiles (the field is gone, and the variant is #[non_exhaustive] besides, see above). Either match with .. and read detail, or — better — classify without parsing English at all via the new accessors: Error::limit_kind(&self) -> Option<LimitKind> / Error::limit_reason(&self) -> Option<LimitReason>. reason reflects a real backend signal: Unsupported when the platform has no whole-tree containment mechanism at all, Unenforceable when a capable mechanism exists but this particular request was rejected, Invalid for a nonsensical value caught before the OS is ever touched.

Field and builder renames

BeforeAfterNotes
Error::OutputTooLarge { line_limit, byte_limit, .. }Error::OutputTooLarge { max_lines, max_bytes, .. }matches the OutputBufferPolicy knobs they report
ResourceLimits::memory_maxResourceLimits::max_memorylimits feature; matches max_processes's word order
ProcessGroupOptions::memory_max(bytes)ProcessGroupOptions::max_memory(bytes)same rename, on the builder

ProcessResult::output_contains_any takes any string collection

Was fn output_contains_any(&self, needles: &[&str]) -> bool, now fn output_contains_any(&self, needles: impl IntoIterator<Item = impl AsRef<str>>) -> bool. Backward-compatible for most call sites — a &[&str] slice still works, since &[&str] implements IntoIterator<Item = &&str> and &&str: AsRef<str> — and a bare array literal that previously needed an explicit & (output_contains_any(&["a", "b"])) now also works without it (output_contains_any(["a", "b"])); a Vec<String> or any other IntoIterator<Item = impl AsRef<str>> now passes directly too, where it previously needed collecting into a slice first. These are all additive, not breaking. The one call shape that stops compiling: a single bare &str passed where &[&str] previously coerced through an implicit reborrow — wrap it in an array instead: output_contains_any([needle]).

Removed: two deprecated aliases and a duplicated field

  • ProcessGroup::terminate_all (deprecated since 1.1.0) is removed — use ProcessGroup::kill_all.
  • RunProfile::avg_cpu (deprecated since 1.1.0) is removed — use RunProfile::avg_cpu_cores.
  • RunProfile::exit_code (the field) is removed — it duplicated outcome.code(); use RunProfile::code() -> Option<i32> instead of profile.exit_code.

Encoding and StreamExt move into processkit::prelude

The flat crate-root re-exports of two upstream 0.x dependencies' vocabulary types — Encoding (from encoding_rs) and StreamExt (from tokio-stream) — moved behind a new processkit::prelude module.

Fix — update the use:

BeforeAfter
use processkit::Encoding;use processkit::prelude::Encoding;
use processkit::StreamExt;use processkit::prelude::StreamExt;

This keeps use processkit::* from pulling in a StreamExt trait that collides with futures::StreamExt, and contains a future 0.x major bump of either dependency to the prelude module instead of the whole crate surface.

output_bytes now enforces the OutputBufferPolicy byte cap

output_bytes now honors OutputBufferPolicy::max_bytes on its raw stdout capture — previously the byte ceiling applied only to the line-pumped stderr path, not to output_bytes's own stdout collection. Affected if you set with_max_bytes(..) (or OutputBufferPolicy::bounded(..)/fail_loud(..)) and called output_bytes expecting an unbounded stdout capture regardless: past the cap, OverflowMode::Error now errors with Error::OutputTooLarge (max_lines: None — raw bytes have no lines), and the drop modes now bound the retained bytes to a head/tail and set ProcessResult::truncated. The default (no byte cap, OutputBufferPolicy::unbounded()) is unchanged — an output_bytes call with the default policy still captures everything, same as before.

RecordReplayRunner cassettes no longer match on cwd

(record feature.) A cassette entry recorded from one absolute working directory now replays against the same invocation run from a different one, instead of Error::CassetteMissing — the main blocker to recording on one machine and replaying on another (a dev box vs. CI vs. a teammate's checkout). cwd is still stored on each entry, verbatim, for visibility; it just no longer discriminates two otherwise-identical recorded runs. The on-disk cassette format revision bumped to 3, but this is not a compatibility gate — a cassette written by a previous build still loads and replays fine, unchanged. The only behavior change: if your test suite relied on two entries that differed only in cwd to answer two different invocations, they now collide and the first-recorded entry answers for both — give such entries a distinguishing argument instead.

1.0.0 (from 0.11.x)

A few breaking changes, all caught by the compiler — if it builds after the bump, you're done.

OutputLine.text is now an accessor

OutputLine (the per-line payload of RunningProcess::output_events) no longer exposes text as a public field — read it via line.text() -> &str (or line.into_text() -> String to take ownership). This frees the line representation to evolve. Fix: line.textline.text().

Error::ResourceLimit is now a struct variant

Error::ResourceLimit(String) became Error::ResourceLimit { message: String } (parity with the other rich variants, room for structured detail later). Fix a match Error::ResourceLimit(m)Error::ResourceLimit { message: m }. (Only relevant with the limits feature.)

This was the 1.0 shape only, kept here for historical context. 2.0 restructured the variant again — the current fields are { kind: LimitKind, reason: LimitReason, detail: String }, not { message: String }; see the 2.0.0 section below.

The text-capture verb is renamed outputoutput_string

The verb that runs to completion and returns the full ProcessResult<String> is now spelled output_string on every layer, matching output_bytes (and the spelling Command/Pipeline/RunningProcess already used). Two reasons: the same operation no longer has two names depending on the type, and a bare output clashed with std::process::Command::output, which returns bytes — the explicit name removes that footgun.

Affected if you call ProcessRunner::output, CliClient::output, the free fn processkit::output, or implement a custom ProcessRunner / use MockRunner. The symptom is a build error like "no method named output" / "cannot find function output in crate processkit".

Fix — rename the calls (mechanical):

BeforeAfter
runner.output(&cmd) / client.output(args)runner.output_string(&cmd) / client.output_string(args)
processkit::output(prog, args)processkit::output_string(prog, args)
impl ProcessRunner { async fn output(..) }async fn output_string(..) (the required method)
mock.expect_output()mock.expect_output_string()

output_bytes is unchanged, and Command/Pipeline/RunningProcess callers need no change (those already used output_string).

0.11.0 (from 0.10.x)

Two breaking changes, both small and caught by the compiler — if it builds after the bump, you're done. Plus one internal fix that needs no action.

1. stats is now opt-in — a Cargo.toml change

The default feature set is now just process-control; stats is no longer on by default. (It is the one feature carrying an extra build dependency — the Windows ProcessStatus FFI used solely for the peak-memory readout — and it gates a specialized metrics surface the core never needs.)

Affected if you use any metrics API: ProcessGroup::stats / ProcessGroupStats, RunningProcess::cpu_time / peak_memory_bytes, or RunProfile / RunningProcess::profile. The symptom is a build error like "no method named stats / cpu_time / peak_memory_bytes / profile" or "cannot find type ProcessGroupStats / RunProfile".

Fix — add the feature:

[dependencies]
processkit = { version = "0.11", features = ["stats"] }

If you already enable limits, do nothinglimits still implies stats.

If you don't use metrics: nothing to do. Your default build is now slightly leaner (no Windows ProcessStatus dependency).

2. OutputEvent carries OutputLine — a code change

Affects only callers of RunningProcess::output_events (the ordered lifecycle+output event stream). The per-line payload changed from a bare String to a #[non_exhaustive] OutputLine struct with a public text field.

Before:

#![allow(unused)]
fn main() {
use processkit::OutputEvent;

while let Some(ev) = events.next().await {
    match ev {
        OutputEvent::Stdout(s) => println!("out: {s}"),
        OutputEvent::Stderr(s) => eprintln!("err: {s}"),
        _ => {}
    }
}
}

After — read line.text (in 1.0 this becomes line.text(); see the 1.0.0 section above):

#![allow(unused)]
fn main() {
match ev {
    OutputEvent::Stdout(line) => println!("out: {}", line.text),
    OutputEvent::Stderr(line) => eprintln!("err: {}", line.text),
    _ => {}
}
}

Or, when you don't care which stream produced the line, use the new accessor:

#![allow(unused)]
fn main() {
if let Some(text) = ev.text() {
    println!("{text}");
}
}

OutputLine is #[non_exhaustive]: you receive it from the crate and read its fields — you don't construct it, and a match on it should use ... The change exists to reserve room for per-line metadata (e.g. a timestamp or a monotonic line index) in a later release without another break.

3. Cancel-precedence fix ("Issue 7") — no action

A run that reaps on its own is no longer at risk of being misreported as Err(Cancelled) by a cancellation token that fires in the narrow window between the reap and the disposition check. This is an internal correctness fix with no public-API change. If you carried a workaround that tolerated a spurious Cancelled on a self-completing run, you can remove it.

Verify the upgrade

cargo update -p processkit
cargo build      # both breaking changes are compiler-caught
cargo test

Upgrading from older than 0.10

The jumps below 0.10 predate this guide. Read the dated sections of the CHANGELOG for each minor you cross — every breaking entry there is marked Breaking and carries its own migration note. Notable recent non-breaking additions you gain along the way: Command::checked / run_unit (0.10.2) and the record-cassette symlink/Display-injection hardening (0.10.2).