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. The current line is 3.x:processkit = "3"accepts compatible3.*upgrades but not a future4.0. A consumer still declaringprocesskit = "2"remains on 2.x until it deliberately changes the requirement and applies the migration below. (Themockfeature'smockall-generatedexpect_*surface stays semver-exempt — it tracks themockallversion.)
This behavior change is not compiler-caught: both methods still return the same
Result<()>, but on the POSIX process-group mechanism (macOS/BSD and the Linux
process-group fallback) a real SIGSTOP / SIGCONT delivery failure now reaches the
caller as ErrorReason::Io. In particular, EPERM from a live, non-zombie member now
surfaces; ESRCH, harmless zombie-only EPERM, an empty group, and EPERM on a BSD
target without a process-state reader remain Ok.
On FreeBSD the same reporting applies through a different mechanism: 3.2.0 moves
that target off the shared process-group backend onto the new process reaper
(Mechanism::ProcessReaper — see
Platform support), where suspend / resume deliver through
PROC_REAP_KILL and surface its refusals — a live, non-zombie member's EPERM, and an
EINVAL / ECAPMODE meaning the request never ran — with the same ESRCH and
zombie-only-EPERM exemptions. Review FreeBSD call sites exactly as below; if the
reaper cannot be acquired the group falls back to the process-group mechanism and its
wording applies verbatim.
Before, code could treat a successful call as guaranteed because the backend swallowed every send failure:
# use processkit::ProcessGroup;
# fn pause(group: &ProcessGroup) -> processkit::Result<()> {
group.suspend()?; // POSIX pgroup used to return Ok even when SIGSTOP was rejected.
// Work that assumes the whole tree is frozen.
group.resume()?;
# Ok(())
# }After, review those call sites and handle a delivery error explicitly before assuming the whole tree reached the requested state:
# use processkit::ProcessGroup;
# fn pause(group: &ProcessGroup) -> processkit::Result<()> {
if let Err(err) = group.suspend() {
// The sweep still visited every member, so some members may be suspended.
return Err(err);
}
// Work that requires the tree to be frozen.
group.resume()?; // Review and handle a partial-resume error here as well.
# Ok(())
# }This requires a code review, not just a rebuild, for callers that assumed Ok(())
meant every member was suspended or resumed. The sweep continues after a rejected
operation, so Err does not mean that nothing changed: the group can be partially
suspended or resumed.
Error changed from an enum into a thin struct Error { .. } holding a
Box<ErrorReason>, so it is one pointer wide instead of ~100 bytes. This
shrinks every Result<T, Error> on the run path (and any enum that embeds one)
and silences the default result_large_err / large_enum_variant clippy lints.
The former enum — with all its variants and fields unchanged — is now the
re-exported [ErrorReason], reached through err.reason().
Who it affects: anyone that pattern-matches an Error by variant. The read
accessors (code(), program(), diagnostic(), is_timeout(),
stdout_bytes(), …), Display, Debug, and source() are unchanged and
still work on Error directly — only direct variant matches need a fix.
Fix: reach the variant through reason() (borrow) or into_reason() (own).
Before:
match err {
Error::Exit { code, .. } => eprintln!("exit {code}"),
Error::Timeout { .. } => eprintln!("timed out"),
_ => {}
}
After:
use processkit::{Error, ErrorReason};
fn handle(err: Error) {
match err.reason() {
ErrorReason::Exit { code, .. } => eprintln!("exit {code}"),
ErrorReason::Timeout { .. } => eprintln!("timed out"),
_ => {}
}
// To move a captured stream or the owned `io::Error` out of the reason,
// consume the wrapper instead: `match err.into_reason() { .. }`.
}The #[doc(hidden)] constructors (Error::exit/timeout/signalled/spawn/
not_found/stdin) and the public Error::parse(..) are unchanged and still
return an Error.
To construct an Error from a directly constructible ErrorReason variant,
wrap the variant literal with the From implementation:
use processkit::{Error, ErrorReason};
let err = Error::from(ErrorReason::Unsupported {
operation: "custom soft stop".into(),
});
let _ = err;Error, ErrorReason, and ErrorKind are three separate public names, all
re-exported from the crate root. If your crate previously re-exported only
Error, decide whether its public surface should now also expose ErrorReason
(and ErrorKind if it exposes classification), so your consumers can still
inspect failure reasons through your API.
The merged output stream is now a process-lifecycle stream (output_events() → events(), OutputEvent → ProcessEvent)
The merged output-event stream widened from "which output line" to "an event in the process's life", so the verb and its enum are renamed to match — a deliberate 3.0 break with no deprecated alias:
| Before | After |
|---|---|
RunningProcess::output_events() (verb) |
RunningProcess::events() |
PipelineSession::output_events() (verb) |
PipelineSession::events() |
OutputEvent (event enum) |
ProcessEvent |
OutputEvents (stream type) |
ProcessEvents |
Who it affects: anyone that calls output_events() or matches an OutputEvent.
The rename is compiler-caught — a build after the bump flags every site ("no
method named output_events" / "cannot find type OutputEvent").
ProcessEvent::Stdout/Stderr carry the same OutputLine payload with unchanged
semantics, and ProcessEvent::text() still returns Some for a line event and
None otherwise. The stream also gained two lifecycle variants —
ProcessEvent::Started { pid } (leads the stream) and ProcessEvent::Exited(Outcome)
(ends it) — so one stream now carries Started → Stdout/Stderr → Exited. The
enum stays #[non_exhaustive], so a _ arm covers them (and any future kind).
Fix — rename the verb and the type, and add a _ arm.
Before:
use processkit::OutputEvent;
let mut events = running.output_events()?;
while let Some(ev) = events.next().await {
match ev {
OutputEvent::Stdout(line) => println!("out: {}", line.text()),
OutputEvent::Stderr(line) => eprintln!("err: {}", line.text()),
_ => {}
}
}
After — the verb is events(), the enum is ProcessEvent, and the new lifecycle
variants are handled (or fall through the _ arm):
use processkit::ProcessEvent;
fn handle(ev: ProcessEvent) {
match ev {
ProcessEvent::Stdout(line) => println!("out: {}", line.text()),
ProcessEvent::Stderr(line) => eprintln!("err: {}", line.text()),
ProcessEvent::Started { pid, .. } => eprintln!("started: {pid:?}"),
ProcessEvent::Exited(_outcome) => eprintln!("exited"),
_ => {}
}
}Behavior change — drive the stream concurrently with the finisher (not
compiler-caught). Because Exited is delivered when the run is reaped, the stream
now parks after both pipes close and yields its terminal Exited only once the run
is finished. So the old "drain the stream to its end, then call finish()/wait()"
shape deadlocks — the stream is waiting for the reap that finish() performs. Drive
the two together instead (e.g. tokio::join! the stream loop and finish()), or
wait()/finish() on a separate task while you consume the stream. If you only used
output_events() for its output lines and always finish()ed separately afterward,
switch to consuming both concurrently.
Not compiler-caught. The max_bytes ceiling (OverflowMode::Error and the drop
modes) and the total_bytes an OutputTooLarge failure reports now count the raw
bytes read off the output pipe — including line terminators and invalid-UTF-8 bytes —
rather than the decoded line-content bytes they counted before. For typical ASCII/UTF-8
line output the two are identical; they diverge for output with CRLF terminators or
non-UTF-8 bytes, where the raw count is slightly higher.
Who it affects: a caller that set a byte cap (with_max_bytes) and depends on the
exact threshold at which capture truncates/errors, or that reads
OutputOverflow::total_bytes() / the total_bytes field and compares it against a
precise expected value. It also affects a downstream crate that documented the prior
decoded-line-content meaning of total_bytes as part of its own public contract: for
that consumer, the documented contract exposed to its customers has changed, not just
an internal capture threshold. Fix: re-check those thresholds/assertions against the
raw-byte count. If you set no byte cap, nothing changes.
Two behavior changes to ProcessGroup::signal(Signal::Int | Signal::Term), neither
compiler-caught (the signature is unchanged, Result<()>):
- Windows: it now best-effort soft-closes the tree (a console
CTRL_BREAKtowindows_graceful_ctrl_breakleaders plusWM_CLOSEto windowed members) and returnsOkwhen it had something to signal, instead of always returningErrorReason::Unsupported. It still returnsUnsupportedonly when the group has neither a console-CTRL leader nor a windowed member. A caller that treated the old blanketUnsupportedas "Windows never soft-stops" should stop assuming that. This change is confined toProcessGroup::signal(Signal::Int | Signal::Term); it does not changeProcessGroup::soft_stop_scope()/SoftStopScope, the separate side-effect-free capability probe. Nor does it affectProcessGroup::kill_all()or drop: those remain unconditional whole-tree hard kills (through the Job Object on Windows), unchanged on every platform. - POSIX process-group mechanism (macOS/BSD, and the Linux process-group fallback):
a genuinely failed send now surfaces as
ErrorReason::Ioinstead of being swallowed behind a falseOk— anEINVAL(an out-of-rangeSignal::Other(n)) or anEPERMfrom a live, non-zombie member now reaches the caller. An already-exited member (ESRCH), a harmless zombie-onlyEPERM, an empty group, and theSignal::Other(0)existence probe still reportOk. A caller that ignored the return value is unaffected; one that inspects it now sees these real failures.
3.0 adds an opt-in real pseudo-terminal backend for tools that require a
controlling terminal (isatty()-gated CLIs, password prompts, full-screen or
in-place terminal output):
[dependencies]
processkit = { version = "3", features = ["pty"] }Command::use_pty() selects openpty on Unix or CreatePseudoConsole (ConPTY)
on Windows. It is additive: without the feature, or with the feature enabled but
use_pty() unset, the existing three-pipe launch path is unchanged. Once selected:
- stdout and stderr are merged onto the terminal master, so
ProcessResult::stderris empty; - interactive input uses
keep_stdin_open()+RunningProcess::take_stdin(); pty_size(cols, rows)sets the initial geometry andresize_pty(cols, rows)updates a live session;- the child stays in the same Job Object, cgroup, or process group, so timeout, cancellation, and kill-on-drop retain their whole-tree guarantee.
This is a terminal transport, not a terminal emulator. Unix and ConPTY differ in echo control, Enter/EOF handling, environment, and resize notification; read the PTY streaming guide and platform matrix before building an interactive protocol around it.
These are new capabilities, not migrations — no code changes are forced. Reach for them if they help:
Command::spawn_detached()→DetachedChild— the one deliberate, opt-in escape from kill-on-drop containment, for a child meant to outlive its launcher (daemonize, anohup-style helper). It inverts the crate's headline guarantee on purpose, so it is a separate, minimal type (just thepid) and loudly refuses every owner-dependent knob rather than dropping it silently. See its rustdoc before using it.Command::capture_policy(...)+ theCapturePolicytrait andOutputStreamenum — a typed redaction-at-capture seam: transform each captured line (e.g. scrub a secret) before it is retained in the backlog /ProcessResult. The handler/tee/output_bytespaths still see the unredacted text — only the retained capture is rewritten — and a panicking policy fails closed (the line is dropped, never leaked).Command::to_tokio_command()is no longer#[doc(hidden)]— it is now a documented, honest low-level escape hatch (pair it withProcessGroup::spawnto keep containment while dropping the high-level verbs/pump/capture). See the "Escape hatch" section in the commands guide.
cargo update -p processkit
cargo build # the events()/ProcessEvent rename and the Error struct change are compiler-caught
cargo test # catches the events()-concurrency, output_bytes byte-count, and signal behavior changes if you rely on them2.0.0 and 1.3.0 were withdrawn — upgrade straight from 1.2.x to 2.1.0.
2.0.0was published in error and yanked;1.3.0accidentally shipped this breaking batch under a minor bump and was yanked too.2.1.0is the first supported release of the changes below — the crate follows semver, so this break lands in a major as intended. There is nothing extra to do for the skip; the migration from a1.2.xdependency is exactly the notes here.
Mostly mechanical renames — caught by the compiler — plus two
#[non_exhaustive] tightenings on Error (also compiler-caught, once you stop
destructuring the affected variants field-exhaustively) and one genuine
behavior change on output_bytes that a build alone won't surface.
| Before | After |
|---|---|
Error::OutputTooLarge { line_limit, byte_limit, .. } |
Error::OutputTooLarge { max_lines, max_bytes, .. } |
ResourceLimits::memory_max (field, limits feature) / .memory_max(n) builder |
ResourceLimits::max_memory / .max_memory(n) |
ProcessGroup::terminate_all() |
ProcessGroup::kill_all() |
RunProfile::avg_cpu() |
RunProfile::avg_cpu_cores() |
RunProfile::exit_code (field) |
profile.code() (method — same Option<i32>) |
use processkit::Encoding; |
use processkit::prelude::Encoding; |
use processkit::StreamExt; |
use processkit::prelude::StreamExt; |
result.output_contains_any(&["a", "b"]) |
result.output_contains_any(["a", "b"]) (now impl IntoIterator<Item = impl AsRef<str>> — a bare array, Vec<String>, or slice all work directly, without the &; the old &["a", "b"] call still compiles too) |
The terminate_all / avg_cpu entries were deprecated forwarding aliases since
1.1.0 (see the 1.1.0 changelog entry); this
release removes them outright. RunProfile::exit_code duplicated
outcome.code(), which RunProfile::code() already exposed — the field is gone,
the method is the one accessor now.
Exit, Timeout, Signalled, Spawn, NotFound, Parse, OutputTooLarge,
Stdin, and — with the limits feature — ResourceLimit can no longer be
struct-literal-constructed or field-exhaustively destructured outside the crate.
Before:
match err {
Error::Exit { program, code, stdout, stderr } => { /* ... */ }
_ => {}
}
After — add .. to the pattern (or, better, use the existing accessors instead
of destructuring at all):
use processkit::{Error, ErrorReason};
fn handle(err: Error) {
// Since 3.0 the variants live on `ErrorReason`, reached via `err.reason()`.
match err.reason() {
ErrorReason::Exit { program, code, stdout, stderr, .. } => { let _ = (program, code, stdout, stderr); }
_ => {}
}
// or, accessor-based and immune to the next field addition:
if let Some(code) = err.code() {
// err.program() / err.stdout() / err.stderr() / err.combined() also work
let _ = code;
}
}This is prep for future field additions to any of these variants without
another breaking change — the Exit/Timeout/Signalled variants already
gained one such field this release (next entry).
A new field, stdout_bytes: Option<Vec<u8>>, carries the exact captured
stdout bytes for a checking-verb error built over output_bytes
(e.g. output_bytes().await?.ensure_success()?); read it through
Error::stdout_bytes() -> Option<&[u8]>, not by destructuring the variant
directly (they are #[non_exhaustive] — see above). None on the text path
(output_string/run/checked/…), where the decoded stdout string is
already the whole story.
| Before | After |
|---|---|
Error::ResourceLimit { message: String } |
Error::ResourceLimit { kind: LimitKind, reason: LimitReason, detail: String } |
Fix a match:
// Before
Error::ResourceLimit { message } => warn!("limit rejected: {message}"),
// After
Error::ResourceLimit { detail, .. } => warn!("limit rejected: {detail}"),
// or, branch on the structured classification instead of parsing text:
if let (Some(kind), Some(reason)) = (err.limit_kind(), err.limit_reason()) {
match (kind, reason) {
(LimitKind::Memory, LimitReason::Unsupported) => { /* ... */ }
_ => {}
}
}
Not compiler-caught: if you configured an OutputBufferPolicy byte ceiling
(with_max_bytes) and called output_bytes, the cap previously bounded only
the line-pumped stderr; raw stdout capture was unbounded regardless of
the configured max_bytes. It now applies to both streams:
OverflowMode::Errorpast the cap now errors on stdout overflow too, withError::OutputTooLarge { max_lines: None, .. }(raw bytes have no lines).- The drop modes (head/tail) now bound retained stdout bytes the same way they
already bounded stderr, and set
ProcessResult::truncated.
If nothing sets a byte cap, capture stays unbounded exactly as before — nothing
to do. If you do set one and rely on output_bytes returning the full
stdout regardless, re-check that call site: it now truncates/errors like every
other capture path under the same policy.
RecordReplayRunner (record feature) replays a cassette recorded from one
absolute working directory against the same invocation run from a different
one, instead of CassetteMissing — cwd is still stored on each entry for
visibility, it just no longer discriminates two otherwise-identical recorded
runs. The on-disk format revision bumped to 3, but this is not a compatibility
gate: a cassette written by a 1.x build still loads and replays fine. The one
edge case: an existing cassette that had two entries differing only in cwd
now collides on replay, and the first-recorded entry answers for both —
re-record it if that matters for your fixtures.
cargo update -p processkit
cargo build # the renames and non_exhaustive tightenings are compiler-caught
cargo test # catches the output_bytes byte-cap behavior change if you rely on itA few breaking changes, all caught by the compiler — if it builds after the bump, you're done.
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(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.)
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).
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.
The default feature set is now just process-control; stats is no longer on by
default. (It gates a specialized metrics surface the core never needs; on
Windows it links an OS library — the ProcessStatus FFI used solely for the
peak-memory readout — but unlike mock/tracing/record it pulls in no extra
crate.)
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).
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:
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):
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:
fn handle(ev: processkit::OutputEvent) {
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.
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.
cargo update -p processkit
cargo build # both breaking changes are compiler-caught
cargo testThe 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).