Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2cc549c
feat(pty): add Unix spawn provider
passcod Aug 24, 2026
10e5d6a
fix: let Tokio reap process-group leaders
passcod Aug 24, 2026
a74e9f2
test(pty): port Unix transport behavior
passcod Aug 24, 2026
bd82a59
test(pty): cover wrapper-native provider lifecycle
passcod Aug 24, 2026
f413835
test(pty): avoid the rejected spawn helper name
passcod Aug 25, 2026
fb18004
docs(pty): describe wrapper-native terminal transport
passcod Aug 25, 2026
8ecb001
ci(pty): cover frontend and portable Unix targets
passcod Aug 25, 2026
0e2e6ba
fix(pty): gate Unix provider internals by target
passcod Aug 25, 2026
289b492
test(pty): cover unsupported lifecycle precedence
passcod Aug 25, 2026
9a0285c
test(pty): cover terminal VEOF input
passcod Aug 25, 2026
d17deea
test(pty): cover absent controller capability
passcod Aug 25, 2026
ab1ee3b
fix(pty): share native child ownership
passcod Aug 25, 2026
0d0012d
fix: stop supervising recycled process groups
passcod Aug 25, 2026
0b33b4f
test(pty): cover provider state isolation
passcod Aug 25, 2026
0de4efe
ci(pty): cover docs and unsupported Unix
passcod Aug 25, 2026
aed08d3
docs: define direct-child group waits
passcod Aug 25, 2026
a2926f2
test: follow direct children in foreign groups
passcod Aug 25, 2026
a6c4e0f
fix(pty): harden portable Unix allocation
passcod Aug 25, 2026
98af1cb
test(pty): cover cancelled child waits
passcod Aug 25, 2026
cb818db
fix(pty): allocate OpenBSD descriptors atomically
passcod Aug 25, 2026
e737575
fix(pty): clean groups before reaping leaders
passcod Aug 25, 2026
e825d5e
fix: avoid post-MSRV let chains
passcod Aug 25, 2026
5762908
merge: carry release-owned versioning forward
passcod Aug 25, 2026
c342c11
merge: carry lockfile versioning forward
passcod Aug 25, 2026
89287ad
merge: carry released documentation forward
passcod Aug 25, 2026
ef57386
fix: keep PTY docs on the released version
passcod Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
features:
- tokio1
- std
- pty

name: "Test on ${{ matrix.platform }} with Rust ${{ matrix.toolchain }} (feat: ${{ matrix.features }})"
runs-on: "${{ matrix.platform }}-latest"
Expand Down Expand Up @@ -93,6 +94,54 @@ jobs:
- run: tests/multiproc_helper.rs 1 1
- run: cargo test --locked --all-features --all-targets

portable-unix-pty:
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-ios
toolchain: stable
- target: aarch64-linux-android
toolchain: stable
- target: x86_64-unknown-freebsd
toolchain: stable
- target: x86_64-unknown-netbsd
toolchain: stable
- target: x86_64-unknown-illumos
toolchain: stable
- target: x86_64-pc-solaris
toolchain: stable
- target: x86_64-unknown-dragonfly
toolchain: nightly
- target: x86_64-unknown-openbsd
toolchain: nightly

name: "Check PTY on ${{ matrix.target }}"
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Configure Rust
run: |
rustup toolchain install --profile minimal --no-self-update ${{ matrix.toolchain }}
rustup default ${{ matrix.toolchain }}
if [ "${{ matrix.toolchain }}" = nightly ]; then
rustup component add rust-src
else
rustup target add "${{ matrix.target }}"
fi

- name: Check portable Unix PTY backend
run: |
if [ "${{ matrix.toolchain }}" = nightly ]; then
cargo check --locked -Zbuild-std=std --target "${{ matrix.target }}" --no-default-features --features pty --lib
else
cargo check --locked --target "${{ matrix.target }}" --no-default-features --features pty --lib
fi

rustdoc:
name: Rustdoc
runs-on: ubuntu-latest
Expand All @@ -112,6 +161,28 @@ jobs:
RUSTDOCFLAGS: -D warnings
run: cargo doc --locked --all-features --no-deps

- name: Test documentation examples
run: cargo test --locked --all-features --doc

package:
name: Package contents
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Configure Rust
run: |
rustup toolchain install --profile minimal --no-self-update stable
rustup default stable

- name: Verify and list the release package
run: |
cargo package --locked
cargo package --locked --list

semver:
name: Semver against PR base
if: github.event_name == 'pull_request'
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ std = ["dep:nix"]
## Frontend: tokio::Command
tokio1 = ["dep:nix", "dep:futures", "dep:tokio"]

## Tokio pseudo-terminal transport
pty = ["tokio1", "nix/term", "tokio/net"]

## Wrapper: Creation Flags
creation-flags = ["dep:windows", "windows/Win32_System_Threading"]

Expand All @@ -73,8 +76,13 @@ reset-sigmask = []
[package.metadata.docs.rs]
all-features = true
targets = [
"aarch64-linux-android",
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-unknown-freebsd",
"x86_64-unknown-netbsd",
"x86_64-unknown-illumos",
"x86_64-pc-solaris",
"x86_64-pc-windows-msvc",
]

56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,60 @@ let status = child.wait().await?;
dbg!(status);
```

### or in a pseudo-terminal

The non-default `pty` feature enables Tokio PTY transport on Linux, Android, macOS, FreeBSD,
NetBSD 10 and newer, OpenBSD, DragonFly BSD, illumos, and Solaris. It implies `tokio1`, selecting
the Tokio frontend and its terminal dependencies explicitly.

```toml
[dependencies]
process-wrap = { version = "10.0.0", features = ["pty"] }
```

```rust
use process_wrap::tokio::*;
use tokio::io::AsyncReadExt;

let mut command = Command::new("ls");
command.wrap(ProcessSession).wrap(Pty::default());
let mut child = command.spawn()?;
let controller = child
.take_pty_controller()
.expect("a successful PTY spawn installs one controller");
let (input, mut output, _resize) = controller.into_parts();
drop(input);

let drain = tokio::spawn(async move {
let mut bytes = Vec::new();
output.read_to_end(&mut bytes).await?;
Ok::<_, std::io::Error>(bytes)
});
let status = child.wait().await?;
let terminal_bytes = drain.await??;
dbg!(status, terminal_bytes);
```

A PTY has one ordered terminal stream, so standard output and standard error are merged.
`PtyInput` and `PtyOutput` are strong owners of one bidirectional master descriptor, so dropping
either one alone does not half-close the terminal. The terminal hangs up after both are gone;
`PtyResize` is weak and cannot keep it alive. Send the terminal's VEOF character when that is the
desired terminal policy instead of expecting a separate input half-close or clonable force-close
handle.

Child waiting and PTY draining are independent. On most supported Unix systems, descendants can
retain the slave after the direct child exits. On macOS, drain output concurrently with waiting: the
kernel drains queued output as the session leader exits, then revokes the controlling terminal from
its descendants. The transport passes terminal bytes through without owning parent-terminal raw
mode, relays, key handling, VT parsing, scrollback, or pager policy.

A bare PTY creates the required session. `ProcessGroup::leader()` and `ProcessSession` each preserve
group-wide signalling while the direct child is live; waiting still follows that direct child.
`ProcessGroup::attach_to(...)` and explicitly registering both wrappers return `InvalidInput`.
`ResetSigmask` composes normally. `KillOnDrop` remains Tokio's direct-child behavior—it does not
promise to kill an entire group or session. Spawning returns the ordinary boxed Tokio child, and
`take_pty_controller()` traverses any outer child wrappers and yields the controller once.

### or with std

```toml
Expand Down Expand Up @@ -342,6 +396,8 @@ Both can exist at the same time, but generally you should use one or the other.
- `kill-on-drop`: **default**, enables the [kill on drop](#kill-on-drop) wrapper.
- `process-group`: **default**, enables the [process group](#process-group) wrapper.
- `process-session`: **default**, enables the [process session](#process-session) wrapper.
- `pty`: enables the Tokio [pseudo-terminal transport](#or-in-a-pseudo-terminal) and implies
`tokio1`.
- `reset-sigmask`: enables the [reset signal mask](#reset-signal-mask) wrapper.

### Diagnostics
Expand Down
34 changes: 34 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,40 @@ impl<B: Backend> SpawnAttempt<B> {
self.native_command_mut()
}

/// Take the fresh native command for an alternate provider without applying native platform setup
/// or making the portable attempt opaque.
///
/// Removing the provider's command leaves the tracked intent intact. If a later lifecycle hook
/// requests native access, the attempt materializes a separate clean command which contains none of
/// the provider's private stdio or child-setup state.
///
/// The provider must apply every accepted portable policy itself before spawning.
#[cfg(all(
feature = "pty",
any(
target_os = "android",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "illumos",
target_os = "linux",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
target_os = "solaris"
)
))]
pub(crate) fn take_native_for_provider_spawn(&mut self) -> B::NativeCommand {
self.materialize_native();
match &mut self.state {
AttemptState::Tracked { native, .. } => native
.take()
.expect("the tracked provider attempt was materialized above"),
AttemptState::NativeOnly(_) => {
unreachable!("portable providers reject native-only attempts before spawning")
}
}
}

pub(crate) fn native_for_explicit_spawn(&mut self) -> &mut B::NativeCommand {
self.make_native_only();
self.prepare_platform();
Expand Down
49 changes: 45 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,9 @@
//!
//! This crate provides a composable process-wrap-owned [`Command`] configuration shared by the std
//! and Tokio frontends. It is a more flexible and composable successor to the `command-group` crate,
//! and is meant to be adaptable to additional use cases: for example spawning processes in PTYs
//! currently requires a different crate (such as `pty-process`) which won't function with
//! `command-group`. Implementing a PTY wrapper for `process-wrap` would instead keep the same API and
//! be composable with the existing process group/session implementations.
//! and is meant to be adaptable to additional use cases. The optional Tokio PTY provider demonstrates
//! that adaptability by keeping terminal process creation in the same wrapper lifecycle as process
//! groups, sessions, signal policy, and custom wrappers.
//!
//! # Usage
//!
Expand Down Expand Up @@ -109,6 +108,47 @@
//! Methods on `Child` mimic those on `process::Child`, but may be customised by the wrappers. For
//! example, `kill` will send a signal to the process group if the `ProcessGroup` wrapper is used.
//!
//! # Pseudo-terminals
//!
//! The non-default `pty` feature selects the Tokio frontend and terminal dependencies. It provides
//! native transport on Linux, Android, macOS, FreeBSD, NetBSD 10 and newer, OpenBSD, DragonFly BSD,
//! illumos, and Solaris; unavailable platforms report `std::io::ErrorKind::Unsupported`.
//!
//! ```rust,no_run
//! # #[cfg(feature = "pty")]
//! # mod example {
//! # fn run() -> std::io::Result<()> {
//! use process_wrap::tokio::{Command, Pty};
//!
//! let mut command = Command::with_new("sh", |command| {
//! command.args(["-c", "printf terminal"]);
//! });
//! command.wrap(Pty::default());
//! let mut child = command.spawn()?;
//! let controller = child
//! .take_pty_controller()
//! .expect("a successful PTY spawn installs one controller");
//! # drop(controller);
//! # Ok(()) }
//! # }
//! # fn main() {}
//! ```
//!
//! A terminal has one ordered output stream, so PTY standard output and standard error are merged.
//! Input and output each strongly own the bidirectional master; resize handles are weak. Dropping one
//! I/O side is not a half-close, and the terminal hangs up only after both are gone. Send VEOF when
//! terminal input policy calls for end-of-file. Waiting for the direct child and draining terminal
//! output are separate lifecycles because descendants may retain the slave. On macOS they should run
//! concurrently while the kernel drains and revokes the terminal during session-leader teardown.
//!
//! The transport owns terminal bytes and resize, not parent-terminal raw mode, relaying, key handling,
//! VT parsing, scrollback, or pager policy. A bare PTY creates its required session.
//! `ProcessGroup::leader()` or `ProcessSession` may independently add group-wide signalling while the
//! direct child is live; waiting continues to follow that child. Attaching to an existing group or
//! explicitly registering both is invalid. `ResetSigmask` composes, while Tokio `KillOnDrop` continues
//! to target only the direct child. The returned boxed child keeps arbitrary outer wrappers, and
//! `take_pty_controller()` traverses them and yields the controller once.
//!
//! # KillOnDrop and CreationFlags
//!
//! Calling native `.kill_on_drop()` or `.creation_flags()` makes a command native-only: those
Expand Down Expand Up @@ -527,6 +567,7 @@
//! - `kill-on-drop`: **default**, enables the kill on drop wrapper (Tokio-only).
//! - `process-group`: **default**, enables the process group wrapper (Unix-only).
//! - `process-session`: **default**, enables the process session wrapper (Unix-only).
//! - `pty`: enables Tokio pseudo-terminal transport and implies `tokio1`.
//! - `reset-sigmask`: enables the sigmask reset wrapper (Unix-only).
//!
//! ## Diagnostics
Expand Down
Loading
Loading