command-stream for Rust
Rust implementation of command-stream: a shell command execution library with streaming, events, shell parsing, virtual commands, and built-in command support.
Installation
Library Usage
use CommandResult;
use EchoCommand;
use VirtualCommand;
async
Successful CLI output on stderr
Command-stream preserves the file descriptor chosen by the child process. A
zero exit code can therefore accompany an empty stdout and useful stderr.
Check both when a CLI version may print a machine-readable result, such as a
new pull request URL, to stderr:
use run;
# async
Append 2>&1 to the command when normal shell stream merging is preferred. The
merged output is captured in stdout, while stderr is empty.
Streaming
StreamingRunner streams output as it arrives and mirrors the JavaScript
stream() async iterator (issue #155):
use ;
async
Parity guarantees with the JavaScript implementation:
- The stream yields a final
OutputChunk::Exit(code)when the process exits. - It never hangs when the process has exited but a grandchild keeps the stdio
pipes open — readers are drained for
exit_pump_grace_ms(default 100ms) and then aborted. - The process can be stopped from inside the loop with
stream.kill()(configured signal) orstream.kill_with(signal)(explicit override); dropping the stream (e.g.break) stops the process too.
StreamingRunner::new(command) interprets a completed command string with the
platform shell. When argument boundaries must be preserved exactly, pass the
executable and arguments separately with from_argv:
use StreamingRunner;
async
The exact-argv form bypasses /bin/sh -c and cmd.exe /c, so it does not
require shell-specific quoting. It also accepts OS-native executable and
argument values such as PathBuf and OsString.
Signals
kill() stops a running command. It defaults to SIGTERM and works the same way
for both runners, matching the JavaScript implementation
(JS signal documentation).
What kill() actually does
Stopping a process is not a single signal. Every kill runs the same four steps:
- The requested signal is delivered to the child and its process group, so a grandchild behind a shell wrapper is reached too.
- The child is given a grace period (
kill_grace_ms, default100) to run its own signal handler and exit on its own terms. - If it is still alive when the grace period expires,
SIGKILLfollows, so a process that ignores the signal is still guaranteed to terminate. - The reported exit code is the conventional
128 + signalvalue.
Step 2 is what makes a shutdown graceful: without it, a child that traps SIGTERM to flush output, release a lock, or stop its own workers is destroyed before its handler can run.
Grandchildren and process groups
Commands run through a shell, so the real work is usually a grandchild of the
sh that was spawned. Both runners therefore start the child in its own process
group and signal the group, not just the direct child — including the common
case where the wrapper dies on the first signal and the grandchild is reparented
to init.
The one exception is a command that shares your terminal: when interactive is
set, or when stdin is inherited and is a tty, ProcessRunner leaves the child in
the caller's process group. It has to, because the terminal delivers CTRL+C to
its foreground group only, and a background child that read from the terminal
would be stopped with SIGTTIN. For those commands the signal reaches the direct
child alone — and CTRL+C from the terminal already reaches the whole group
anyway. Set stdin to StdinOption::Null or StdinOption::Pipe if you need
group delivery from kill().
Group membership is recorded when the child is spawned rather than looked up
when it is signalled, because by then the group leader is usually dead: the
first signal kills the sh wrapper, and the escalation follows a grace period
later. macOS refuses to answer getpgid for a process in that state, which
would silently skip the delivery and leave the grandchild running.
This is one place where ProcessRunner can promise slightly more than the
JavaScript implementation. Because it holds the child until you await it, the
group id stays reserved even after the shell exits, so kill() still reaches a
worker the shell left behind. Node and Bun reap the shell immediately, so
JavaScript cannot address that group safely and leaves such a worker running.
ProcessRunner
kill() sends the configured signal; kill_with(signal) overrides it for a
single call:
use ;
async
StreamingRunner
stream.kill() and stream.kill_with(signal) stop the process from inside the
loop; dropping the stream (e.g. break) stops it too:
use ;
async
Tuning the grace period
kill_grace_ms is the number of milliseconds between the requested signal and
the SIGKILL escalation. Set it to 0 to escalate immediately, with no chance to
clean up: the requested signal is then not delivered at all, only SIGKILL.
Delivering it first and then killing would leave a window the child can be
scheduled in, which makes "no grace" a race rather than a guarantee. The
reported exit code still reflects the signal you requested.
SIGKILL is never delayed: it cannot be caught, so kill_with("SIGKILL") skips
the grace period regardless of the configured value.
Signal exit codes
A process stopped by a signal reports 128 + signal, the same convention POSIX
shells use. signal_number and signal_exit_code expose the mapping:
use ;
assert_eq!;
assert_eq!; // CTRL+C
assert_eq!;
assert_eq!;
| Signal | Number | Exit code | Typical meaning |
|---|---|---|---|
SIGHUP |
1 | 129 |
Terminal closed / reload config |
SIGINT |
2 | 130 |
CTRL+C |
SIGQUIT |
3 | 131 |
Quit from keyboard |
SIGKILL |
9 | 137 |
Forced termination, cannot be caught |
SIGUSR1 |
10 | 138 |
Application-defined |
SIGUSR2 |
12 | 140 |
Application-defined |
SIGTERM |
15 | 143 |
Polite request to stop (the default) |
The code reflects the signal you requested, even when the SIGKILL escalation
is what ultimately stopped the process. Unknown signal names fall back to
SIGTERM. Signals are a Unix concept; on Windows the escalation terminates the
process directly.
Multiline Text and Exact Output
The command macros treat an interpolated multiline string as one literal argument. Shell metacharacters remain data, and captured stdout/stderr preserve whether the child emitted a final newline:
use s;
# async
Use printf '%s' instead of echo when exact text matters; echo normally
adds a trailing newline. If no command is involved, prefer std::fs::write.
GitHub CLI Markdown Bodies
The same literal-argument contract applies to complex issue bodies. No manual
escaping is needed for fenced code, ${...} text, quotes, shell-looking
syntax, backslashes, newlines, or Unicode:
use s;
# async
If the text already lives in a file, use GitHub CLI's --body-file option.
For platform-native argument handling without a shell, pass the same values to
StreamingRunner::from_argv.
Command Line
The crate also builds a command-stream binary:
TUI Capture
capture_terminal runs an interactive program in a real pseudoterminal and
uses vt100 to retain settled terminal states:
use ;
let capture = capture_terminal?;
println!;
# Ok::
The capture contains the raw PTY output, consecutive-deduplicated frames, an
ordered unrolled transcript, and asciicast v2 input/output/resize events. The
artifact directory receives transcript.txt, frames.json, session.cast,
snapshot.svg, and an animated recording.svg; timeout errors retain the
partial capture and those diagnostic files. Use capture_terminal_async from
an async application.
Interactive sessions
capture_terminal is batch-only: every interaction is known up front and
timeout (30 s by default) kills the child. When the input arrives later and
from elsewhere — an authorization code a human pastes back minutes later, a
chat-ops bridge, a test that interleaves assertions with input — use
open_terminal, which keeps the same PTY open until you close it:
use ;
use Duration;
let mut session = open_terminal?;
session.wait_for?;
let url = session.transcript;
// ... arbitrary time passes; nothing terminates the child ...
session.send?;
session.wait_for?;
let capture = session.close?;
# let _ = ;
# Ok::
open_terminal accepts every capture_terminal option and forces
timeout: None, so a session runs until the child exits or close() is called.
wait_for reuses the same readiness semantics as interactions, including the
idle wait, and fails when the child exits first or its own timeout elapses.
send uses the TerminalInteraction vocabulary, close stops the child and
returns the usual capture (writing artifact_directory artifacts), and finish
waits for a child that exits on its own. capture_terminal is implemented on
top of the same session, so the two paths cannot drift.
Interactions can wait for literal output with after, regex output with
after_regex, and output quiescence with idle_duration. Named
TerminalKey variants cover arrows, Enter, Tab, Escape, Backspace, Ctrl-C, and
Ctrl-D; use TerminalKey::Raw for any other escape sequence. An interaction
must contain at least one action or wait; an empty TerminalInteraction is
rejected before the terminal is opened or input is sent.
Built-in tee
tee copies its input to stdout and to every file it is given, so a pipeline
can be recorded and keep flowing. It follows GNU coreutils: -a/--append
appends instead of truncating, -i/--ignore-interrupts keeps writing when
the pipeline is cancelled, -- ends option parsing, and a bare - is a file
named - rather than stdout. A write failure is reported on stderr and sets
exit code 1, while the remaining files are still written.
use Pipeline;
async
Built-in commands receive their stdin as one completed buffer, because a
pipeline reads each upstream stage to the end before handing the result on. So
tee is a pipeline stage, not a live terminal filter; for an interactive tee,
use the PTY sessions described under Interactive sessions.
Features
Tracked compatibility corpus
The Rust tests track the native process API plus Tokio, async-process, assert_cmd, duct, xshell, subprocess, rust_cmd_lib, run_script, bkt, rust-shell, shellfn, rexpect, and expectrl. All fourteen upstream suites are pinned to immutable commits. Portable public behavior runs against command-stream, while unsupported capabilities and inapplicable competitor-specific tests are accounted for in the competitor test corpus audit.
Run the focused executable corpus with
cargo test --test competitor_compatibility.
The Rust benchmark playground turns six of those native process-library mappings into validated performance, crate-footprint, feature-coverage, and real-world comparisons. Its CI-sized profile is:
- Shell parser for pipelines, command lists, logical operators, and redirection.
- Built-in command implementations for file-system and shell utility commands.
- Async execution with
tokio. - Virtual command abstractions for embedding command behavior in Rust programs.
- Cross-platform tests covering parser, state, events, streams, and built-ins.
Development
Rust release automation lives in scripts/ and is controlled by
.github/workflows/rust.yml from the repository root.