Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ updates:
- rust
commit-message:
prefix: deps
# `just msrv` and the Rust 1.75 test legs check the whole workspace, so an
# xtask dependency that raises its own MSRV above ours breaks CI even though
# it never ships. sha2 0.11 requires Rust 1.85; the checksum code already
# handles both generations, so this only needs lifting when the crate MSRV
# moves past 1.85.
ignore:
- dependency-name: sha2
update-types:
- version-update:semver-minor
- version-update:semver-major
groups:
cargo-minor-and-patch:
applies-to: version-updates
Expand Down
18 changes: 17 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ jobs:
tool: just@1.57.0
- run: just test

i686-smoke:
name: Windows i686 execution smoke
runs-on: windows-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
targets: i686-pc-windows-msvc
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Run the ConPTY stdio isolation regression as a 32-bit process
run: >-
cargo test --target i686-pc-windows-msvc --test windows_spawn
pseudoconsole_regular_stdio_stays_off_parent_pipes -- --exact

linux:
name: Linux empty API
runs-on: ubuntu-latest
Expand Down Expand Up @@ -107,7 +123,7 @@ jobs:
ci-required:
name: CI required
if: always()
needs: [hygiene, test, linux, coverage, package]
needs: [hygiene, test, i686-smoke, linux, coverage, package]
runs-on: ubuntu-latest
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
Expand Down
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,35 @@ Versioning with Cargo's pre-1.0 compatibility rules.

## [Unreleased]

### Added

- A compiled, executed `Command` documentation example. The crate previously had
only `compile_fail` boundary pins, so no usage example was ever type-checked.
- `clippy::undocumented_unsafe_blocks` is denied, making CONTRIBUTING's
"every `unsafe` block carries a specific safety justification" rule
machine-checked instead of a convention.
- A `_typos.toml` so the spell-check gate has a checked-in configuration
matching the sibling repositories.

### Changed

- Strengthened process creation with private running/suspended typestates,
unified handle-transfer ownership, and value-based pseudoconsole storage.
- Aligned ConPTY startup with the Windows reference sequence by leaving
ordinary standard handles unused and omitting `STARTF_USESTDHANDLES`.
- Aligned ConPTY startup with Microsoft Terminal by setting
`STARTF_USESTDHANDLES` while keeping all ordinary standard handles null.
- Require `SuspendedChild::resume` to observe the expected suspend count of
exactly one; externally changed counts now fail and roll back the process.

### Fixed

- Prevented ConPTY children from reading or writing the parent's redirected
standard streams instead of the pseudoconsole channels.
- Always join both output reader threads when output capture encounters a
reader error or panic.
- The release-artifact checksum helper no longer relies on `LowerHex` being
implemented for the digest output, so it builds against both `sha2` 0.10 and
0.11. The bump itself stays deferred because `sha2` 0.11 requires Rust 1.85,
above this crate's 1.75 minimum.

## [0.1.0] - 2026-08-02

Expand Down
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,15 @@ unused_qualifications = "deny"
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }

# CONTRIBUTING requires a specific safety justification on every `unsafe` block.
# This restriction lint is what makes that rule machine-checked rather than a
# convention, so a missing justification fails the build.
#
# `multiple_unsafe_ops_per_block` is deliberately not enabled: several blocks
# here acquire two OS resources that must be adopted together to stay
# exception-safe (see `pipe`), and splitting them would widen the window in
# which one handle can leak.
undocumented_unsafe_blocks = "deny"

[lints]
workspace = true
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ Non-Windows targets expose no public API.

## Installation

```toml
[dependencies]
windows-spawn = "0.1"
```
`windows-spawn` 0.1.0 is not published yet, so registry installation is not
available. This repository is self-contained: it neither requires nor checks
out a downstream terminal crate. A downstream that validates the unpublished
version may temporarily supply its own local Cargo path override; that
bootstrap belongs to the downstream repository and is removed after 0.1.0 is
published.

## Minimal example

Expand Down
1 change: 1 addition & 0 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ path = [
".cargo/**",
".gitattributes",
".gitignore",
"_typos.toml",
"Cargo.toml",
"CHANGELOG.md",
"CONTRIBUTING.md",
Expand Down
11 changes: 11 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Yasunobu Sakashita
#
# SPDX-License-Identifier: MIT OR Apache-2.0

[files]
ignore-hidden = false
extend-exclude = [
".git/",
"target/",
"mutants.out/",
]
34 changes: 26 additions & 8 deletions docs/adr/0006-conpty-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,37 @@ Use the unsafe `AsPseudoConsole` trait. Implementors guarantee a stable,
nonzero, live `HPCON` for the full borrow and retain ownership. The raw method
is public so terminal libraries can implement the bridge.

`conpty-oxide` depends on windows-spawn and implements the trait. It owns
ConPTY creation, pipes, waits, Tokio integration, and lifecycle. windows-spawn
owns command lowering, attributes, Jobs, and `CreateProcessW`.
A terminal library may depend on windows-spawn and implement the trait. That
downstream owns ConPTY creation, pipes, waits, runtime integration, and
lifecycle. windows-spawn owns only command lowering, attributes, Jobs, and
`CreateProcessW`; it has no dependency on, checkout of, or CI pin to a
particular terminal library.

The builder snapshots the `HPCON` numeric value and keeps only a lifetime
marker, so the stored options do not require dynamic dispatch. During process
creation ConPTY has no ordinary standard-handle set: the zero-initialized
startup fields remain zero and `STARTF_USESTDHANDLES` is not set, matching the
Microsoft ConPTY creation sequence.
creation ConPTY uses an explicit startup-I/O mode: `STARTF_USESTDHANDLES` is
set while `hStdInput`, `hStdOutput`, and `hStdError` remain null. No standard
handle is added to `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`, and a spawn with no
other handle transfer passes `bInheritHandles = FALSE`.

This deliberately follows the production `ConptyConnection` implementation in
Microsoft Terminal. The shorter Microsoft Learn walkthrough zero-initializes
`STARTUPINFOEXW` and leaves `STARTF_USESTDHANDLES` clear. That sample explains
the pseudoconsole attribute but does not isolate a hosted process from the
calling process's normal standard-handle slots. In a parent with redirected
standard I/O, following it literally can let the child use the parent's pipes
instead of ConPTY. The explicit null slots make that ownership boundary
testable and deterministic.

## Consequences

- Ordinary users pass a safe borrow and do not construct raw `HPCON` values.
- windows-spawn does not depend on a terminal library.
- Dependency and validation flow only from a terminal library to
windows-spawn; windows-spawn does not name a downstream integration target.
- Pseudoconsole use conflicts with explicit standard streams and leaves the
ordinary startup handles unused.
ordinary startup handles explicitly null.

## References

- [Microsoft Terminal `ConptyConnection.cpp`](https://github.com/microsoft/terminal/blob/fbda436dc654cf551dd196b2667ef95d3e0a7262/src/cascadia/TerminalConnection/ConptyConnection.cpp)
- [Creating a Pseudoconsole session](https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session)
5 changes: 4 additions & 1 deletion docs/crate.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ See
[`SpawnOptions`] borrows one-spawn capabilities such as Jobs, an alternate
parent, or a pseudoconsole. A borrowed `ConPTY` remains owned by the terminal
library implementing [`AsPseudoConsole`]. That library defines when terminal
pipes close and when terminal EOF occurs.
pipes close and when terminal EOF occurs. Pseudoconsole process creation sets
`STARTF_USESTDHANDLES` with all three standard-handle slots null and does not
put standard handles in the inheritance list. This prevents a hosted child
from falling back to redirected standard handles owned by the parent.

# Drop, wait, and EOF contract

Expand Down
20 changes: 20 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ pub(crate) enum EnvValue {
/// privately duplicated when configured. Each spawn duplicates those handles
/// again into the actual parent process and only then lowers their numeric
/// values to decimal text.
///
/// # Examples
///
/// Run a command to completion and capture what it wrote, terminating any
/// descendants it leaves behind:
///
/// ```
/// use windows_spawn::{Command, DropPolicy, SpawnOptions};
///
/// // `.bat` and `.cmd` are rejected, so a shell boundary is always explicit.
/// let shell = std::env::var_os("COMSPEC").expect("COMSPEC is set on Windows");
/// let mut command = Command::new(shell);
/// command.args(["/D", "/S", "/C"]).raw_arg("echo hello");
///
/// let output = command.output_with(SpawnOptions::new().drop_policy(DropPolicy::KillTree))?;
///
/// assert!(output.status.success());
/// assert!(String::from_utf8_lossy(&output.stdout).contains("hello"));
/// # Ok::<(), std::io::Error>(())
/// ```
#[derive(Debug)]
pub struct Command {
pub(crate) program: OsString,
Expand Down
18 changes: 13 additions & 5 deletions src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,17 @@ pub(crate) struct StandardHandles<T> {
pub(crate) stderr: T,
}

#[derive(Debug)]
pub(crate) enum StandardIo<'a> {
Ordinary(StandardHandles<StdioSpec<'a>>),
PseudoConsole,
}

#[derive(Debug)]
pub(crate) struct SpawnPlan<'command, 'options, M> {
pub(crate) command: &'command Command,
pub(crate) options: SpawnOptions<'options>,
pub(crate) stdio: Option<StandardHandles<StdioSpec<'command>>>,
pub(crate) stdio: StandardIo<'command>,
state: PhantomData<M>,
}

Expand Down Expand Up @@ -112,7 +118,7 @@ impl<'command, 'options, M> SpawnPlan<'command, 'options, M> {
));
}
let stdio = if options.pseudoconsole_raw().is_some() {
None
StandardIo::PseudoConsole
} else {
let handles = match io_mode {
IoMode::Spawn => StandardHandles {
Expand All @@ -126,7 +132,7 @@ impl<'command, 'options, M> SpawnPlan<'command, 'options, M> {
stderr: configured_or(command.stderr.as_ref(), StdioSpec::Piped),
},
};
Some(handles)
StandardIo::Ordinary(handles)
};

Ok(Self {
Expand Down Expand Up @@ -398,7 +404,9 @@ mod tests {
fn successful_plans_choose_the_expected_stdio_modes() {
let command = Command::new("cmd.exe");
let output = SpawnPlan::new_running(&command, SpawnOptions::new(), IoMode::Output).unwrap();
let output_stdio = output.stdio.unwrap();
let StandardIo::Ordinary(output_stdio) = output.stdio else {
panic!("output capture must use ordinary standard I/O");
};
assert!(matches!(output_stdio.stdin, StdioSpec::Null));
assert!(matches!(output_stdio.stdout, StdioSpec::Piped));
assert!(matches!(output_stdio.stderr, StdioSpec::Piped));
Expand All @@ -410,6 +418,6 @@ mod tests {
IoMode::Spawn,
)
.unwrap();
assert!(pcon.stdio.is_none());
assert!(matches!(pcon.stdio, StandardIo::PseudoConsole));
}
}
Loading