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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ Versioning with Cargo's pre-1.0 compatibility rules.

## [Unreleased]

### 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`.
- Require `SuspendedChild::resume` to observe the expected suspend count of
exactly one; externally changed counts now fail and roll back the process.

### Fixed

- Always join both output reader threads when output capture encounters a
reader error or panic.

## [0.1.0] - 2026-08-02

### Added
Expand Down
6 changes: 6 additions & 0 deletions docs/adr/0003-attribute-lifetime-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ allocation word alignment and each normal value stable owned storage. Retain
both until after `DeleteProcThreadAttributeList`. Pass the borrowed
pseudoconsole value according to its Win32 contract.

Derive the native attribute-list pointer from the backing allocation whenever
it is needed. Do not store a second, self-reference-like raw pointer. Snapshot
the `HPCON` value when `SpawnOptions::pseudoconsole` is called while retaining
the original capability lifetime with a private marker.

`SpawnOptions<'a>` carries the lifetime of borrowed Jobs, parent process, and
pseudoconsole capabilities; reusable `Command` does not borrow them.

Expand All @@ -24,4 +29,5 @@ pseudoconsole capabilities; reusable `Command` does not borrow them.
- No public raw attribute API or self-referential builder.
- Attribute pointers cannot outlive their values.
- Delete the list before its values and backing storage.
- Moving `AttributeList` cannot stale a duplicated raw pointer field.
- Keep the unsafe lifetime proof inside `sys`.
10 changes: 8 additions & 2 deletions docs/adr/0006-conpty-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ is public so terminal libraries can implement the bridge.
ConPTY creation, pipes, waits, Tokio integration, and lifecycle. windows-spawn
owns command lowering, attributes, Jobs, and `CreateProcessW`.

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.

## Consequences

- Ordinary users pass a safe borrow and do not construct raw `HPCON` values.
- windows-spawn does not depend on a terminal library.
- Pseudoconsole use conflicts with explicit standard streams and creates the
process with invalid ordinary standard handles, as required by ConPTY.
- Pseudoconsole use conflicts with explicit standard streams and leaves the
ordinary startup handles unused.
11 changes: 10 additions & 1 deletion docs/adr/0007-spawn-transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,23 @@ distributed cleanup state permits leaks and double closes.
Commit moves only the process handle, public pipe endpoints, lifecycle policy,
and retained Job ownership into `Child`; thread and temporary handles close.

Private `Running` and `Suspended` marker types parameterize both the plan and
transaction. Each state exposes only its corresponding commit, so a wrong
commit is unrepresentable instead of rejected at runtime. A `HandleTransfer`
owner keeps the effective parent, local or remote duplicates, and inheritance
list together.

`SuspendedChild` represents the suspended state. Its consuming `resume` is the
only normal transition to `Child`; dropping it first terminates the process or
its private Job.
its private Job. Resume succeeds only when `ResumeThread` reports the expected
previous suspend count of exactly one. External suspension or resumption makes
the transition fail and the process roll back.

## Consequences

- No shared ownership of raw handles.
- No public partial-initialization state.
- Running and suspended commits are selected by the type system.
- Cleanup follows ownership instead of error-site flags.
- Failure-injection and handle-count tests can verify rollback without exposing
transaction internals.
11 changes: 9 additions & 2 deletions docs/crate.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,14 @@ pipes close and when terminal EOF occurs.
- [`Child::wait_with_output`] drains stdout and stderr concurrently. With
`KillTree`, it terminates remaining descendants after the root exits before
joining the readers. This guarantees pipe EOF even when a grandchild
inherited a writer.
inherited a writer. Both reader threads are joined even when one reader
fails or panics.
- Dropping [`SuspendedChild`] before [`SuspendedChild::resume`] terminates the
suspended process. Its ID, process handle, and primary-thread handle are
available before resume. `resume(self)` is consuming, so a second transition
is unrepresentable.
is unrepresentable. The transition requires the primary thread's previous
suspend count to be exactly one; external changes are rejected and rolled
back.

# Transaction and security boundary

Expand All @@ -92,6 +95,10 @@ transaction owns pipes, temporary duplicates, attributes, Jobs, and
process/thread handles. Success transfers durable resources to [`Child`] or
[`SuspendedChild`]; errors roll back the rest.

The private validation plan and transaction carry running or suspended marker
types. Their state-specific commits make a mismatched internal transition
unrepresentable.

This crate is not a sandbox, cross-platform process facade, async runtime, or
process supervisor. Tokens, ACLs, `AppContainer`, LPAC, capability SIDs, and
async supervision are outside its scope. Callers building an isolation boundary
Expand Down
61 changes: 43 additions & 18 deletions src/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,7 @@ impl Child {
.kill_job
.as_ref()
.map_or(Ok(()), |job| job.terminate(1));
let stdout = join_reader(stdout_reader)?;
let stderr = join_reader(stderr_reader)?;
let (stdout, stderr) = join_readers(stdout_reader, stderr_reader)?;
termination?;

Ok(Output {
Expand Down Expand Up @@ -222,6 +221,15 @@ fn join_reader(reader: Option<thread::JoinHandle<io::Result<Vec<u8>>>>) -> io::R
}
}

fn join_readers(
stdout: Option<thread::JoinHandle<io::Result<Vec<u8>>>>,
stderr: Option<thread::JoinHandle<io::Result<Vec<u8>>>>,
) -> io::Result<(Vec<u8>, Vec<u8>)> {
let stdout = join_reader(stdout);
let stderr = join_reader(stderr);
Ok((stdout?, stderr?))
}

/// A process whose primary thread has not yet been resumed.
///
/// Dropping this value without resuming always terminates the process.
Expand All @@ -239,14 +247,14 @@ fn join_reader(reader: Option<thread::JoinHandle<io::Result<Vec<u8>>>>) -> io::R
#[must_use = "dropping a suspended child terminates it"]
pub struct SuspendedChild {
child: Option<Child>,
main_thread: Option<OwnedHandle>,
main_thread: OwnedHandle,
}

impl SuspendedChild {
pub(crate) fn new(child: Child, main_thread: OwnedHandle) -> Self {
Self {
child: Some(child),
main_thread: Some(main_thread),
main_thread,
}
}

Expand All @@ -269,30 +277,27 @@ impl SuspendedChild {
/// This handle is available for supported thread configuration and
/// inspection before [`Self::resume`] consumes the suspended state.
///
/// # Panics
///
/// Panics only if an internal ownership invariant was violated and the
/// primary thread was removed before this suspended value was consumed.
#[must_use]
pub fn primary_thread_handle(&self) -> BorrowedHandle<'_> {
self.main_thread
.as_ref()
.expect("a suspended child owns its primary thread until resume")
.as_handle()
self.main_thread.as_handle()
}

/// Resumes the primary thread and transitions to an ordinary [`Child`].
///
/// # Errors
///
/// Returns the operating-system error when the primary thread cannot be
/// resumed. The suspended process is then terminated during rollback.
/// resumed. It also returns `InvalidData` when external suspension or
/// resumption changed the expected suspend count of exactly one. The
/// process is terminated during either rollback.
pub fn resume(mut self) -> io::Result<Child> {
let main_thread = self
.main_thread
.take()
.ok_or_else(|| io::Error::other("suspended child lost its primary thread"))?;
sys::resume_thread(main_thread.as_handle())?;
let previous = sys::resume_thread(self.main_thread.as_handle())?;
if previous != 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("primary thread suspend count was {previous}, expected 1"),
));
}
self.child
.take()
.ok_or_else(|| io::Error::other("suspended child lost its process"))
Expand All @@ -318,6 +323,9 @@ impl Drop for SuspendedChild {

#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use super::*;

#[test]
Expand All @@ -329,4 +337,21 @@ mod tests {
io::ErrorKind::Other
);
}

#[test]
fn both_output_readers_are_joined_when_the_first_panics() {
let joined = Arc::new(AtomicBool::new(false));
let stdout = thread::spawn(|| -> io::Result<Vec<u8>> { panic!("stdout panic") });
let stderr_joined = Arc::clone(&joined);
let stderr = thread::spawn(move || {
stderr_joined.store(true, Ordering::Release);
Ok(Vec::new())
});

assert_eq!(
join_readers(Some(stdout), Some(stderr)).unwrap_err().kind(),
io::ErrorKind::Other
);
assert!(joined.load(Ordering::Acquire));
}
}
14 changes: 7 additions & 7 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::process::{ExitStatus, Output};
use crate::child::{Child, SuspendedChild};
use crate::handles::Stdio;
use crate::options::SpawnOptions;
use crate::plan::{IoMode, SpawnMode, SpawnPlan};
use crate::plan::{IoMode, SpawnPlan};
use crate::sys;
use crate::transaction::SpawnTransaction;

Expand Down Expand Up @@ -214,8 +214,8 @@ impl Command {
///
/// Returns validation, resource-acquisition, or process-creation errors.
pub fn spawn_with(&mut self, options: SpawnOptions<'_>) -> io::Result<Child> {
let plan = SpawnPlan::new(self, options, SpawnMode::Running, IoMode::Spawn)?;
SpawnTransaction::new(&plan)?.commit_child()
let plan = SpawnPlan::new_running(self, options, IoMode::Spawn)?;
Ok(SpawnTransaction::new(&plan)?.commit_child())
}

/// Spawns in the suspended type state with default options.
Expand All @@ -236,8 +236,8 @@ impl Command {
&mut self,
options: SpawnOptions<'_>,
) -> io::Result<SuspendedChild> {
let plan = SpawnPlan::new(self, options, SpawnMode::Suspended, IoMode::Spawn)?;
SpawnTransaction::new(&plan)?.commit_suspended()
let plan = SpawnPlan::new_suspended(self, options, IoMode::Spawn)?;
Ok(SpawnTransaction::new(&plan)?.commit_suspended())
}

/// Runs the process and waits for its status using default options.
Expand Down Expand Up @@ -273,9 +273,9 @@ impl Command {
///
/// Returns an error from spawning, waiting, reading, or Job termination.
pub fn output_with(&mut self, options: SpawnOptions<'_>) -> io::Result<Output> {
let plan = SpawnPlan::new(self, options, SpawnMode::Running, IoMode::Output)?;
let plan = SpawnPlan::new_running(self, options, IoMode::Output)?;
SpawnTransaction::new(&plan)?
.commit_child()?
.commit_child()
.wait_with_output()
}
}
1 change: 0 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ mod child;
#[cfg(windows)]
mod command;
#[cfg(windows)]
#[allow(unsafe_code)]
mod handles;
#[cfg(windows)]
mod mitigation;
Expand Down
Loading