Start here when you have a symptom but do not yet know which processkit subsystem owns it. The sections below give a short diagnosis and route to the guide that owns the full contract.
Before changing code, preserve the structured evidence:
- inspect
Error::reason()/Error::kind()andError::diagnostic()instead of parsingDisplaytext; - record
ProcessGroup::mechanism()(or usehost_containment()before any group exists); - note whether the run owns a private group or uses a shared group —
RunningProcess::kills_tree_on_drop()answers that directly; - distinguish the command's lifetime deadline from a readiness probe's
withindeadline.
| Symptom | First distinction |
|---|---|
| A child survived drop | Private owner, shared group, deliberate detach, external/remote process, or POSIX setsid escape? |
NotFound, but the tool is installed |
Bare-name lookup or an explicit path? Which effective PATH / PATHEXT was used? |
ResourceLimit while creating a group |
Invalid value, unsupported mechanism, or an existing but undelegated cgroup? |
| The process runs, but no output appears | End-of-run capture, incomplete line, child-side pipe buffering, or non-piped output? |
| PTY output contains ANSI/VT garbage | Retained text that can be sanitized, or an exact/raw sink that intentionally stays byte-accurate? |
wait or a stream never finishes |
Open stdin, undrained full-duplex output, missing line terminator, or a live process/descendant? |
| Graceful shutdown does not work on Windows | Windowed WM_CLOSE, opted-in console CTRL_BREAK, or hard-kill-only member? |
Timeout instead of NotReady — or vice versa |
Run contract versus non-killing readiness observation? |
First identify what was dropped and who owns containment:
Command::start()creates a private group. Its handle reportskills_tree_on_drop() == true; dropping it hard-kills the local tree.ProcessGroup::start()creates a shared-group handle. It reportsfalse: the separately heldProcessGroupcontrols the tree's lifetime, so stop or drop that group (or callstart_kill()when only the direct child should stop). Keeping anArc<ProcessGroup>clone alive keeps the owner alive too.spawn_detached()is the deliberate exception. DroppingDetachedChilddoes nothing because the child was explicitly launched to outlive this process.- A process spawned outside processkit is outside the boundary until
adopt()(with itsChildhandle) oradopt_external()(with only its pid). Adoption moves the named process, not descendants it already created; the POSIX process-group backend can only track an already-executed adopted child individually.adopt_externaladditionally reportsUnsupportedon FreeBSD and the other BSDs, where the crate has no start-time reader to anchor the pid on — a refusal, so a process left running there was never contained. - A group whose members die to a teardown it never ran, or whose
startsuddenly fails "job is full" (Windows). Adopting a pid that already belonged to another Job Object nests this crate's job under that one, so the outer job's terminate or close reaches these members (later-started ones included) and its limits bind them. The mirror image on Linux cgroup v2: adopting a process takes it out of its previous cgroup, so an outside supervisor's teardown and limits stop applying to the process it thought it held. Neither is reverted on drop — see platform support. adopt_externalreturned "pid … was recycled while it was being adopted". The number changed hands inside the call, so nothing was adopted. Read the rest of that message before retrying: on Linux cgroup v2 it also says whether the number could be moved back out of this group's cgroup, and in the case where it could not, the process now holding the number is a member of this group and will be killed by its teardown — drop or tear down that group promptly if that is not acceptable.- Nothing reaps a process adopted by pid. No exit status for it appears anywhere
in this API, so a
wait-shaped answer for it has to come from whoever is its actual parent; on the POSIX process-group backends an exited one that nobody reaps stays a zombie, keeps probing alive through a graceful shutdown's whole grace, and cannot be cleared byescalate_to_kill. - A command reached through
sshis local containment only: the localsshclient dies, but the remote command needs remote-side containment.
Then inspect the mechanism. JobObject and CgroupV2 are real tree
containers. ProcessGroup is intentionally reported as the weaker fallback:
a descendant that calls setsid can escape it. Also remember that a process
abort or SIGKILL can skip Rust Drop. Windows still closes the Job Object and
reaps the whole tree; Linux's opt-in kill_on_parent_death() reaches only the
direct child, and macOS/BSD have no equivalent owner-death hook.
If an ordinary private-group child survives a normal Rust drop without one of those boundaries, capture the mechanism, platform, program shape, and a minimal reproducer — that is not an expected lifecycle outcome.
Full contracts: process ownership and adoption, deliberate detachment, containment mechanisms, and the remote-execution boundary.
NotFound means processkit could not resolve the program under the command's
actual launch rules. Check these in order:
- A bare name is searched on the command's effective child
PATH(and on Windows withPATHEXT).env_clear(),inherit_env(...), an explicitPATH, or a missing extension can make that different from the interactive shell where you verified the installation. - A program containing a path separator is not searched on
PATH.current_dirdoes not portably re-anchor a relative program path; combine a child working directory with an absolute program path. prefer_local(dir)affects only bare names. A relative preferred directory is resolved against the parent's real current directory, not the command'scurrent_dir.- Run
Command::resolve_program()(orprocesskit::whichfor a bare name). It is a spawn-free preflight using the samePATH/PATHEXT, executable-bit, environment, andprefer_locallogic as the real launch.
If preflight finds the file but launch still fails, inspect whether the error is
actually Spawn: permissions, a bad cwd, or a Windows .cmd/.bat that
needs cmd.exe are launch failures, not missing programs.
Full contracts: program and working-directory resolution,
prefer_local,
spawn-free preflight,
and NotFound versus Spawn.
Do not parse the English detail. Read limit_kind() and limit_reason():
Invalidmeans the requested value itself is invalid;Unsupportedmeans there is no whole-tree resource accounting for that limit (no container at all, or — on FreeBSD — a reaper that contains but does not account);Unenforceablemeans a suitable mechanism exists but this process cannot apply the cap.
On Linux, processkit's per-tree limits require the process to own the real
cgroup v2 hierarchy root so it can enable controllers. A container cgroup
namespace root is not that root, and a normal systemd session, scope, or service
is not delegated this way. Both ordinary and privileged containers therefore
commonly return Unenforceable; requesting a cap fails loud instead of silently
creating an unbounded group.
Inside Docker/Kubernetes, use the orchestrator's memory/CPU limits as the outer
boundary. Use processkit's limits on a host where the process genuinely owns
the cgroup root (typically a minimal non-systemd init on bare metal or a VM).
Kill-on-drop can still fall back to a POSIX process group when no per-tree cap
was requested; it is the unenforceable protection request that must fail.
Full contracts: container limits versus processkit limits, resource-limit semantics, and cgroup prerequisites.
Separate transport from the child's own buffering:
- One-shot capture (
output_string,run) returns output when the child exits. For live output, usestart()plusstdout_lines(), a line handler, or a tee. - A line stream emits only after a terminator. An interactive
Password:or REPL prompt without\nneedswait_for_output, notwait_for_line. - A child may block-buffer stdout when it sees a pipe. Prefer the tool's own
unbuffered/line-buffered switch when available. If its behavior is genuinely
isatty()-gated, enable theptyfeature and useuse_pty(). StdioMode::Inherit,Null, andstdout_file*intentionally bypass the capture pump. A PTY instead merges stdout and stderr into logical stdout, so its separate stderr capture is empty.
PTY is a semantic change, not just a flushing switch: it merges streams, uses terminal line framing, and may add control sequences. Keep pipes when stream identity or exact bytes matter.
Full contracts: streaming stdout, prompt-aware waiting, interactive/TTY launch, and the PTY platform matrix.
Terminal applications emit color, cursor movement, alternate-screen, OSC
title/hyperlink, and other VT sequences. use_pty() does not emulate a screen.
Opt into sanitize_vt() (or the per-stream stdout_sanitize_vt() /
stderr_sanitize_vt() variants for pipe-capable wrappers) when retained text
should be plain.
Sanitization is deliberately scoped to the capture backlog. Handlers, decoded
tees, raw tees, and output_bytes() still see exact input; sanitize inside
those sinks if they also need clean text. PTY framing is separately \r-aware
by default; an explicit LineTerminator::Newline can make redraw-style progress
look delayed even after escapes are removed.
Full contract: PTY output hygiene.
Work through the resources that can still be open:
- Stdin is closed by default. If you opted into
inherit_stdin(), the child can wait for EOF from the parent's terminal or pipe. If you calledkeep_stdin_open()and took the writer, callProcessStdin::finish()or drop it when the conversation ends. - Do not perform a large interactive write while nothing drains output. A full stdout/PTY buffer can stop the child reading stdin while the parent's stdin write is also blocked. Read and write concurrently.
stdout_lines()andwait_for_line()need a complete line. For an unterminated prompt, usewait_for_output().wait_any()does not pump output. Drain chatty children first (or concurrently) before racing them.- A still-running descendant can hold an inherited pipe open after its direct
parent exits. Bound the run with
Command::timeout()orinactivity_timeout(). On a shared-group handle those watchdogs stop the direct child; stop the owning group when the whole shared tree must end.
A readiness within bound is not a run bound: NotReady leaves the child
alive. If giving up on readiness should end the run, explicitly shutdown() an
own-group handle, start_kill() a direct child, or stop the shared group.
Full contracts: interactive stdin and full-duplex deadlock,
wait versus drain,
stream deadlines, and
timeouts and inactivity.
Dropping a handle/group is always the hard safety net; it never waits for
application cleanup. Use shutdown, stop, or timeout_grace for a graceful
tier, then identify which Windows soft-close path the child can receive:
| Child shape | Soft-close path |
|---|---|
| Owns a top-level window | WM_CLOSE is posted automatically; the child has the grace window to exit. |
Console child spawned with windows_graceful_ctrl_break() |
The direct child receives CTRL_BREAK, then survivors are terminated after the grace. |
create_no_window, DETACHED_PROCESS, or console child without the opt-in |
No console event can land; teardown reaches the hard Job Object kill. |
| Adopted console child | Not a registered CTRL leader; only an owned top-level window can receive the automatic WM_CLOSE path. |
soft_stop_scope() is the side-effect-free check for the live group:
OptInMembers means at least a console/window target is reachable;
Unsupported means no soft target exists. The console event is CTRL_BREAK,
not CTRL_C, and the child must install the corresponding handler and exit
within the grace.
Full contract: Windows graceful teardown and graceful timeouts.
They answer different questions:
| Result | Clock | Side effect |
|---|---|---|
ErrorReason::NotReady |
A readiness method's within argument |
Observation stops; the child remains alive. |
Outcome::TimedOut / ErrorReason::Timeout |
Command::timeout() or inactivity_timeout() |
The applicable process tree/direct child is torn down. |
Readiness probes deliberately do not arm the command watchdog while they
poll. A probe can therefore return NotReady even if the command's absolute
deadline has passed; the following consuming verb (finish, wait,
output_string, and so on) enforces that run deadline and can then report
Timeout. Conversely, a probe whose output stream closes before a match returns
NotReady immediately because readiness can no longer happen — it does not wait
out the rest of within.
After NotReady, choose explicitly: keep waiting, stop the handle/group, or
surface startup failure. For checking code, branch on err.reason() /
is_timeout(); is_timeout() is intentionally false for NotReady.
Full contracts: readiness probes, deadline families, and look-alike errors.
Next: Errors · Streaming & interactive I/O · Platform support · Running in containers