diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c7f918..c33aee3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,6 @@ jobs: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@1.88.0 - run: cargo test --all-targets --all-features + - name: Windows containment runtime evidence + if: runner.os == 'Windows' + run: cargo test --test contained_spawn -- --nocapture diff --git a/Cargo.lock b/Cargo.lock index 43117af..04ff866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,7 +705,7 @@ dependencies = [ [[package]] name = "sysprims-core" version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +source = "git+https://github.com/3leaps/sysprims?rev=7e5cc03847029dbd316d9f8c0887997bf64a247c#7e5cc03847029dbd316d9f8c0887997bf64a247c" dependencies = [ "rsfulmen", "thiserror", @@ -715,7 +715,7 @@ dependencies = [ [[package]] name = "sysprims-proc" version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +source = "git+https://github.com/3leaps/sysprims?rev=7e5cc03847029dbd316d9f8c0887997bf64a247c#7e5cc03847029dbd316d9f8c0887997bf64a247c" dependencies = [ "libc", "serde", @@ -753,7 +753,7 @@ dependencies = [ [[package]] name = "sysprims-session" version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +source = "git+https://github.com/3leaps/sysprims?rev=7e5cc03847029dbd316d9f8c0887997bf64a247c#7e5cc03847029dbd316d9f8c0887997bf64a247c" dependencies = [ "libc", "sysprims-core", @@ -762,7 +762,7 @@ dependencies = [ [[package]] name = "sysprims-signal" version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +source = "git+https://github.com/3leaps/sysprims?rev=7e5cc03847029dbd316d9f8c0887997bf64a247c#7e5cc03847029dbd316d9f8c0887997bf64a247c" dependencies = [ "libc", "rsfulmen", @@ -773,7 +773,7 @@ dependencies = [ [[package]] name = "sysprims-timeout" version = "0.2.2" -source = "git+https://github.com/3leaps/sysprims?rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff#1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +source = "git+https://github.com/3leaps/sysprims?rev=7e5cc03847029dbd316d9f8c0887997bf64a247c#7e5cc03847029dbd316d9f8c0887997bf64a247c" dependencies = [ "libc", "rsfulmen", diff --git a/Cargo.toml b/Cargo.toml index 989cda8..40265be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ version = "1.1" [dependencies.sysprims-timeout] git = "https://github.com/3leaps/sysprims" -rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +rev = "7e5cc03847029dbd316d9f8c0887997bf64a247c" version = "=0.2.2" [dev-dependencies.futures] @@ -131,5 +131,5 @@ version = "0.10" [target."cfg(unix)".dependencies.sysprims-session] git = "https://github.com/3leaps/sysprims" -rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" +rev = "7e5cc03847029dbd316d9f8c0887997bf64a247c" version = "=0.2.2" diff --git a/Cargo.toml.orig b/Cargo.toml.orig index b703551..e154045 100644 --- a/Cargo.toml.orig +++ b/Cargo.toml.orig @@ -22,7 +22,7 @@ shell-words = "1.1" serde_derive = {version="1.0", optional=true} serde = {version="1.0", optional=true} serial2 = "0.2" -sysprims-timeout = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" } +sysprims-timeout = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", rev = "7e5cc03847029dbd316d9f8c0887997bf64a247c" } [features] default = [] @@ -43,7 +43,7 @@ winapi = { version = "0.3", features = [ winreg = "0.10" [target."cfg(unix)".dependencies] -sysprims-session = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", rev = "1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff" } +sysprims-session = { version = "=0.2.2", git = "https://github.com/3leaps/sysprims", rev = "7e5cc03847029dbd316d9f8c0887997bf64a247c" } [dev-dependencies] smol = "2.0" diff --git a/Makefile b/Makefile index 59d253d..a2ccc8a 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: help check test-owned-pty-empty test-diabolical +.PHONY: help check check-candidate test-owned-pty-empty test-diabolical help: @printf '%s\n' \ 'check Run formatting, lint, and host-safe tests' \ + 'check-candidate Check the exact sibling sysprims candidate' \ 'test-owned-pty-empty Prove explicit-close and natural-exit PTY cleanup' \ 'test-diabolical Run hostile containment scenes in disposable Docker' @@ -11,6 +12,9 @@ check: cargo clippy --all-targets --all-features -- -D warnings cargo test --all-targets --all-features +check-candidate: + ./scripts/run-candidate-check.sh + test-owned-pty-empty: ./scripts/run-owned-pty-empty.sh diff --git a/README.md b/README.md index 478b80a..2386d37 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,13 @@ task runner that: 5. reads containment, completion, and reap evidence with no process-group, Job, or wait glue of your own. -On Unix today, `SlavePty::spawn_contained_command` installs the prepared -sysprims acquisition hook in the PTY-owned spawn, validates its sealed -same-spawn receipt, and returns an owned `ContainmentGuard`. +On Unix, `SlavePty::spawn_contained_command` installs the prepared sysprims +acquisition hook in the PTY-owned spawn and validates its sealed same-spawn +receipt. On Windows, the ConPTY adapter creates the child suspended exactly +once, assigns and verifies that exact process in a prepared non-breakaway Job, +transfers sole process/Job authority to the guard, and resumes the primary +thread exactly once. Both paths return an owned +`ContainmentGuard`. ## What this is not @@ -62,10 +66,11 @@ Generic process identity, Job/group evidence, and receipts live in lifecycle transition. - Completion evidence is reported independently as `Empty`, `Survivors`, or `Unknown`. +- Boundary strength is independent too: Unix reports `cooperative_group`; the + pre-execution Windows Job path reports `kernel_enforced_job`. - A cooperative Unix descendant can still leave its acquired group. Guaranteed acquisition is not an OS-enforced non-escape guarantee. -- Unsupported implementations, including Windows in this tree, reject the - guarded API before spawning. +- Unsupported PTY implementations reject the guarded API before spawning. ## Owned real-PTY examples diff --git a/UPSTREAM.md b/UPSTREAM.md index 503b54f..6eb7616 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -15,13 +15,15 @@ The compatibility delta is intentionally narrow: - an object-safe guarded-spawn method on `SlavePty`; - a Unix implementation whose prepared sysprims acquisition hook replaces portable-pty's internal `setsid` slot; +- a Windows ConPTY transaction that creates one child suspended, assigns and + verifies that exact process in a non-breakaway Job, then resumes once; - an exact-child adapter owned by `sysprims_timeout::ContainmentGuard`; - a pre-spawn parent recovery owner that retains the opaque exact child for bounded failure attempts and nonblocking error destruction; -- pre-spawn rejection for unsupported implementations, including Windows; +- pre-spawn rejection for unsupported PTY implementations; - lifecycle, real-PTY, allocator-lock, and compatibility tests; and - mechanical current-Clippy fixes that do not change behavior. The minimum sysprims contract is `v0.2.2`, commit -`1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff`. Compatibility is also checked +`7e5cc03847029dbd316d9f8c0887997bf64a247c`. Compatibility is also checked against that exact sysprims revision before a companion release is cut. diff --git a/scripts/run-candidate-check.sh b/scripts/run-candidate-check.sh new file mode 100755 index 0000000..5c1acf0 --- /dev/null +++ b/scripts/run-candidate-check.sh @@ -0,0 +1,69 @@ +#!/bin/sh +set -eu + +companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} +reviewed_sysprims_rev=7e5cc03847029dbd316d9f8c0887997bf64a247c + +actual_sysprims_rev=$(git -C "$sysprims_root" rev-parse HEAD) +if [ "$actual_sysprims_rev" != "$reviewed_sysprims_rev" ]; then + echo "sysprims must be at reviewed revision $reviewed_sysprims_rev" >&2 + echo "found $actual_sysprims_rev" >&2 + exit 2 +fi +if [ -n "$(git -C "$sysprims_root" status --short)" ]; then + echo "sysprims worktree must be clean" >&2 + exit 2 +fi + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/sysprims-pty-candidate.XXXXXX") +trap 'rm -rf "$work_root"' EXIT HUP INT TERM + +mkdir -p "$work_root/companion" +tar -C "$companion_root" \ + --exclude .git \ + --exclude target \ + -cf - . | tar -C "$work_root/companion" -xf - + +awk \ + -v timeout_path="$sysprims_root/crates/sysprims-timeout" \ + -v session_path="$sysprims_root/crates/sysprims-session" \ + ' + $0 == "[dependencies.sysprims-timeout]" { + print + print "path = \"" timeout_path "\"" + replacing_timeout = 1 + next + } + replacing_timeout { + if ($0 == "") { + print + replacing_timeout = 0 + } + next + } + $0 == "[target.\"cfg(unix)\".dependencies.sysprims-session]" { + print + print "path = \"" session_path "\"" + replacing_session = 1 + next + } + replacing_session { next } + { print } + ' "$work_root/companion/Cargo.toml" >"$work_root/Cargo.toml" +mv "$work_root/Cargo.toml" "$work_root/companion/Cargo.toml" + +echo "sysprims candidate: $reviewed_sysprims_rev" +cd "$work_root/companion" +CARGO_TARGET_DIR="$companion_root/target/candidate-check" \ + cargo fmt --all -- --check +CARGO_TARGET_DIR="$companion_root/target/candidate-check" \ + cargo clippy --all-targets --all-features -- -D warnings +CARGO_TARGET_DIR="$companion_root/target/candidate-check" \ + cargo test --all-targets --all-features +CARGO_TARGET_DIR="$companion_root/target/candidate-check-windows-x64" \ + RUSTFLAGS=-Dwarnings \ + cargo check --all-targets --all-features --target x86_64-pc-windows-msvc +CARGO_TARGET_DIR="$companion_root/target/candidate-check-windows-arm64" \ + RUSTFLAGS=-Dwarnings \ + cargo check --all-targets --all-features --target aarch64-pc-windows-msvc diff --git a/scripts/run-diabolical-docker.sh b/scripts/run-diabolical-docker.sh index 210676d..ce92fb9 100755 --- a/scripts/run-diabolical-docker.sh +++ b/scripts/run-diabolical-docker.sh @@ -3,7 +3,7 @@ set -eu companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} -reviewed_sysprims_rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff +reviewed_sysprims_rev=7e5cc03847029dbd316d9f8c0887997bf64a247c if ! docker info >/dev/null 2>&1; then echo "docker daemon unavailable; start the reviewed disposable runtime" >&2 diff --git a/scripts/run-owned-pty-empty.sh b/scripts/run-owned-pty-empty.sh index 2208bf7..10e0392 100755 --- a/scripts/run-owned-pty-empty.sh +++ b/scripts/run-owned-pty-empty.sh @@ -3,7 +3,7 @@ set -eu companion_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) sysprims_root=${SYSPRIMS_ROOT:-"$companion_root/../sysprims"} -reviewed_sysprims_rev=1e56e8b7fbe004a0a1b028b81ba11193e1dd71ff +reviewed_sysprims_rev=7e5cc03847029dbd316d9f8c0887997bf64a247c case "$(uname -s)" in Darwin|Linux) ;; diff --git a/src/lib.rs b/src/lib.rs index c3743b6..998901c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,7 +51,10 @@ use std::os::windows::prelude::{AsRawHandle, RawHandle}; /// While containment is active, the guard keeps the child handle private. #[derive(Debug)] pub struct ContainedPtyChild { + #[cfg(unix)] pub(crate) child: std::process::Child, + #[cfg(windows)] + pub(crate) child: win::WinChild, } impl ContainedPtyChild { @@ -59,6 +62,11 @@ impl ContainedPtyChild { pub(crate) fn new(child: std::process::Child) -> Self { Self { child } } + + #[cfg(windows)] + pub(crate) fn new(child: win::WinChild) -> Self { + Self { child } + } } /// Stage at which a guarded PTY spawn failed. @@ -66,13 +74,14 @@ impl ContainedPtyChild { pub enum ContainedPtySpawnErrorStage { /// No child was created. BeforeSpawn, - /// The same-spawn receipt could not be validated. + /// Spawn-time group/Job assignment evidence could not be sealed. Receipt, /// The exact child and receipt could not be adopted into a guard. Adoption, + /// The assigned child could not be resumed and was resolved through its Job. + Resume, } -#[cfg(unix)] enum FailedChildRecoveryCommand { Adopt(ContainedPtyChild), Attempt { @@ -83,21 +92,21 @@ enum FailedChildRecoveryCommand { }, } -#[cfg(all(test, unix))] +#[cfg(test)] #[derive(Clone, Copy)] enum FailedChildRecoveryFault { None, + #[cfg(unix)] KillError, + #[cfg(unix)] Deadline, } -#[cfg(unix)] struct FailedChildRecovery { sender: std::sync::mpsc::Sender, pending: std::sync::Arc, } -#[cfg(unix)] impl FailedChildRecovery { fn prepare() -> IoResult { let (sender, receiver) = std::sync::mpsc::channel(); @@ -163,7 +172,7 @@ impl FailedChildRecovery { })? } - #[cfg(test)] + #[cfg(all(test, unix))] fn attempt_with_fault( &self, timeout: std::time::Duration, @@ -177,7 +186,6 @@ impl FailedChildRecovery { } } -#[cfg(unix)] fn failed_child_recovery_worker( receiver: std::sync::mpsc::Receiver, pending: std::sync::Arc, @@ -223,7 +231,6 @@ fn failed_child_recovery_worker( } } -#[cfg(unix)] fn attempt_failed_child_recovery( child: &mut Option, timeout: std::time::Duration, @@ -234,19 +241,21 @@ fn attempt_failed_child_recovery( None => return Ok(true), }; - if owned_child.child.try_wait()?.is_some() { + if Child::try_wait(owned_child)?.is_some() { child.take(); return Ok(true); } #[cfg(test)] match fault { + #[cfg(unix)] FailedChildRecoveryFault::KillError => { return Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, "injected exact-child kill failure", )); } + #[cfg(unix)] FailedChildRecoveryFault::Deadline => { std::thread::sleep(timeout.min(std::time::Duration::from_millis(10))); return Ok(false); @@ -254,14 +263,14 @@ fn attempt_failed_child_recovery( FailedChildRecoveryFault::None => {} } - // This is the exact spawn-owned std::process::Child handle. Never + // This is the exact spawn-owned child handle. Never // reconstruct signal authority from an observed PID. - owned_child.child.kill()?; + ChildKiller::kill(owned_child)?; let timeout = timeout.min(std::time::Duration::from_secs(2)); let deadline = std::time::Instant::now() + timeout; loop { - match owned_child.child.try_wait()? { + match Child::try_wait(owned_child)? { Some(_) => { child.take(); return Ok(true); @@ -274,12 +283,11 @@ fn attempt_failed_child_recovery( } } -#[cfg(unix)] fn bounded_local_recovery_or_abort(mut child: ContainedPtyChild) -> ! { - let _ = child.child.kill(); + let _ = ChildKiller::kill(&mut child); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); loop { - match child.child.try_wait() { + match Child::try_wait(&mut child) { Ok(Some(_)) => { std::process::abort(); } @@ -300,7 +308,6 @@ fn bounded_local_recovery_or_abort(mut child: ContainedPtyChild) -> ! { pub struct ContainedPtySpawnError { stage: ContainedPtySpawnErrorStage, source: Error, - #[cfg(unix)] recovery: Option, } @@ -309,12 +316,10 @@ impl ContainedPtySpawnError { Self { stage: ContainedPtySpawnErrorStage::BeforeSpawn, source, - #[cfg(unix)] recovery: None, } } - #[cfg(unix)] pub(crate) fn after_spawn( stage: ContainedPtySpawnErrorStage, source: Error, @@ -355,17 +360,9 @@ impl ContainedPtySpawnError { /// Whether exact-child recovery is still in progress. pub fn recovery_pending(&self) -> bool { - #[cfg(unix)] - { - self.recovery - .as_ref() - .is_some_and(FailedChildRecovery::is_pending) - } - - #[cfg(not(unix))] - { - false - } + self.recovery + .as_ref() + .is_some_and(FailedChildRecovery::is_pending) } /// Make one bounded exact-child recovery attempt. @@ -373,18 +370,9 @@ impl ContainedPtySpawnError { /// The attempt is capped at two seconds. Returns `true` when no child /// remains to reap. pub fn recover(&mut self, timeout: std::time::Duration) -> IoResult { - #[cfg(unix)] - { - match self.recovery.as_mut() { - Some(recovery) => recovery.attempt(timeout), - None => Ok(true), - } - } - - #[cfg(not(unix))] - { - let _ = timeout; - Ok(true) + match self.recovery.as_mut() { + Some(recovery) => recovery.attempt(timeout), + None => Ok(true), } } } @@ -534,8 +522,9 @@ pub trait SlavePty { /// Spawns a command with a sealed same-spawn sysprims containment guard. /// - /// Native Unix PTYs provide guaranteed spawn-time acquisition. Other PTY - /// implementations reject this operation before spawning a process. + /// Native Unix PTYs use a sealed session receipt. Native Windows ConPTY + /// creates the child suspended and seals exact-process Job membership + /// before one resume. Other PTY implementations reject before spawning. fn spawn_contained_command( &self, cmd: CommandBuilder, @@ -671,39 +660,47 @@ impl Child for std::process::Child { impl Child for ContainedPtyChild { fn try_wait(&mut self) -> IoResult> { - std::process::Child::try_wait(&mut self.child).map(|status| status.map(Into::into)) + #[cfg(unix)] + return std::process::Child::try_wait(&mut self.child).map(|status| status.map(Into::into)); + + #[cfg(windows)] + return Child::try_wait(&mut self.child); } fn wait(&mut self) -> IoResult { - std::process::Child::wait(&mut self.child).map(Into::into) + #[cfg(unix)] + return std::process::Child::wait(&mut self.child).map(Into::into); + + #[cfg(windows)] + return Child::wait(&mut self.child); } fn process_id(&self) -> Option { - Some(self.child.id()) + #[cfg(unix)] + return Some(self.child.id()); + + #[cfg(windows)] + return Child::process_id(&self.child); } #[cfg(windows)] fn as_raw_handle(&self) -> Option { - Some(std::os::windows::io::AsRawHandle::as_raw_handle( - &self.child, - )) + Child::as_raw_handle(&self.child) } } impl sysprims_timeout::ContainmentChild for ContainedPtyChild { fn process_id(&self) -> Option { - Some(self.child.id()) + Child::process_id(self) } fn try_wait(&mut self) -> IoResult { - std::process::Child::try_wait(&mut self.child).map(|status| status.is_some()) + Child::try_wait(self).map(|status| status.is_some()) } #[cfg(windows)] fn raw_process_handle(&self) -> Option { - Some(std::os::windows::io::AsRawHandle::as_raw_handle( - &self.child, - )) + Child::as_raw_handle(self) } } diff --git a/src/win/conpty.rs b/src/win/conpty.rs index eebe087..db9a989 100644 --- a/src/win/conpty.rs +++ b/src/win/conpty.rs @@ -1,41 +1,47 @@ use crate::cmdbuilder::CommandBuilder; use crate::win::psuedocon::PsuedoCon; -use crate::{Child, MasterPty, PtyPair, PtySize, PtySystem, SlavePty}; +use crate::{ + Child, ContainedPtyChild, ContainedPtyGuard, ContainedPtySpawnError, + ContainedPtySpawnErrorStage, FailedChildRecovery, MasterPty, PtyPair, PtySize, PtySystem, + SlavePty, +}; use anyhow::Error; use filedescriptor::{FileDescriptor, Pipe}; use std::sync::{Arc, Mutex}; +use sysprims_timeout::{PreparedWindowsJob, TerminateTreeConfig}; use winapi::um::wincon::COORD; #[derive(Default)] pub struct ConPtySystem {} +fn open_conpty(size: PtySize) -> anyhow::Result<(ConPtyMasterPty, ConPtySlavePty)> { + let stdin = Pipe::new()?; + let stdout = Pipe::new()?; + let con = PsuedoCon::new( + COORD { + X: size.cols as i16, + Y: size.rows as i16, + }, + stdin.read, + stdout.write, + )?; + let master = ConPtyMasterPty { + inner: Arc::new(Mutex::new(Inner { + con, + readable: stdout.read, + writable: Some(stdin.write), + size, + })), + }; + let slave = ConPtySlavePty { + inner: master.inner.clone(), + }; + Ok((master, slave)) +} + impl PtySystem for ConPtySystem { fn openpty(&self, size: PtySize) -> anyhow::Result { - let stdin = Pipe::new()?; - let stdout = Pipe::new()?; - - let con = PsuedoCon::new( - COORD { - X: size.cols as i16, - Y: size.rows as i16, - }, - stdin.read, - stdout.write, - )?; - - let master = ConPtyMasterPty { - inner: Arc::new(Mutex::new(Inner { - con, - readable: stdout.read, - writable: Some(stdin.write), - size, - })), - }; - - let slave = ConPtySlavePty { - inner: master.inner.clone(), - }; - + let (master, slave) = open_conpty(size)?; Ok(PtyPair { master: Box::new(master), slave: Box::new(slave), @@ -81,6 +87,92 @@ pub struct ConPtySlavePty { inner: Arc>, } +impl ConPtySlavePty { + fn spawn_contained_command_inner( + &self, + cmd: CommandBuilder, + before_resume: F, + ) -> Result + where + F: FnOnce(&ContainedPtyGuard) -> Result<(), Error>, + { + let prepared_job = PreparedWindowsJob::new() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?; + let recovery = FailedChildRecovery::prepare() + .map_err(|error| ContainedPtySpawnError::before_spawn(error.into()))?; + let inner = self.inner.lock().unwrap(); + let (child, primary_thread) = inner + .con + .spawn_suspended_command(cmd) + .map_err(ContainedPtySpawnError::before_spawn)?; + let child = ContainedPtyChild::new(child); + let process = Child::as_raw_handle(&child) + .expect("Windows contained child retains its process handle"); + + // SAFETY: this handle and primary thread came from the one suspended + // CreateProcessW call above. The thread has not been resumed. + let receipt = match unsafe { prepared_job.assign_process(process) } { + Ok(receipt) => receipt, + Err(error) => { + drop(primary_thread); + return Err(ContainedPtySpawnError::after_spawn( + ContainedPtySpawnErrorStage::Receipt, + error.into(), + child, + recovery, + )); + } + }; + + // SAFETY: child owns the exact process handle sealed into receipt, + // remains suspended, and transfers exclusive wait/reap authority. + let mut guard = + match unsafe { sysprims_timeout::contain_acquired_windows_job(child, receipt) } { + Ok(guard) => guard, + Err(adoption) => { + drop(primary_thread); + return Err(ContainedPtySpawnError::after_spawn( + ContainedPtySpawnErrorStage::Adoption, + adoption.error.into(), + adoption.child, + recovery, + )); + } + }; + + if let Err(error) = before_resume(&guard) { + drop(primary_thread); + let _ = guard.terminate(TerminateTreeConfig { + grace_timeout_ms: 0, + kill_timeout_ms: 2_000, + ..TerminateTreeConfig::default() + }); + return Err(ContainedPtySpawnError { + stage: ContainedPtySpawnErrorStage::Resume, + source: error, + recovery: None, + }); + } + + if let Err(error) = primary_thread.resume() { + // The child was never made runnable by this adapter. Resolve it + // through the already-owned Job and return no active guard. + let _ = guard.terminate(TerminateTreeConfig { + grace_timeout_ms: 0, + kill_timeout_ms: 2_000, + ..TerminateTreeConfig::default() + }); + return Err(ContainedPtySpawnError { + stage: ContainedPtySpawnErrorStage::Resume, + source: error.into(), + recovery: None, + }); + } + + Ok(guard) + } +} + impl MasterPty for ConPtyMasterPty { fn resize(&self, size: PtySize) -> anyhow::Result<()> { let mut inner = self.inner.lock().unwrap(); @@ -114,4 +206,154 @@ impl SlavePty for ConPtySlavePty { let child = inner.con.spawn_command(cmd)?; Ok(Box::new(child)) } + + fn spawn_contained_command( + &self, + cmd: CommandBuilder, + ) -> Result { + self.spawn_contained_command_inner(cmd, |_| Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + use sysprims_timeout::{ContainmentBoundaryStrength, TreeKillReliability}; + + fn marker_path(label: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "sysprims-pty-{label}-{}-{nonce}", + std::process::id(), + )) + } + + fn serve_headless_conpty(master: &ConPtyMasterPty) { + let mut reader = master + .try_clone_reader() + .expect("ConPTY output reader clone failed"); + let mut writer = master.take_writer().expect("ConPTY input writer missing"); + std::thread::spawn(move || { + let mut output = Vec::new(); + let mut chunk = [0; 1_024]; + loop { + let Ok(read) = reader.read(&mut chunk) else { + return; + }; + if read == 0 { + return; + } + output.extend_from_slice(&chunk[..read]); + if output.windows(4).any(|window| window == b"\x1b[6n") { + use std::io::Write as _; + writer + .write_all(b"\x1b[1;1R") + .expect("ConPTY cursor-position response failed"); + writer.flush().expect("ConPTY input flush failed"); + output.clear(); + } else if output.len() > 3 { + output.drain(..output.len() - 3); + } + } + }); + } + + #[test] + fn child_cannot_run_before_assignment_proof_and_resume() { + let marker = marker_path("resume-sentinel"); + let _ = std::fs::remove_file(&marker); + let (master, slave) = open_conpty(PtySize::default()).expect("ConPTY open failed"); + serve_headless_conpty(&master); + let mut command = + CommandBuilder::new(std::env::current_exe().expect("current test executable missing")); + command.args([ + "--exact", + "win::conpty::tests::resume_sentinel_helper", + "--nocapture", + ]); + command.env("SYSPRIMS_PTY_RESUME_SENTINEL", &marker); + + let mut guard = slave + .spawn_contained_command_inner(command, |guard| { + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + assert_eq!( + guard.boundary_strength(), + ContainmentBoundaryStrength::KernelEnforcedJob + ); + std::thread::sleep(Duration::from_millis(100)); + assert!( + !marker.exists(), + "suspended child executed before the explicit resume gate" + ); + Ok(()) + }) + .expect("contained ConPTY spawn failed"); + + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() { + assert!(Instant::now() < deadline, "resumed child did not execute"); + std::thread::sleep(Duration::from_millis(10)); + } + master + .resize(PtySize { + rows: 30, + cols: 100, + ..PtySize::default() + }) + .expect("presentation handle was not retained"); + let outcome = guard + .terminate(TerminateTreeConfig::default()) + .expect("contained ConPTY termination failed"); + assert!(outcome.exited); + assert_eq!( + outcome.boundary_strength, + ContainmentBoundaryStrength::KernelEnforcedJob + ); + let _ = std::fs::remove_file(marker); + } + + #[test] + fn failed_resume_gate_never_runs_child() { + let marker = marker_path("failed-resume"); + let _ = std::fs::remove_file(&marker); + let (master, slave) = open_conpty(PtySize::default()).expect("ConPTY open failed"); + serve_headless_conpty(&master); + let mut command = + CommandBuilder::new(std::env::current_exe().expect("current test executable missing")); + command.args([ + "--exact", + "win::conpty::tests::resume_sentinel_helper", + "--nocapture", + ]); + command.env("SYSPRIMS_PTY_RESUME_SENTINEL", &marker); + + let error = match slave.spawn_contained_command_inner(command, |_| { + Err(anyhow::anyhow!("injected resume-gate failure")) + }) { + Ok(_) => panic!("resume-gate failure must fail the transaction"), + Err(error) => error, + }; + assert_eq!(error.stage(), ContainedPtySpawnErrorStage::Resume); + assert!(!error.recovery_pending()); + assert!( + !marker.exists(), + "failed resume transaction executed the child" + ); + } + + #[test] + fn resume_sentinel_helper() { + let Some(marker) = std::env::var_os("SYSPRIMS_PTY_RESUME_SENTINEL") else { + return; + }; + std::fs::write(marker, b"started").expect("failed to write resume sentinel"); + std::thread::sleep(Duration::from_secs(30)); + } } diff --git a/src/win/mod.rs b/src/win/mod.rs index 24f8f53..d240939 100644 --- a/src/win/mod.rs +++ b/src/win/mod.rs @@ -22,6 +22,30 @@ pub struct WinChild { proc: Mutex, } +pub(crate) struct SuspendedThread { + thread: OwnedHandle, +} + +impl SuspendedThread { + pub(crate) fn new(thread: OwnedHandle) -> Self { + Self { thread } + } + + /// Consume the sole primary-thread resume authority. + pub(crate) fn resume(self) -> IoResult<()> { + let previous_suspend_count = unsafe { ResumeThread(self.thread.as_raw_handle() as _) }; + if previous_suspend_count == u32::MAX { + Err(IoError::last_os_error()) + } else if previous_suspend_count != 1 { + Err(IoError::other(format!( + "unexpected primary-thread suspend count {previous_suspend_count}" + ))) + } else { + Ok(()) + } + } +} + impl WinChild { fn is_complete(&mut self) -> IoResult> { let mut status: DWORD = 0; @@ -41,19 +65,17 @@ impl WinChild { fn do_kill(&mut self) -> IoResult<()> { let proc = self.proc.lock().unwrap().try_clone().unwrap(); let res = unsafe { TerminateProcess(proc.as_raw_handle() as _, 1) }; - let err = IoError::last_os_error(); if res != 0 { - Err(err) - } else { Ok(()) + } else { + Err(IoError::last_os_error()) } } } impl ChildKiller for WinChild { fn kill(&mut self) -> IoResult<()> { - self.do_kill().ok(); - Ok(()) + self.do_kill() } fn clone_killer(&self) -> Box { @@ -70,11 +92,10 @@ pub struct WinChildKiller { impl ChildKiller for WinChildKiller { fn kill(&mut self) -> IoResult<()> { let res = unsafe { TerminateProcess(self.proc.as_raw_handle() as _, 1) }; - let err = IoError::last_os_error(); if res != 0 { - Err(err) - } else { Ok(()) + } else { + Err(IoError::last_os_error()) } } diff --git a/src/win/psuedocon.rs b/src/win/psuedocon.rs index 1351bbb..808d5c9 100644 --- a/src/win/psuedocon.rs +++ b/src/win/psuedocon.rs @@ -1,4 +1,4 @@ -use super::WinChild; +use super::{SuspendedThread, WinChild}; use crate::cmdbuilder::CommandBuilder; use crate::win::procthreadattr::ProcThreadAttributeList; use anyhow::{bail, ensure, Error}; @@ -17,7 +17,8 @@ use winapi::shared::winerror::{HRESULT, S_OK}; use winapi::um::handleapi::*; use winapi::um::processthreadsapi::*; use winapi::um::winbase::{ - CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, STARTF_USESTDHANDLES, STARTUPINFOEXW, + CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, + STARTF_USESTDHANDLES, STARTUPINFOEXW, }; use winapi::um::wincon::COORD; use winapi::um::winnt::HANDLE; @@ -173,4 +174,66 @@ impl PsuedoCon { proc: Mutex::new(proc), }) } + + pub fn spawn_suspended_command( + &self, + cmd: CommandBuilder, + ) -> anyhow::Result<(WinChild, SuspendedThread)> { + let mut si: STARTUPINFOEXW = unsafe { mem::zeroed() }; + si.StartupInfo.cb = mem::size_of::() as u32; + // ConPTY transports stdio through the pseudoconsole attribute. No + // process handles or parent-side pipe handles are inheritable. + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE; + si.StartupInfo.hStdError = INVALID_HANDLE_VALUE; + + let mut attrs = ProcThreadAttributeList::with_capacity(1)?; + attrs.set_pty(self.con)?; + si.lpAttributeList = attrs.as_mut_ptr(); + + let mut pi: PROCESS_INFORMATION = unsafe { mem::zeroed() }; + let (mut exe, mut cmdline) = cmd.cmdline()?; + let cmd_os = OsString::from_wide(&cmdline); + let cwd = cmd.current_directory(); + let mut environment = cmd.environment_block(); + + let res = unsafe { + CreateProcessW( + exe.as_mut_slice().as_mut_ptr(), + cmdline.as_mut_slice().as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + 0, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED, + environment.as_mut_slice().as_mut_ptr() as *mut _, + cwd.as_ref() + .map(|c| c.as_slice().as_ptr()) + .unwrap_or(ptr::null()), + &mut si.StartupInfo, + &mut pi, + ) + }; + if res == 0 { + let err = IoError::last_os_error(); + let msg = format!( + "suspended CreateProcessW `{:?}` in cwd `{:?}` failed: {}", + cmd_os, + cwd.as_ref().map(|c| OsString::from_wide(c)), + err + ); + log::error!("{}", msg); + bail!("{}", msg); + } + + // SAFETY: successful CreateProcessW returned fresh owned handles. + let thread = unsafe { OwnedHandle::from_raw_handle(pi.hThread as _) }; + let proc = unsafe { OwnedHandle::from_raw_handle(pi.hProcess as _) }; + Ok(( + WinChild { + proc: Mutex::new(proc), + }, + SuspendedThread::new(thread), + )) + } } diff --git a/tests/contained_spawn.rs b/tests/contained_spawn.rs index 1a96477..ee77ab2 100644 --- a/tests/contained_spawn.rs +++ b/tests/contained_spawn.rs @@ -208,25 +208,316 @@ mod unix { #[cfg(windows)] #[test] -fn guaranteed_containment_rejects_before_spawn() { +fn guaranteed_conpty_containment_owns_job_and_child() { use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + use sysprims_timeout::{ContainmentBoundaryStrength, TerminateTreeConfig, TreeKillReliability}; let marker = std::env::temp_dir().join(format!( - "portable-pty-contained-spawn-{}", - std::process::id() + "portable-pty-contained-spawn-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_nanos() )); - let mut command = CommandBuilder::new("cmd.exe"); - command.args(["/C", &format!("echo spawned>\"{}\"", marker.display())]); + let mut command = CommandBuilder::new(std::env::current_exe().unwrap()); + command.args(["--exact", "windows_contained_spawn_helper", "--nocapture"]); + command.env("SYSPRIMS_PTY_TEST_MODE", "contained_spawn"); + command.env("SYSPRIMS_PTY_CONTAINED_MARKER", &marker); let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + serve_headless_windows_pty(pair.master.as_ref()); + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); - let error = match pair.slave.spawn_contained_command(command) { - Ok(_) => panic!("Windows guaranteed containment must reject"), - Err(error) => error, + assert_eq!( + guard.tree_kill_reliability(), + TreeKillReliability::Guaranteed + ); + assert_eq!( + guard.boundary_strength(), + ContainmentBoundaryStrength::KernelEnforcedJob + ); + + let deadline = Instant::now() + Duration::from_secs(5); + while !marker.exists() { + assert!(Instant::now() < deadline, "contained child did not run"); + std::thread::sleep(Duration::from_millis(10)); + } + let outcome = guard + .terminate(TerminateTreeConfig::default()) + .expect("contained ConPTY termination failed"); + assert!(outcome.exited); + assert_eq!( + outcome.boundary_strength, + ContainmentBoundaryStrength::KernelEnforcedJob + ); + let _ = std::fs::remove_file(marker); +} + +#[cfg(windows)] +#[test] +fn windows_contained_spawn_helper() { + use std::time::Duration; + + if std::env::var("SYSPRIMS_PTY_TEST_MODE").as_deref() != Ok("contained_spawn") { + return; + } + std::fs::write( + std::env::var_os("SYSPRIMS_PTY_CONTAINED_MARKER").unwrap(), + b"started", + ) + .expect("failed to write contained-spawn marker"); + std::thread::sleep(Duration::from_secs(30)); +} + +#[cfg(windows)] +fn windows_fixture_path(label: &str) -> std::path::PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + + std::env::temp_dir().join(format!( + "sysprims-pty-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_nanos() + )) +} + +#[cfg(windows)] +fn wait_for_pid_file(path: &std::path::Path) -> u32 { + use std::time::{Duration, Instant}; + + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Ok(value) = std::fs::read_to_string(path) { + return value.trim().parse().expect("fixture PID is invalid"); + } + assert!(Instant::now() < deadline, "fixture PID was not reported"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[cfg(windows)] +fn open_process_for_wait(pid: u32) -> std::os::windows::io::OwnedHandle { + use std::os::windows::io::FromRawHandle; + use winapi::um::processthreadsapi::OpenProcess; + use winapi::um::winnt::{PROCESS_QUERY_LIMITED_INFORMATION, SYNCHRONIZE}; + + let handle = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + assert!(!handle.is_null(), "failed to open fixture process {}", pid); + unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(handle.cast()) } +} + +#[cfg(windows)] +fn assert_process_exited(handle: &std::os::windows::io::OwnedHandle) { + use std::os::windows::io::AsRawHandle; + use winapi::um::synchapi::WaitForSingleObject; + use winapi::um::winbase::WAIT_OBJECT_0; + + let result = unsafe { WaitForSingleObject(handle.as_raw_handle().cast(), 5_000) }; + assert_eq!(result, WAIT_OBJECT_0, "Job member survived termination"); +} + +#[cfg(windows)] +fn serve_headless_windows_pty(master: &dyn portable_pty::MasterPty) { + let mut reader = master.try_clone_reader().unwrap(); + let mut writer = master.take_writer().unwrap(); + std::thread::spawn(move || { + let mut output = Vec::new(); + let mut chunk = [0; 1_024]; + loop { + let Ok(read) = reader.read(&mut chunk) else { + return; + }; + if read == 0 { + return; + } + output.extend_from_slice(&chunk[..read]); + if output.windows(4).any(|window| window == b"\x1b[6n") { + use std::io::Write as _; + writer.write_all(b"\x1b[1;1R").unwrap(); + writer.flush().unwrap(); + output.clear(); + } else if output.len() > 3 { + output.drain(..output.len() - 3); + } + } + }); +} + +#[cfg(windows)] +#[test] +fn immediate_child_and_grandchild_remain_in_owned_job() { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use sysprims_timeout::{ContainmentCompletionEvidence, TerminateTreeConfig}; + + let child_pid_file = windows_fixture_path("child-pid"); + let grandchild_pid_file = windows_fixture_path("grandchild-pid"); + let mut command = CommandBuilder::new(std::env::current_exe().unwrap()); + command.args(["--exact", "windows_tree_parent_helper", "--nocapture"]); + command.env("SYSPRIMS_PTY_TEST_MODE", "tree_parent"); + command.env("SYSPRIMS_PTY_CHILD_PID_FILE", &child_pid_file); + command.env("SYSPRIMS_PTY_GRANDCHILD_PID_FILE", &grandchild_pid_file); + + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + serve_headless_windows_pty(pair.master.as_ref()); + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + let child_handle = open_process_for_wait(wait_for_pid_file(&child_pid_file)); + let grandchild_handle = open_process_for_wait(wait_for_pid_file(&grandchild_pid_file)); + + let outcome = guard + .terminate(TerminateTreeConfig::default()) + .expect("Job termination failed"); + assert!(outcome.exited); + assert!(matches!( + outcome.completion, + ContainmentCompletionEvidence::Empty { .. } + )); + assert_process_exited(&child_handle); + assert_process_exited(&grandchild_handle); + + let _ = std::fs::remove_file(child_pid_file); + let _ = std::fs::remove_file(grandchild_pid_file); +} + +#[cfg(windows)] +#[test] +fn create_breakaway_from_job_cannot_escape() { + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::time::{Duration, Instant}; + use sysprims_timeout::TerminateTreeConfig; + + let result_file = windows_fixture_path("breakaway-result"); + let mut command = CommandBuilder::new(std::env::current_exe().unwrap()); + command.args(["--exact", "windows_breakaway_parent_helper", "--nocapture"]); + command.env("SYSPRIMS_PTY_TEST_MODE", "breakaway_parent"); + command.env("SYSPRIMS_PTY_BREAKAWAY_RESULT_FILE", &result_file); + + let pair = native_pty_system().openpty(PtySize::default()).unwrap(); + serve_headless_windows_pty(pair.master.as_ref()); + let mut guard = pair.slave.spawn_contained_command(command).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let result = loop { + if let Ok(result) = std::fs::read_to_string(&result_file) { + break result; + } + assert!( + Instant::now() < deadline, + "breakaway result was not reported" + ); + std::thread::sleep(Duration::from_millis(10)); }; - assert!(error.to_string().contains("unavailable")); + let outcome = guard + .terminate(TerminateTreeConfig::default()) + .expect("Job termination failed"); + assert!(outcome.exited); assert!( - !marker.exists(), - "unsupported contained spawn executed the command" + result.starts_with("denied:"), + "CREATE_BREAKAWAY_FROM_JOB unexpectedly escaped: {}", + result ); + let _ = std::fs::remove_file(result_file); +} + +#[cfg(windows)] +#[test] +fn windows_tree_parent_helper() { + if std::env::var("SYSPRIMS_PTY_TEST_MODE").as_deref() != Ok("tree_parent") { + return; + } + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "windows_tree_child_helper", "--nocapture"]) + .env("SYSPRIMS_PTY_TEST_MODE", "tree_child") + .env( + "SYSPRIMS_PTY_CHILD_PID_FILE", + std::env::var_os("SYSPRIMS_PTY_CHILD_PID_FILE").unwrap(), + ) + .env( + "SYSPRIMS_PTY_GRANDCHILD_PID_FILE", + std::env::var_os("SYSPRIMS_PTY_GRANDCHILD_PID_FILE").unwrap(), + ) + .spawn() + .expect("failed to spawn immediate child helper"); + child.wait().expect("immediate child wait failed"); +} + +#[cfg(windows)] +#[test] +fn windows_tree_child_helper() { + if std::env::var("SYSPRIMS_PTY_TEST_MODE").as_deref() != Ok("tree_child") { + return; + } + std::fs::write( + std::env::var_os("SYSPRIMS_PTY_CHILD_PID_FILE").unwrap(), + std::process::id().to_string(), + ) + .expect("failed to write immediate child PID"); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "windows_tree_grandchild_helper", "--nocapture"]) + .env("SYSPRIMS_PTY_TEST_MODE", "tree_grandchild") + .env( + "SYSPRIMS_PTY_GRANDCHILD_PID_FILE", + std::env::var_os("SYSPRIMS_PTY_GRANDCHILD_PID_FILE").unwrap(), + ) + .spawn() + .expect("failed to spawn grandchild helper"); + child.wait().expect("grandchild wait failed"); +} + +#[cfg(windows)] +#[test] +fn windows_tree_grandchild_helper() { + if std::env::var("SYSPRIMS_PTY_TEST_MODE").as_deref() != Ok("tree_grandchild") { + return; + } + std::fs::write( + std::env::var_os("SYSPRIMS_PTY_GRANDCHILD_PID_FILE").unwrap(), + std::process::id().to_string(), + ) + .expect("failed to write grandchild PID"); + std::thread::sleep(std::time::Duration::from_secs(30)); +} + +#[cfg(windows)] +#[test] +fn windows_breakaway_parent_helper() { + use std::os::windows::process::CommandExt; + use winapi::um::winbase::CREATE_BREAKAWAY_FROM_JOB; + + if std::env::var("SYSPRIMS_PTY_TEST_MODE").as_deref() != Ok("breakaway_parent") { + return; + } + let result_file = std::env::var_os("SYSPRIMS_PTY_BREAKAWAY_RESULT_FILE").unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "windows_breakaway_leaf_helper", "--nocapture"]) + .env("SYSPRIMS_PTY_TEST_MODE", "breakaway_leaf") + .creation_flags(CREATE_BREAKAWAY_FROM_JOB) + .spawn(); + match result { + Err(error) => { + std::fs::write( + result_file, + format!("denied:{}", error.raw_os_error().unwrap_or(0)), + ) + .expect("failed to write breakaway denial"); + } + Ok(mut escaped) => { + let pid = escaped.id(); + let _ = escaped.kill(); + let _ = escaped.wait(); + std::fs::write(result_file, format!("escaped:{pid}")) + .expect("failed to write breakaway escape"); + } + } +} + +#[cfg(windows)] +#[test] +fn windows_breakaway_leaf_helper() { + if std::env::var("SYSPRIMS_PTY_TEST_MODE").as_deref() != Ok("breakaway_leaf") { + return; + } + std::thread::sleep(std::time::Duration::from_secs(30)); }