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
processkitfollows Semantic Versioning: the public API is stable, and any breaking change lands only in a new major version, so2.xupgrades are backward-compatible. The default Cargo requirementprocesskit = "2"already does the right thing — it allows2.*but not3.0. Skim the relevant section here before each major bump. (Themockfeature'smockall-generatedexpect_*surface stays semver-exempt — it tracks themockallversion.)
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 Error — Exit, 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)](butpub, 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::Parseis the one constructor left on the documented surface — building anError::Parsefrom 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
| Before | After | Notes |
|---|---|---|
Error::OutputTooLarge { line_limit, byte_limit, .. } | Error::OutputTooLarge { max_lines, max_bytes, .. } | matches the OutputBufferPolicy knobs they report |
ResourceLimits::memory_max | ResourceLimits::max_memory | limits 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 — useProcessGroup::kill_all.RunProfile::avg_cpu(deprecated since 1.1.0) is removed — useRunProfile::avg_cpu_cores.RunProfile::exit_code(the field) is removed — it duplicatedoutcome.code(); useRunProfile::code() -> Option<i32>instead ofprofile.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:
| Before | After |
|---|---|
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.text → line.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 output → output_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):
| Before | After |
|---|---|
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 nothing — limits 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).