# pset
Orchestrate a set of child processes: one event stream for their output and their exits. It gives you the same
functionality (almost) that async gives you, but without bringing async runtimes in your program.
A `ProcessSet` owns any number of children, each labelled with a tag of the caller's choosing, and hands out what they do — output on stdout, output on stderr, exiting — one `PsetEvent` at a time. One thread waits on all the pipes and all the exits together; there is no signal handler and no thread per pipe.
A `ProcessSet` is using `std::process::Command` for actual spawning, so every feature supported by std is also supported by pset.
```rust
use pset::{ProcessSet, PsetEvent};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, PartialEq)]
enum Tag {
Date,
Uname,
}
fn main() -> std::io::Result<()> {
let mut pset = pset::create::<Tag>()?;
// Piping a stream is what turns it into events — see below.
let mut cmd1 = Command::new("sh");
cmd1.arg("-c").arg("date").stdout(Stdio::piped()).stderr(Stdio::piped());
let mut cmd2 = Command::new("sh");
cmd2.arg("-c").arg("uname").stdout(Stdio::piped()).stderr(Stdio::piped());
pset.spawn(Tag::Date, cmd1)?;
pset.spawn(Tag::Uname, cmd2)?;
while let Some((tag, event)) = pset.wait_next()? {
match event {
PsetEvent::Stdout(data) => println!("{tag:?} said {} bytes", data.len()),
PsetEvent::Stderr(data) => eprintln!("{tag:?} complained: {} bytes", data.len()),
PsetEvent::ProcessExited(status) => println!("{tag:?} exited: {status}"),
}
}
Ok(())
}
```
`wait_next` returns `None` once the set is empty, so the loop ends when the last child is gone — no bookkeeping of who is still running. `wait_next_timeout` is the same wait with a deadline.
## Guarantees
- **`ProcessExited` is the last event for a process.** It is emitted only after the child has been reaped _and_ every pipe the set holds for it has reached end of file, so no output can appear after it and nothing written before the exit is lost.
- **No output is lost to a full pipe.** Every pipe is drained by the same loop that waits for the exits, so a child writing megabytes cannot deadlock against a parent waiting for it to finish.
- **Signals cannot go astray.** `kill` never signals through a pid that could have been recycled: on Linux a pidfd carries the signal, and on macOS it only ever goes to a child the set has not reaped yet, whose pid therefore still belongs to it.
- **No orphans, no zombies.** Dropping the set kills (`SIGKILL`) and reaps every child still running. Wait for the children, or `kill_all` them on your own terms, if that is not what you want.
## Platform support
Linux and macOS, each on its native way of making a process exit an ordinary event in a kernel queue:
- **Linux** — `pidfd_open(2)` + `epoll(7)`
- **macOS** — `kqueue(2)` + `EVFILT_PROC`
The platform-specific plumbing lives in the `sys` module behind a small common surface; everything above it is shared.
## Documentation
The crate-level docs cover the API and its contract in detail:
```sh
cargo doc --open
```
## Testing
```sh
cargo test
```