From 79aaa4954e8f304a6d1098fa3e59b5f9cc5c9b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 24 Sep 2026 22:50:01 +1200 Subject: [PATCH 001/114] plan: clarify ConPTY support contract Co-Authored-By: Claude Code --- docs/plans/command-api-and-pty-provider.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md index 3ef16b3..05b23a9 100644 --- a/docs/plans/command-api-and-pty-provider.md +++ b/docs/plans/command-api-and-pty-provider.md @@ -92,8 +92,11 @@ Carry creation flags, explicit versus temporary suspension, JobObject, and KillO ## ConPTY transport Reuse the existing dynamic ConPTY API resolution, startup attributes, named pipes, manual `CreateProcessW`, custom child, controller, and cleanup modules. -Integrate them as the Tokio `Pty` provider rather than a separate spawn method. -Preserve unsupported-runtime precedence and exact command line, environment, cwd, flag, and handle-inheritance semantics. +Call the native ConPTY API through the `windows` crate rather than introducing another PTY or process abstraction. +Integrate the transport as the Tokio `Pty` provider rather than a separate spawn method. +Resolve `CreatePseudoConsole`, `ResizePseudoConsole`, `ReleasePseudoConsole`, and `ClosePseudoConsole` dynamically and preserve unsupported-runtime precedence. +Require Windows 11 24H2 build 26100 or Windows Server 2025 because descendant-aware output EOF depends on `ReleasePseudoConsole`. +Preserve exact command line, environment, cwd, flag, and handle-inheritance semantics. Retain process and primary-thread handles through JobObject assignment and provider-owned suspension finalization. Keep the cleanup guard armed through all wrapper hooks and disarm it only when the provider transaction commits. Preserve cancellation-safe repeated waits, post-exit kill behavior, resize, merged I/O, direct-child and job-object kill-on-drop behavior, and off-reactor pseudoconsole closure. @@ -109,3 +112,4 @@ Preserve the terminal stream, ownership, VEOF, draining, macOS lifecycle, and pr Explain that `pty` remains non-default because it selects Tokio and PTY dependencies. Remove the rejected public PTY builder, tuple spawn, marker names, fallback-to-pipes wording, and CI-runner prose. Document native-only escape behavior and the migration from the former PTY prototype. +Document ConPTY support, its Windows build floor, and the descendant-aware EOF semantics which require that floor. From fda4566ed5ac38cafec8f586e8a354aaa6ac7bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 24 Sep 2026 22:55:06 +1200 Subject: [PATCH 002/114] plan: expose PTY capability queries Co-Authored-By: Claude Code --- docs/plans/command-api-and-pty-provider.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md index 05b23a9..c8db768 100644 --- a/docs/plans/command-api-and-pty-provider.md +++ b/docs/plans/command-api-and-pty-provider.md @@ -67,6 +67,8 @@ Keep ordinary `spawn` returning the boxed Tokio child contract. Install a private child layer which owns the PTY controller beneath arbitrary outer wrappers. Add one-shot controller extraction by traversing the child chain without unwrapping it. Keep PTY input and output on the controller and keep native Tokio pipe accessors absent. +Expose side-effect-free `Pty::check_supported` and `Pty::is_supported` associated functions for platform and runtime capability detection. +Keep command configuration, wrapper compatibility, and spawn failures outside that capability result so callers do not mistake availability for a spawn guarantee. ## Unix PTY transport From 40fcd8501555457903e99406731f71998b16d094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 24 Sep 2026 22:59:43 +1200 Subject: [PATCH 003/114] plan: map ConPTY recovery sequence Co-Authored-By: Claude Code --- docs/plans/command-api-and-pty-provider.md | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md index c8db768..2df9d5f 100644 --- a/docs/plans/command-api-and-pty-provider.md +++ b/docs/plans/command-api-and-pty-provider.md @@ -115,3 +115,30 @@ Explain that `pty` remains non-default because it selects Tokio and PTY dependen Remove the rejected public PTY builder, tuple spawn, marker names, fallback-to-pipes wording, and CI-runner prose. Document native-only escape behavior and the migration from the former PTY prototype. Document ConPTY support, its Windows build floor, and the descendant-aware EOF semantics which require that floor. + +## Recovery and completion sequence + +Treat the shared command family, exact state, attempt lifecycle, spawn providers, Tokio PTY wrapper, Unix transport, and exact Windows command model as the merged foundation on `main`. +Recover the unpublished ConPTY work by rebasing its logical commits onto that foundation rather than merging the obsolete branch or reconstructing one squashed change. +Preserve the feature sequence for executable resolution, dynamic API resolution, named pipes, startup ownership, child ownership, controller ownership, exact process creation, and provider integration. +Preserve the subsequent test and lifecycle-fix commits while omitting the historical correction merge whose other parent is already an ancestor of `main`. + +Keep the recovered Windows transport split by responsibility: + +- `src/tokio/pty/windows/api.rs` resolves and calls the required ConPTY entry points. +- `src/tokio/pty/windows/attributes.rs` owns the process-thread startup attribute list. +- `src/tokio/pty/windows/backend.rs` validates, allocates, and returns the provider product and transaction. +- `src/tokio/pty/windows/child.rs` owns process handles and cancellation-safe wait state. +- `src/tokio/pty/windows/console.rs` owns startup release, resize, and off-reactor pseudoconsole closure. +- `src/tokio/pty/windows/controller.rs` exposes PTY input, output, and resize ownership. +- `src/tokio/pty/windows/pipe.rs` pairs ConPTY-compatible synchronous endpoints with Tokio-compatible named-pipe endpoints. +- `src/tokio/pty/windows/program.rs` resolves executables deterministically and rejects direct batch execution. +- `src/tokio/pty/windows/spawn.rs` performs exact `CreateProcessW` startup and arms process cleanup. +- `src/tokio/pty/windows/mod.rs` composes those modules with the existing command and environment preparation. +- `src/tokio/pty.rs` selects the Windows backend and exposes the public capability queries. +- `Cargo.toml` enables only the additional Win32 API features required by the transport. +- `README.md` and crate-level documentation describe Windows support, its runtime floor, and the same ownership and lifecycle contract as the Unix backend. + +Add the capability-query API as a focused follow-up commit after the recovered historical series. +Resolve current-`main` integration gaps in new focused commits rather than folding fixes into the recovered commits. +When every implementation promise in this plan is present, remove this plan in a standalone `unplan:` commit. From d0eb5805927ac12df7fb61166e728fb44d289db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 19:54:29 +1200 Subject: [PATCH 004/114] plan: decompose ConPTY recovery tasks Co-Authored-By: Claude Code --- docs/plans/command-api-and-pty-provider.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md index 2df9d5f..62602ee 100644 --- a/docs/plans/command-api-and-pty-provider.md +++ b/docs/plans/command-api-and-pty-provider.md @@ -118,6 +118,8 @@ Document ConPTY support, its Windows build floor, and the descendant-aware EOF s ## Recovery and completion sequence +### Task 1: Recover the historical ConPTY stack + Treat the shared command family, exact state, attempt lifecycle, spawn providers, Tokio PTY wrapper, Unix transport, and exact Windows command model as the merged foundation on `main`. Recover the unpublished ConPTY work by rebasing its logical commits onto that foundation rather than merging the obsolete branch or reconstructing one squashed change. Preserve the feature sequence for executable resolution, dynamic API resolution, named pipes, startup ownership, child ownership, controller ownership, exact process creation, and provider integration. @@ -137,8 +139,19 @@ Keep the recovered Windows transport split by responsibility: - `src/tokio/pty/windows/mod.rs` composes those modules with the existing command and environment preparation. - `src/tokio/pty.rs` selects the Windows backend and exposes the public capability queries. - `Cargo.toml` enables only the additional Win32 API features required by the transport. -- `README.md` and crate-level documentation describe Windows support, its runtime floor, and the same ownership and lifecycle contract as the Unix backend. -Add the capability-query API as a focused follow-up commit after the recovered historical series. -Resolve current-`main` integration gaps in new focused commits rather than folding fixes into the recovered commits. +### Task 2: Add PTY capability queries + +Add `Pty::check_supported() -> io::Result<()>` and `Pty::is_supported() -> bool` in `src/tokio/pty.rs` as side-effect-free associated functions. +Delegate the provider availability callback to the same check so the public query and spawn path cannot drift. +Keep size validation, command compatibility, wrapper compatibility, and spawn outcomes outside the capability result. + +### Task 3: Complete the Windows support documentation + +Update `README.md` and crate-level documentation to describe Windows ConPTY support, its Windows 11 24H2 build 26100 or Windows Server 2025 floor, and the same ownership and lifecycle contract as the Unix backend. +Document capability queries as the supported way to select PTY conditionally without weakening later validation and spawn errors. + +### Task 4: Close the implementation plan + +Resolve any current-`main` integration gaps in new focused commits rather than folding fixes into the recovered commits. When every implementation promise in this plan is present, remove this plan in a standalone `unplan:` commit. From c852725947d899c5dfc26f2dd9854b515e767530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 19:55:59 +1200 Subject: [PATCH 005/114] plan: separate ConPTY recovery from capability API Co-Authored-By: Claude Code --- docs/plans/command-api-and-pty-provider.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md index 62602ee..7ae84cc 100644 --- a/docs/plans/command-api-and-pty-provider.md +++ b/docs/plans/command-api-and-pty-provider.md @@ -137,7 +137,7 @@ Keep the recovered Windows transport split by responsibility: - `src/tokio/pty/windows/program.rs` resolves executables deterministically and rejects direct batch execution. - `src/tokio/pty/windows/spawn.rs` performs exact `CreateProcessW` startup and arms process cleanup. - `src/tokio/pty/windows/mod.rs` composes those modules with the existing command and environment preparation. -- `src/tokio/pty.rs` selects the Windows backend and exposes the public capability queries. +- `src/tokio/pty.rs` selects the Windows backend. - `Cargo.toml` enables only the additional Win32 API features required by the transport. ### Task 2: Add PTY capability queries From ea89dbde61d50f164caa94a4240099dceb8ef02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 00:55:24 +1200 Subject: [PATCH 006/114] feat(pty): resolve Windows executables Port deterministic Windows executable resolution and direct batch-script rejection from revisions 011582343911 and 52dae07fb17a. Consume the shared program and effective environment getters without restoring a PTY-local command model. Co-Authored-By: Claude --- Cargo.toml | 1 + src/tokio/pty/windows/environment.rs | 2 +- src/tokio/pty/windows/mod.rs | 12 +- src/tokio/pty/windows/program.rs | 276 +++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 src/tokio/pty/windows/program.rs diff --git a/Cargo.toml b/Cargo.toml index 5ff6c4f..53400ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ pty = [ "dep:windows", "windows/Win32_Globalization", "windows/Win32_System_Environment", + "windows/Win32_System_SystemInformation", "windows/Win32_System_Threading", ] diff --git a/src/tokio/pty/windows/environment.rs b/src/tokio/pty/windows/environment.rs index be36e53..0162c2a 100644 --- a/src/tokio/pty/windows/environment.rs +++ b/src/tokio/pty/windows/environment.rs @@ -139,7 +139,7 @@ fn set_variable(variables: &mut Vec, variable: EnvironmentV } } -fn windows_equal(left: &[u16], right: &[u16]) -> bool { +pub(super) fn windows_equal(left: &[u16], right: &[u16]) -> bool { windows_compare(left, right) == Ordering::Equal } diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 6ef721d..b8b9408 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -14,6 +14,7 @@ use environment::{PreparedEnvironment, prepare_environment}; pub(super) mod command; pub(super) mod environment; +mod program; #[derive(Debug, Eq, PartialEq)] struct PreparedWindowsCommand { @@ -25,9 +26,11 @@ struct PreparedWindowsCommand { } fn prepare(attempt: &SpawnAttempt) -> io::Result { - prepare_with(attempt, |inherits, changes| { + let mut prepared = prepare_with(attempt, |inherits, changes| { prepare_environment(inherits, changes) - }) + })?; + prepared.application_name = program::resolve(attempt.get_program(), attempt.get_envs())?; + Ok(prepared) } fn prepare_with<'a>( @@ -109,7 +112,10 @@ mod tests { *self .0 .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(prepare(attempt)); + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(prepare_with(attempt, |inherits, changes| { + prepare_environment(inherits, changes) + })); Err(io::Error::other("Windows command model captured")) } diff --git a/src/tokio/pty/windows/program.rs b/src/tokio/pty/windows/program.rs new file mode 100644 index 0000000..201f67b --- /dev/null +++ b/src/tokio/pty/windows/program.rs @@ -0,0 +1,276 @@ +//! Resolution of tracked program intent to the explicit `CreateProcessW` application name. + +use std::{ + env, + ffi::{OsStr, OsString}, + io, + os::windows::ffi::OsStringExt, + path::{Component, Path, PathBuf}, +}; + +use windows::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW}; + +use super::{ + command::{WideCString, encode}, + environment::windows_equal, +}; + +pub(super) fn resolve<'a>( + program: &OsStr, + changes: impl IntoIterator)>, +) -> io::Result { + let encoded = encode(program, "PTY program")?; + if has_suffix(&encoded, b".bat") || has_suffix(&encoded, b".cmd") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Windows PTY spawning does not execute batch scripts directly", + )); + } + + let path = Path::new(program); + if program.is_empty() || path.file_name().is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "PTY program path has no file name", + )); + } + + if !is_file_name(path, program) { + if has_suffix(&encoded, b".exe") { + return WideCString::from_os(program, "PTY program"); + } + let with_exe = append_exe(path); + if with_exe.exists() { + return WideCString::from_os(with_exe.as_os_str(), "PTY program"); + } + return WideCString::from_os(program, "PTY program"); + } + + let has_extension = encoded.contains(&(b'.' as u16)); + let child_path = child_path(changes)?; + if let Some(path) = child_path.as_deref() { + if let Some(program) = search_path(path, program, has_extension) { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + } + if let Ok(mut directory) = env::current_exe() { + directory.pop(); + if let Some(program) = candidate(directory, program, has_extension) { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + } + if let Ok(directory) = system_directory() { + if let Some(program) = candidate(directory, program, has_extension) { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + } + if let Ok(directory) = windows_directory() { + if let Some(program) = candidate(directory, program, has_extension) { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + } + if let Some(path) = env::var_os("PATH") { + if let Some(program) = search_path(&path, program, has_extension) { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + "PTY program not found", + )) +} + +fn child_path<'a>( + changes: impl IntoIterator)>, +) -> io::Result> { + let path_key = "PATH".encode_utf16().collect::>(); + let mut child_path = None; + for (key, value) in changes { + if windows_equal(&encode(key, "PTY environment key")?, &path_key) { + child_path = value.map(OsStr::to_owned); + } + } + Ok(child_path) +} + +fn search_path(paths: &OsStr, program: &OsStr, has_extension: bool) -> Option { + env::split_paths(paths) + .filter(|path| !path.as_os_str().is_empty()) + .find_map(|path| candidate(path, program, has_extension)) +} + +fn candidate(mut directory: PathBuf, program: &OsStr, has_extension: bool) -> Option { + directory.push(program); + if !has_extension { + directory = append_exe(&directory); + } + directory.exists().then_some(directory) +} + +fn append_exe(path: &Path) -> PathBuf { + let mut path = path.as_os_str().to_os_string(); + path.push(".exe"); + PathBuf::from(path) +} + +fn is_file_name(path: &Path, program: &OsStr) -> bool { + let mut components = path.components(); + matches!(components.next(), Some(Component::Normal(name)) if name == program) + && components.next().is_none() +} + +fn has_suffix(value: &[u16], suffix: &[u8]) -> bool { + value.len() >= suffix.len() + && value[value.len() - suffix.len()..] + .iter() + .zip(suffix) + .all(|(&left, &right)| { + u8::try_from(left).is_ok_and(|left| left.eq_ignore_ascii_case(&right)) + }) +} + +fn system_directory() -> io::Result { + query_directory(|buffer| { + // SAFETY: the optional slice is writable for its reported length. + unsafe { GetSystemDirectoryW(buffer) } + }) +} + +fn windows_directory() -> io::Result { + query_directory(|buffer| { + // SAFETY: the optional slice is writable for its reported length. + unsafe { GetWindowsDirectoryW(buffer) } + }) +} + +fn query_directory(mut query: impl FnMut(Option<&mut [u16]>) -> u32) -> io::Result { + let required = query(None); + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0; required as usize]; + loop { + let length = query(Some(&mut buffer)); + if length == 0 { + return Err(io::Error::last_os_error()); + } + if (length as usize) < buffer.len() { + buffer.truncate(length as usize); + return Ok(PathBuf::from(OsString::from_wide(&buffer))); + } + buffer.resize(length as usize + 1, 0); + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs::File, + os::windows::ffi::{OsStrExt, OsStringExt}, + }; + + use tempfile::tempdir; + + use super::*; + + type EnvChange = (OsString, Option); + + fn resolve_program( + program: impl AsRef, + changes: &[EnvChange], + ) -> io::Result { + resolve( + program.as_ref(), + changes + .iter() + .map(|(key, value)| (key.as_os_str(), value.as_deref())), + ) + } + + fn units(path: &OsStr) -> Vec { + path.encode_wide().chain([0]).collect() + } + + #[test] + fn preserves_explicit_executable_paths_and_wtf16() { + let current = env::current_exe().unwrap(); + let resolved = resolve_program(current.as_os_str(), &[]).unwrap(); + assert_eq!(resolved.as_units(), units(current.as_os_str())); + + let raw = OsString::from_wide(&[ + b'C' as u16, + b':' as u16, + b'\\' as u16, + 0xd800, + b'.' as u16, + b'e' as u16, + b'x' as u16, + b'e' as u16, + ]); + let resolved = resolve_program(&raw, &[]).unwrap(); + assert_eq!(resolved.as_units(), units(&raw)); + } + + #[test] + fn searches_the_application_and_system_directories() { + let current = env::current_exe().unwrap(); + let file_name = current.file_name().unwrap().to_os_string(); + let resolved = resolve_program(file_name, &[]).unwrap(); + assert_eq!(resolved.as_units(), units(current.as_os_str())); + + let command = resolve_program("cmd", &[]).unwrap(); + let command = PathBuf::from(OsString::from_wide( + &command.as_units()[..command.as_units().len() - 1], + )); + assert!(command.exists()); + assert!( + command + .file_name() + .unwrap() + .to_string_lossy() + .eq_ignore_ascii_case("cmd.exe") + ); + } + + #[test] + fn searches_the_explicit_child_path_case_insensitively() { + let directory = tempdir().unwrap(); + let executable = directory.path().join("tracked.exe"); + File::create(&executable).unwrap(); + let changes = [( + OsString::from("Path"), + Some(directory.path().as_os_str().to_os_string()), + )]; + + let resolved = resolve_program("tracked", &changes).unwrap(); + assert_eq!(resolved.as_units(), units(executable.as_os_str())); + } + + #[test] + fn appends_exe_to_an_existing_direct_path() { + let directory = tempdir().unwrap(); + let requested = directory.path().join("direct"); + let executable = directory.path().join("direct.exe"); + File::create(&executable).unwrap(); + + let resolved = resolve_program(requested.as_os_str(), &[]).unwrap(); + assert_eq!(resolved.as_units(), units(executable.as_os_str())); + } + + #[test] + fn rejects_batch_files_and_missing_bare_programs() { + for program in ["script.bat", "SCRIPT.CMD"] { + let error = resolve_program(program, &[]).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "Windows PTY spawning does not execute batch scripts directly" + ); + } + + let error = resolve_program("process-wrap-definitely-missing-program", &[]).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!(error.to_string(), "PTY program not found"); + } +} From 4f8132e032a73b01516a7d600de9e0438e7e2049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 00:59:51 +1200 Subject: [PATCH 007/114] feat(pty): resolve ConPTY capabilities dynamically Port the complete runtime capability set from revisions df66fb7d, 193d16fc, and 2bc7ab1f. Resolve all four exports without adding them to the crate static import table, preserving Unsupported behavior on older Windows runtimes. Co-Authored-By: Claude --- Cargo.toml | 2 + src/tokio/pty/windows/api.rs | 195 +++++++++++++++++++++++++++++++++++ src/tokio/pty/windows/mod.rs | 1 + 3 files changed, 198 insertions(+) create mode 100644 src/tokio/pty/windows/api.rs diff --git a/Cargo.toml b/Cargo.toml index 53400ed..f836dc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,9 @@ pty = [ "tokio/net", "dep:windows", "windows/Win32_Globalization", + "windows/Win32_System_Console", "windows/Win32_System_Environment", + "windows/Win32_System_LibraryLoader", "windows/Win32_System_SystemInformation", "windows/Win32_System_Threading", ] diff --git a/src/tokio/pty/windows/api.rs b/src/tokio/pty/windows/api.rs new file mode 100644 index 0000000..066be22 --- /dev/null +++ b/src/tokio/pty/windows/api.rs @@ -0,0 +1,195 @@ +//! Runtime resolution of the ConPTY API surface. +//! +//! These functions must not appear in the crate's static import table: loading process-wrap on an +//! older Windows release must continue to work, with PTY spawning reporting `Unsupported` instead. + +use std::{ffi::CStr, io, sync::OnceLock}; + +use windows::{ + Win32::{ + Foundation::{FARPROC, HANDLE}, + System::{ + Console::{COORD, HPCON}, + LibraryLoader::{GetModuleHandleW, GetProcAddress}, + }, + }, + core::{HRESULT, PCSTR, w}, +}; + +pub(super) type CreatePseudoConsole = + unsafe extern "system" fn(COORD, HANDLE, HANDLE, u32, *mut HPCON) -> HRESULT; +pub(super) type ResizePseudoConsole = unsafe extern "system" fn(HPCON, COORD) -> HRESULT; +pub(super) type ReleasePseudoConsole = unsafe extern "system" fn(HPCON) -> HRESULT; +pub(super) type ClosePseudoConsole = unsafe extern "system" fn(HPCON); + +#[derive(Clone, Copy, Debug)] +pub(super) struct ConPtyApi { + pub(super) create: CreatePseudoConsole, + pub(super) resize: ResizePseudoConsole, + pub(super) release: ReleasePseudoConsole, + pub(super) close: ClosePseudoConsole, +} + +pub(super) fn get() -> io::Result<&'static ConPtyApi> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(resolve).as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "Windows pseudo-console APIs are unavailable", + ) + }) +} + +fn resolve() -> Option { + // SAFETY: kernel32.dll is loaded for every Windows process and GetModuleHandleW only borrows its + // module handle. + let kernel32 = unsafe { GetModuleHandleW(w!("kernel32.dll")) }.ok()?; + resolve_with(|name| { + // SAFETY: kernel32 remains loaded for the process lifetime, and name is a NUL-terminated C + // string which remains live for the call. Returned addresses have the module's lifetime. + unsafe { GetProcAddress(kernel32, PCSTR(name.as_ptr().cast())) } + }) +} + +fn resolve_with(mut lookup: impl FnMut(&CStr) -> FARPROC) -> Option { + let create = lookup(c"CreatePseudoConsole")?; + let resize = lookup(c"ResizePseudoConsole")?; + let release = lookup(c"ReleasePseudoConsole")?; + let close = lookup(c"ClosePseudoConsole")?; + + // SAFETY: each address was resolved under the matching exported function name. Win32 function + // pointers share one representation, and these signatures are the documented ConPTY ABI. + Some(unsafe { + ConPtyApi { + create: std::mem::transmute:: isize, CreatePseudoConsole>( + create, + ), + resize: std::mem::transmute:: isize, ResizePseudoConsole>( + resize, + ), + release: std::mem::transmute::< + unsafe extern "system" fn() -> isize, + ReleasePseudoConsole, + >(release), + close: std::mem::transmute:: isize, ClosePseudoConsole>( + close, + ), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + unsafe extern "system" fn create( + _size: COORD, + _input: HANDLE, + _output: HANDLE, + _flags: u32, + _pseudo_console: *mut HPCON, + ) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn resize(_pseudo_console: HPCON, _size: COORD) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn release(_pseudo_console: HPCON) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn close(_pseudo_console: HPCON) {} + + fn create_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>(create) + }) + } + + fn resize_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>(resize) + }) + } + + fn release_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>( + release, + ) + }) + } + + fn close_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>(close) + }) + } + + fn lookup(name: &CStr) -> FARPROC { + match name.to_bytes() { + b"CreatePseudoConsole" => create_proc(), + b"ResizePseudoConsole" => resize_proc(), + b"ReleasePseudoConsole" => release_proc(), + b"ClosePseudoConsole" => close_proc(), + _ => None, + } + } + + #[test] + fn resolves_all_four_exports_by_exact_name() { + let mut requested = Vec::new(); + let api = resolve_with(|name| { + requested.push(name.to_bytes().to_vec()); + lookup(name) + }) + .unwrap(); + + let expected_create: CreatePseudoConsole = create; + let expected_resize: ResizePseudoConsole = resize; + let expected_release: ReleasePseudoConsole = release; + let expected_close: ClosePseudoConsole = close; + assert_eq!(api.create as *const (), expected_create as *const ()); + assert_eq!(api.resize as *const (), expected_resize as *const ()); + assert_eq!(api.release as *const (), expected_release as *const ()); + assert_eq!(api.close as *const (), expected_close as *const ()); + assert_eq!( + requested, + [ + b"CreatePseudoConsole".to_vec(), + b"ResizePseudoConsole".to_vec(), + b"ReleasePseudoConsole".to_vec(), + b"ClosePseudoConsole".to_vec(), + ] + ); + } + + #[test] + fn rejects_an_incomplete_capability_set() { + for missing in [ + b"CreatePseudoConsole".as_slice(), + b"ResizePseudoConsole".as_slice(), + b"ReleasePseudoConsole".as_slice(), + b"ClosePseudoConsole".as_slice(), + ] { + let api = resolve_with(|name| { + if name.to_bytes() == missing { + None + } else { + lookup(name) + } + }); + assert!( + api.is_none(), + "missing {}", + String::from_utf8_lossy(missing) + ); + } + } +} diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index b8b9408..3e0969c 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -12,6 +12,7 @@ use super::super::SpawnAttempt; use command::{PreparedCommandLine, WideCString, prepare_command_line}; use environment::{PreparedEnvironment, prepare_environment}; +mod api; pub(super) mod command; pub(super) mod environment; mod program; From 5f43231d96f9b2554eb3bbc82530d948b8dd86b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 01:01:16 +1200 Subject: [PATCH 008/114] feat(pty): create ConPTY named pipes Port the synchronous ConPTY endpoints and overlapped Tokio host endpoints from revisions 6150ae88 and 880ce4f2. Keep every handle non-inheritable and use unique local byte-mode pipe instances. Co-Authored-By: Claude --- Cargo.toml | 3 + src/tokio/pty/windows/mod.rs | 1 + src/tokio/pty/windows/pipe.rs | 188 ++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 src/tokio/pty/windows/pipe.rs diff --git a/Cargo.toml b/Cargo.toml index f836dc7..dba3c74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,9 +67,12 @@ pty = [ "tokio/net", "dep:windows", "windows/Win32_Globalization", + "windows/Win32_Security", + "windows/Win32_Storage_FileSystem", "windows/Win32_System_Console", "windows/Win32_System_Environment", "windows/Win32_System_LibraryLoader", + "windows/Win32_System_Pipes", "windows/Win32_System_SystemInformation", "windows/Win32_System_Threading", ] diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 3e0969c..d062fd5 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -15,6 +15,7 @@ use environment::{PreparedEnvironment, prepare_environment}; mod api; pub(super) mod command; pub(super) mod environment; +mod pipe; mod program; #[derive(Debug, Eq, PartialEq)] diff --git a/src/tokio/pty/windows/pipe.rs b/src/tokio/pty/windows/pipe.rs new file mode 100644 index 0000000..f4a5e13 --- /dev/null +++ b/src/tokio/pty/windows/pipe.rs @@ -0,0 +1,188 @@ +//! Split named pipes for the ConPTY-facing synchronous and host-facing overlapped endpoints. + +use std::{ + ffi::OsString, + io, + os::windows::{ + ffi::OsStrExt, + io::{AsRawHandle, FromRawHandle, OwnedHandle}, + }, + sync::atomic::{AtomicU64, Ordering}, +}; + +use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; +use windows::{ + Win32::{ + Foundation::{HANDLE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAGS_AND_ATTRIBUTES, PIPE_ACCESS_INBOUND, + PIPE_ACCESS_OUTBOUND, + }, + System::Pipes::{ + CreateNamedPipeW, NAMED_PIPE_MODE, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, + PIPE_TYPE_BYTE, PIPE_WAIT, + }, + }, + core::PCWSTR, +}; + +#[derive(Clone, Copy, Debug)] +enum Direction { + Input, + Output, +} + +impl Direction { + fn suffix(self) -> &'static str { + match self { + Self::Input => "in", + Self::Output => "out", + } + } + + fn server_access(self) -> FILE_FLAGS_AND_ATTRIBUTES { + let access = match self { + Self::Input => PIPE_ACCESS_INBOUND, + Self::Output => PIPE_ACCESS_OUTBOUND, + }; + access | FILE_FLAG_FIRST_PIPE_INSTANCE + } + + fn configure_client(self, options: &mut ClientOptions) { + match self { + Self::Input => { + options.read(false).write(true); + } + Self::Output => { + options.read(true).write(false); + } + } + } +} + +#[derive(Debug)] +pub(super) struct PipePair { + server: OwnedHandle, + host: NamedPipeClient, +} + +impl PipePair { + pub(super) fn input() -> io::Result { + Self::create(Direction::Input) + } + + pub(super) fn output() -> io::Result { + Self::create(Direction::Output) + } + + fn create(direction: Direction) -> io::Result { + static NEXT_PIPE: AtomicU64 = AtomicU64::new(0); + + let sequence = NEXT_PIPE.fetch_add(1, Ordering::Relaxed); + let name = OsString::from(format!( + r"\\.\pipe\process-wrap-conpty-{}-{sequence}-{}", + std::process::id(), + direction.suffix() + )); + let wide_name = name.encode_wide().chain([0]).collect::>(); + let pipe_mode: NAMED_PIPE_MODE = + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS; + + // SAFETY: wide_name is NUL-terminated and remains live for the call. Null security attributes + // produce a non-inheritable synchronous server handle, as required by ConPTY. + let server = unsafe { + CreateNamedPipeW( + PCWSTR(wide_name.as_ptr()), + direction.server_access(), + pipe_mode, + 1, + 0, + 0, + 0, + None, + ) + }; + if server == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateNamedPipeW returned a unique owned handle which is transferred exactly once. + let server = unsafe { OwnedHandle::from_raw_handle(server.0) }; + + let mut options = ClientOptions::new(); + direction.configure_client(&mut options); + let host = options.open(&name)?; + + Ok(Self { server, host }) + } + + pub(super) fn server_handle(&self) -> HANDLE { + HANDLE(self.server.as_raw_handle()) + } + + pub(super) fn into_parts(self) -> (OwnedHandle, NamedPipeClient) { + (self.server, self.host) + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs::File, + io::{Read, Write}, + os::windows::io::AsRawHandle, + }; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use windows::Win32::Foundation::{GetHandleInformation, HANDLE_FLAG_INHERIT}; + + use super::*; + + fn assert_not_inheritable(handle: HANDLE) { + let mut flags = 0; + // SAFETY: handle is borrowed from a live owner and flags points to initialized writable storage. + unsafe { GetHandleInformation(handle, &mut flags) }.unwrap(); + assert_eq!(flags & HANDLE_FLAG_INHERIT.0, 0); + } + + #[tokio::test] + async fn input_pair_has_a_synchronous_reader_and_overlapped_writer() { + let pair = PipePair::input().unwrap(); + assert_not_inheritable(pair.server_handle()); + assert_not_inheritable(HANDLE(pair.host.as_raw_handle())); + let (server, mut host) = pair.into_parts(); + + let reader = std::thread::spawn(move || { + let mut server = File::from(server); + let mut bytes = [0; 4]; + server.read_exact(&mut bytes).unwrap(); + bytes + }); + host.write_all(b"ping").await.unwrap(); + host.shutdown().await.unwrap(); + assert_eq!(reader.join().unwrap(), *b"ping"); + } + + #[tokio::test] + async fn output_pair_has_a_synchronous_writer_and_overlapped_reader() { + let pair = PipePair::output().unwrap(); + assert_not_inheritable(pair.server_handle()); + assert_not_inheritable(HANDLE(pair.host.as_raw_handle())); + let (server, mut host) = pair.into_parts(); + + let writer = std::thread::spawn(move || { + let mut server = File::from(server); + server.write_all(b"pong").unwrap(); + }); + let mut bytes = [0; 4]; + host.read_exact(&mut bytes).await.unwrap(); + writer.join().unwrap(); + assert_eq!(bytes, *b"pong"); + } + + #[tokio::test] + async fn pipe_names_are_unique_across_live_pairs() { + let first = PipePair::input().unwrap(); + let second = PipePair::input().unwrap(); + assert_ne!(first.server.as_raw_handle(), second.server.as_raw_handle()); + } +} From 5c7715cc7f26f3034cf9145eadbbaf1bfb5f8368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 01:02:24 +1200 Subject: [PATCH 009/114] feat(pty): own ConPTY startup state Port aligned pseudo-console attributes and dynamically owned HPCON lifecycle from revisions 0321b106, 33ba7f80, 6a764385, 9ff3db36, 2bc7ab1f, 7e42c7ec, and 66124413. Preserve idempotent startup release, resizing after release, and off-thread final close. Co-Authored-By: Claude --- src/tokio/pty/windows/attributes.rs | 94 +++++++ src/tokio/pty/windows/console.rs | 380 ++++++++++++++++++++++++++++ src/tokio/pty/windows/mod.rs | 2 + 3 files changed, 476 insertions(+) create mode 100644 src/tokio/pty/windows/attributes.rs create mode 100644 src/tokio/pty/windows/console.rs diff --git a/src/tokio/pty/windows/attributes.rs b/src/tokio/pty/windows/attributes.rs new file mode 100644 index 0000000..6e2c85f --- /dev/null +++ b/src/tokio/pty/windows/attributes.rs @@ -0,0 +1,94 @@ +//! Aligned ownership for a pseudo-console process-thread attribute list. + +use std::{ffi::c_void, io, mem::size_of}; + +use windows::Win32::System::{ + Console::HPCON, + Threading::{ + DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + UpdateProcThreadAttribute, + }, +}; + +#[derive(Debug)] +pub(super) struct AttributeList { + list: LPPROC_THREAD_ATTRIBUTE_LIST, + _storage: Box<[usize]>, +} + +impl AttributeList { + pub(super) fn new(pseudo_console: HPCON) -> io::Result { + let mut bytes = 0; + // SAFETY: a null first call is the documented size query and bytes is writable storage. + let _ = unsafe { InitializeProcThreadAttributeList(None, 1, None, &mut bytes) }; + if bytes == 0 { + return Err(io::Error::last_os_error()); + } + + let words = bytes.div_ceil(size_of::()); + let mut storage = vec![0usize; words].into_boxed_slice(); + let list = LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast()); + // SAFETY: storage is pointer-aligned, has at least the queried byte size, and remains owned by + // the returned guard until after DeleteProcThreadAttributeList runs. + unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut bytes) } + .map_err(io::Error::other)?; + + // Microsoft documents passing the HPCON value itself as lpValue, rather than a pointer to a + // separate HPCON variable. + // SAFETY: list is initialized for one attribute and pseudo_console is a live handle whose owner + // outlives this list's use by CreateProcessW. + if let Err(error) = unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + Some(pseudo_console.0 as *const c_void), + size_of::(), + None, + None, + ) + } { + // SAFETY: the list was initialized successfully and has not yet been deleted. + unsafe { DeleteProcThreadAttributeList(list) }; + return Err(io::Error::other(error)); + } + + Ok(Self { + list, + _storage: storage, + }) + } + + pub(super) fn as_ptr(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.list + } +} + +impl Drop for AttributeList { + fn drop(&mut self) { + // SAFETY: this guard owns one initialized list and drops it before its aligned storage. + unsafe { DeleteProcThreadAttributeList(self.list) }; + } +} + +#[cfg(test)] +mod tests { + use std::mem::align_of; + + use super::*; + + #[test] + fn allocates_an_aligned_single_pseudoconsole_attribute() { + let attributes = AttributeList::new(HPCON(42)).unwrap(); + assert!(!attributes.as_ptr().is_invalid()); + assert_eq!( + attributes._storage.as_ptr() as usize % align_of::(), + 0 + ); + assert_eq!( + attributes.as_ptr().0, + attributes._storage.as_ptr().cast_mut().cast() + ); + } +} diff --git a/src/tokio/pty/windows/console.rs b/src/tokio/pty/windows/console.rs new file mode 100644 index 0000000..2920dc2 --- /dev/null +++ b/src/tokio/pty/windows/console.rs @@ -0,0 +1,380 @@ +//! Dynamically owned pseudo-console handles, resizing, and off-thread close cleanup. + +use std::{ + io, + sync::{Arc, Mutex, OnceLock}, + thread::JoinHandle, +}; + +#[cfg(feature = "tracing")] +use tracing::warn; +use windows::Win32::{ + Foundation::HANDLE, + System::Console::{COORD, HPCON}, +}; + +use super::{ + super::PtySize, + api::{self, ConPtyApi}, +}; + +#[derive(Debug)] +pub(super) struct PseudoConsole { + state: Arc, +} + +#[derive(Clone, Debug)] +pub(super) struct Release { + state: Arc, +} + +#[derive(Debug)] +struct State { + handle: HPCON, + api: &'static ConPtyApi, + lifecycle: Mutex, +} + +#[derive(Debug, Default)] +struct Lifecycle { + released: bool, + closed: bool, +} + +impl PseudoConsole { + pub(super) fn create(size: PtySize, input: HANDLE, output: HANDLE) -> io::Result { + Self::create_with(api::get()?, size, input, output) + } + + fn create_with( + api: &'static ConPtyApi, + size: PtySize, + input: HANDLE, + output: HANDLE, + ) -> io::Result { + let mut handle = HPCON::default(); + // SAFETY: input and output are live synchronous pipe handles, handle points to initialized + // writable storage, and the resolved function has the documented ConPTY ABI. + unsafe { (api.create)(coordinate(size)?, input, output, 0, &mut handle) } + .ok() + .map_err(io::Error::other)?; + if handle.is_invalid() { + return Err(io::Error::other( + "CreatePseudoConsole returned an invalid handle", + )); + } + Ok(Self { + state: Arc::new(State { + handle, + api, + lifecycle: Mutex::new(Lifecycle::default()), + }), + }) + } + + pub(super) fn handle(&self) -> HPCON { + self.state.handle + } + + pub(super) fn releaser(&self) -> Release { + Release { + state: Arc::clone(&self.state), + } + } + + #[cfg(test)] + fn release(&self) -> io::Result<()> { + self.releaser().release() + } + + pub(super) fn resize(&self, size: PtySize) -> io::Result<()> { + let lifecycle = self + .state + .lifecycle + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if lifecycle.closed { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "the pseudo-console is closed", + )); + } + // SAFETY: this is a live pseudo-console and the resolved function has the documented ABI. + unsafe { (self.state.api.resize)(self.state.handle, coordinate(size)?) } + .ok() + .map_err(io::Error::other) + } +} + +impl Release { + pub(super) fn release(&self) -> io::Result<()> { + let mut lifecycle = self + .state + .lifecycle + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if lifecycle.released || lifecycle.closed { + return Ok(()); + } + // SAFETY: the application owns this live pseudo-console and the resolved function has the + // documented ABI. + unsafe { (self.state.api.release)(self.state.handle) } + .ok() + .map_err(io::Error::other)?; + lifecycle.released = true; + Ok(()) + } +} + +impl Drop for PseudoConsole { + fn drop(&mut self) { + let mut lifecycle = self + .state + .lifecycle + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if lifecycle.closed { + return; + } + lifecycle.closed = true; + drop(lifecycle); + schedule_close(self.state.api.close, self.state.handle); + } +} + +pub(super) fn coordinate(size: PtySize) -> io::Result { + size.validate()?; + let columns = i16::try_from(size.columns).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "PTY columns must not exceed 32767 on Windows", + ) + })?; + let rows = i16::try_from(size.rows).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "PTY rows must not exceed 32767 on Windows", + ) + })?; + Ok(COORD { + X: columns, + Y: rows, + }) +} + +fn schedule_close(close: api::ClosePseudoConsole, handle: HPCON) { + static CLOSE_THREADS: OnceLock>>> = OnceLock::new(); + let threads = CLOSE_THREADS.get_or_init(|| Mutex::new(Vec::new())); + let mut threads = threads.lock().unwrap_or_else(|poison| poison.into_inner()); + let mut index = 0; + while index < threads.len() { + if threads[index].is_finished() { + let finished = threads.swap_remove(index); + let _ = finished.join(); + } else { + index += 1; + } + } + + let close_thread = std::thread::Builder::new() + .name("process-wrap-conpty-close".into()) + .spawn(move || { + // SAFETY: ownership of this live raw HPCON was transferred to exactly this close thread. + unsafe { close(handle) }; + }); + match close_thread { + Ok(close_thread) => threads.push(close_thread), + Err(error) => { + // Calling ClosePseudoConsole here could indefinitely block a reactor or arbitrary dropping + // thread on older Windows versions. Leaking is the only safe fallback after thread creation + // itself fails. + #[cfg(feature = "tracing")] + warn!( + ?error, + "failed to start ConPTY close thread; leaking the pseudo-console handle" + ); + #[cfg(not(feature = "tracing"))] + let _ = error; + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}, + time::{Duration, Instant}, + }; + + use windows::core::HRESULT; + + use super::*; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + static CREATE_SIZE: AtomicU32 = AtomicU32::new(0); + static CREATE_FLAGS: AtomicU32 = AtomicU32::new(u32::MAX); + static RESIZE_SIZE: AtomicU32 = AtomicU32::new(0); + static RELEASE_COUNT: AtomicUsize = AtomicUsize::new(0); + static BLOCK_CLOSE: AtomicBool = AtomicBool::new(false); + static CLOSE_STARTED: AtomicBool = AtomicBool::new(false); + static CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "system" fn create( + size: COORD, + _input: HANDLE, + _output: HANDLE, + flags: u32, + pseudo_console: *mut HPCON, + ) -> HRESULT { + CREATE_SIZE.store(pack(size), Ordering::SeqCst); + CREATE_FLAGS.store(flags, Ordering::SeqCst); + // SAFETY: the test caller passes writable storage for the output handle. + unsafe { *pseudo_console = HPCON(42) }; + HRESULT(0) + } + + unsafe extern "system" fn resize(_pseudo_console: HPCON, size: COORD) -> HRESULT { + RESIZE_SIZE.store(pack(size), Ordering::SeqCst); + HRESULT(0) + } + + unsafe extern "system" fn release(_pseudo_console: HPCON) -> HRESULT { + RELEASE_COUNT.fetch_add(1, Ordering::SeqCst); + HRESULT(0) + } + + unsafe extern "system" fn close(_pseudo_console: HPCON) { + CLOSE_STARTED.store(true, Ordering::SeqCst); + for _ in 0..1_000 { + if !BLOCK_CLOSE.load(Ordering::SeqCst) { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + CLOSE_COUNT.fetch_add(1, Ordering::SeqCst); + } + + static TEST_API: ConPtyApi = ConPtyApi { + create, + resize, + release, + close, + }; + + fn pack(size: COORD) -> u32 { + u32::from(size.X as u16) | (u32::from(size.Y as u16) << 16) + } + + fn wait_for(predicate: impl Fn() -> bool) { + for _ in 0..1_000 { + if predicate() { + return; + } + std::thread::sleep(Duration::from_millis(1)); + } + panic!("timed out waiting for mock ConPTY operation"); + } + + fn reset_close() { + RELEASE_COUNT.store(0, Ordering::SeqCst); + BLOCK_CLOSE.store(false, Ordering::SeqCst); + CLOSE_STARTED.store(false, Ordering::SeqCst); + CLOSE_COUNT.store(0, Ordering::SeqCst); + } + + #[test] + fn converts_character_dimensions_and_rejects_windows_overflow() { + assert_eq!( + coordinate(PtySize::new(24, 80).unwrap()).unwrap(), + COORD { X: 80, Y: 24 } + ); + let columns = coordinate(PtySize { + rows: 1, + columns: 32_768, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap_err(); + assert_eq!(columns.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + columns.to_string(), + "PTY columns must not exceed 32767 on Windows" + ); + let rows = coordinate(PtySize { + rows: 32_768, + columns: 1, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap_err(); + assert_eq!(rows.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + rows.to_string(), + "PTY rows must not exceed 32767 on Windows" + ); + } + + #[test] + fn creates_and_resizes_through_the_resolved_capabilities() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + CREATE_SIZE.store(0, Ordering::SeqCst); + CREATE_FLAGS.store(u32::MAX, Ordering::SeqCst); + RESIZE_SIZE.store(0, Ordering::SeqCst); + + let console = PseudoConsole::create_with( + &TEST_API, + PtySize::new(25, 81).unwrap(), + HANDLE(1usize as _), + HANDLE(2usize as _), + ) + .unwrap(); + assert_eq!(console.handle(), HPCON(42)); + assert_eq!( + CREATE_SIZE.load(Ordering::SeqCst), + pack(COORD { X: 81, Y: 25 }) + ); + assert_eq!(CREATE_FLAGS.load(Ordering::SeqCst), 0); + console.resize(PtySize::new(40, 120).unwrap()).unwrap(); + assert_eq!( + RESIZE_SIZE.load(Ordering::SeqCst), + pack(COORD { X: 120, Y: 40 }) + ); + console.release().unwrap(); + console.release().unwrap(); + assert_eq!(RELEASE_COUNT.load(Ordering::SeqCst), 1); + console.resize(PtySize::new(41, 121).unwrap()).unwrap(); + assert_eq!( + RESIZE_SIZE.load(Ordering::SeqCst), + pack(COORD { X: 121, Y: 41 }) + ); + + drop(console); + wait_for(|| CLOSE_COUNT.load(Ordering::SeqCst) == 1); + } + + #[test] + fn drop_schedules_exactly_one_close_away_from_the_caller() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + BLOCK_CLOSE.store(true, Ordering::SeqCst); + let console = PseudoConsole { + state: Arc::new(State { + handle: HPCON(42), + api: &TEST_API, + lifecycle: Mutex::new(Lifecycle::default()), + }), + }; + + let started = Instant::now(); + drop(console); + assert!(started.elapsed() < Duration::from_millis(250)); + wait_for(|| CLOSE_STARTED.load(Ordering::SeqCst)); + assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 0); + + BLOCK_CLOSE.store(false, Ordering::SeqCst); + wait_for(|| CLOSE_COUNT.load(Ordering::SeqCst) == 1); + std::thread::sleep(Duration::from_millis(10)); + assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 1); + } +} diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index d062fd5..6ad92e8 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -13,7 +13,9 @@ use command::{PreparedCommandLine, WideCString, prepare_command_line}; use environment::{PreparedEnvironment, prepare_environment}; mod api; +mod attributes; pub(super) mod command; +mod console; pub(super) mod environment; mod pipe; mod program; From 8ff220c75e7d66e6cb7d2eab693e77f8ee69ba74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 01:03:47 +1200 Subject: [PATCH 010/114] feat(pty): own ConPTY child processes Port direct Win32 child ownership, exact primary-thread resume, and cancellation-safe repeated waits from revisions 92c683c1, fdbd3e72, 0a65307c, and 5a526988. Adapt the child to current process-handle and resume capabilities, retaining armed process cleanup until finalization. Co-Authored-By: Claude --- src/tokio/pty/windows/child.rs | 407 +++++++++++++++++++++++++++++++++ src/tokio/pty/windows/mod.rs | 1 + 2 files changed, 408 insertions(+) create mode 100644 src/tokio/pty/windows/child.rs diff --git a/src/tokio/pty/windows/child.rs b/src/tokio/pty/windows/child.rs new file mode 100644 index 0000000..d526ef7 --- /dev/null +++ b/src/tokio/pty/windows/child.rs @@ -0,0 +1,407 @@ +//! A terminal [`ChildWrapper`] backed directly by Win32 process handles. + +use std::{ + future::Future, + io, + os::windows::{ + io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}, + process::ExitStatusExt, + }, + pin::Pin, + process::ExitStatus, +}; + +use tokio::{ + process::{ChildStderr, ChildStdin, ChildStdout}, + task::{JoinHandle, spawn_blocking}, +}; +#[cfg(feature = "job-object")] +use windows::Win32::System::Threading::ResumeThread; +use windows::Win32::{ + Foundation::{ + DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + System::Threading::{ + GetCurrentProcess, GetExitCodeProcess, INFINITE, TerminateProcess, WaitForSingleObject, + }, +}; + +use crate::{ChildExitStatus, tokio::ChildWrapper}; + +#[derive(Debug)] +pub(super) struct ConPtyChild { + process: OwnedHandle, + #[cfg_attr(not(feature = "job-object"), allow(dead_code))] + primary_thread: Option, + pid: u32, + kill_on_drop: bool, + cleanup_armed: bool, + exit_status: ChildExitStatus, + wait_task: Option>>, + stdin: Option, + stdout: Option, + stderr: Option, +} + +impl ConPtyChild { + pub(super) fn new( + process: OwnedHandle, + primary_thread: OwnedHandle, + pid: u32, + kill_on_drop: bool, + ) -> Self { + Self { + process, + primary_thread: Some(primary_thread), + pid, + kill_on_drop, + cleanup_armed: true, + exit_status: ChildExitStatus::Running, + wait_task: None, + stdin: None, + stdout: None, + stderr: None, + } + } + + #[cfg(test)] + pub(super) fn primary_thread_handle(&self) -> Option> { + self.primary_thread.as_ref().map(OwnedHandle::as_handle) + } + + #[cfg(feature = "job-object")] + pub(super) fn resume_primary_thread(&mut self) -> io::Result<()> { + let thread = self + .primary_thread + .take() + .ok_or_else(|| io::Error::other("ConPTY child has no primary thread handle"))?; + // SAFETY: thread is the live primary-thread handle returned by CreateProcessW. + let previous_count = unsafe { ResumeThread(HANDLE(thread.as_raw_handle())) }; + match previous_count { + u32::MAX => Err(io::Error::last_os_error()), + 1 => Ok(()), + 0 => Err(io::Error::other( + "ConPTY primary thread was not suspended for job assignment", + )), + count => Err(io::Error::other(format!( + "ConPTY primary thread remains suspended with count {}", + count - 1 + ))), + } + } + + fn raw_process_handle(&self) -> HANDLE { + HANDLE(self.process.as_raw_handle()) + } +} + +impl ChildWrapper for ConPtyChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn process_handle(&self) -> Option> { + Some(self.process.as_handle()) + } + + #[cfg(feature = "job-object")] + fn resume_after_job_assignment(&mut self) -> Option> { + Some(self.resume_primary_thread()) + } + + fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + self.cleanup_armed = false; + Ok(()) + } + + fn stdin(&mut self) -> &mut Option { + &mut self.stdin + } + + fn stdout(&mut self) -> &mut Option { + &mut self.stdout + } + + fn stderr(&mut self) -> &mut Option { + &mut self.stderr + } + + fn id(&self) -> Option { + Some(self.pid) + } + + fn start_kill(&mut self) -> io::Result<()> { + if self.try_wait()?.is_some() { + return Ok(()); + } + // SAFETY: self owns a live process handle with process-termination access. + unsafe { TerminateProcess(self.raw_process_handle(), 1) }.map_err(io::Error::other) + } + + fn try_wait(&mut self) -> io::Result> { + if let ChildExitStatus::Exited(status) = self.exit_status { + return Ok(Some(status)); + } + let status = process_status(self.raw_process_handle(), 0)?; + if let Some(status) = status { + self.exit_status = ChildExitStatus::Exited(status); + self.wait_task.take(); + } + Ok(status) + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { + if let ChildExitStatus::Exited(status) = self.exit_status { + return Ok(status); + } + + if self.wait_task.is_none() { + let process = duplicate_handle(&self.process)?; + self.wait_task = Some(spawn_blocking(move || { + process_status(HANDLE(process.as_raw_handle()), INFINITE)?.ok_or_else(|| { + io::Error::other("infinite process wait returned without an exit status") + }) + })); + } + let result = self + .wait_task + .as_mut() + .expect("an in-progress ConPTY wait must retain its task") + .await; + self.wait_task.take(); + let status = result.map_err(io::Error::other)??; + self.exit_status = ChildExitStatus::Exited(status); + Ok(status) + }) + } +} + +impl Drop for ConPtyChild { + fn drop(&mut self) { + if (self.cleanup_armed || self.kill_on_drop) + && matches!(self.exit_status, ChildExitStatus::Running) + && !matches!(process_status(self.raw_process_handle(), 0), Ok(Some(_))) + { + // SAFETY: self still owns the process handle for the duration of this call. + let _ = unsafe { TerminateProcess(self.raw_process_handle(), 1) }; + } + } +} + +fn duplicate_handle(handle: &OwnedHandle) -> io::Result { + // SAFETY: GetCurrentProcess returns a permanent pseudo-handle. The source handle is live, and the + // output pointer is writable. The resulting non-inheritable duplicate is transferred once. + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + process, + HANDLE(handle.as_raw_handle()), + process, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .map_err(io::Error::other)?; + Ok(OwnedHandle::from_raw_handle(duplicate.0)) + } +} + +fn process_status(process: HANDLE, timeout: u32) -> io::Result> { + // SAFETY: process is a live process handle and timeout has the documented wait semantics. + match unsafe { WaitForSingleObject(process, timeout) } { + WAIT_TIMEOUT => Ok(None), + WAIT_OBJECT_0 => { + let mut code = 0; + // SAFETY: the process is signaled and code points to initialized writable storage. + unsafe { GetExitCodeProcess(process, &mut code) }.map_err(io::Error::other)?; + Ok(Some(ExitStatus::from_raw(code))) + } + WAIT_FAILED => Err(io::Error::last_os_error()), + other => Err(io::Error::other(format!( + "unexpected process wait result {}", + other.0 + ))), + } +} + +#[cfg(test)] +mod tests { + use std::{ + os::windows::io::{AsRawHandle, FromRawHandle}, + process::{Child, Command}, + thread::sleep, + time::{Duration, Instant}, + }; + + use super::*; + + fn duplicate_process(child: &Child) -> OwnedHandle { + // SAFETY: the current-process pseudo-handle and child's process handle are live, and duplicate + // points to initialized writable storage. Ownership of the result is transferred once. + unsafe { + let current = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + current, + HANDLE(child.as_raw_handle()), + current, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .unwrap(); + OwnedHandle::from_raw_handle(duplicate.0) + } + } + + fn wrap(child: &Child, kill_on_drop: bool) -> ConPtyChild { + ConPtyChild { + process: duplicate_process(child), + primary_thread: None, + pid: child.id(), + kill_on_drop, + cleanup_armed: false, + exit_status: ChildExitStatus::Running, + wait_task: None, + stdin: None, + stdout: None, + stderr: None, + } + } + + fn spawn_exit_259() -> Child { + Command::new("cmd.exe") + .args(["/D", "/C", "exit /b 259"]) + .spawn() + .unwrap() + } + + fn spawn_long_running() -> Child { + Command::new("ping.exe") + .args(["-n", "30", "127.0.0.1"]) + .spawn() + .unwrap() + } + + fn wait_native(child: &mut Child) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "native test child did not exit"); + sleep(Duration::from_millis(10)); + } + } + + #[tokio::test] + async fn preserves_exit_code_259_and_repeated_waits() { + let mut native = spawn_exit_259(); + let mut child = wrap(&native, false); + assert_eq!(child.id(), Some(native.id())); + assert!(child.process_handle().is_some()); + assert!(child.primary_thread_handle().is_none()); + assert!((&child as &dyn ChildWrapper).try_inner_child().is_none()); + + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.code(), Some(259)); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn kills_and_reaps_the_direct_process() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + assert!(child.try_wait().unwrap().is_none()); + child.start_kill().unwrap(); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn canceled_async_waits_share_one_task_and_preserve_the_process_handle() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + assert!( + tokio::time::timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + let wait_id = child + .wait_task + .as_ref() + .expect("a canceled wait must retain its task") + .id(); + for _ in 0..7 { + assert!( + tokio::time::timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + assert_eq!( + child + .wait_task + .as_ref() + .expect("a canceled wait must retain its task") + .id(), + wait_id + ); + } + child.start_kill().unwrap(); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(wait_native(&mut native), status); + } + + #[test] + fn armed_spawn_cleanup_terminates_a_running_process() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + child.cleanup_armed = true; + drop(child); + let _ = wait_native(&mut native); + } + + #[test] + fn disarming_spawn_cleanup_preserves_a_running_process() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + child.cleanup_armed = true; + child.disarm_spawn_cleanup_layer().unwrap(); + drop(child); + assert!(native.try_wait().unwrap().is_none()); + native.kill().unwrap(); + let _ = wait_native(&mut native); + } + + #[test] + fn kill_on_drop_terminates_a_running_process() { + let mut native = spawn_long_running(); + let child = wrap(&native, true); + drop(child); + let _ = wait_native(&mut native); + } +} diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 6ad92e8..7bb36a1 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -14,6 +14,7 @@ use environment::{PreparedEnvironment, prepare_environment}; mod api; mod attributes; +mod child; pub(super) mod command; mod console; pub(super) mod environment; From e4fe2cefee92828a7adaac588c50e14615193478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 01:07:46 +1200 Subject: [PATCH 011/114] feat(pty): share ConPTY controller ownership Port strong input/output and weak resize ownership from revision bc8f3ab0. Duplicate both host pipe handles into the shared master so either public I/O half can keep the complete pseudo-console transport alive. Co-Authored-By: Claude --- src/tokio/pty/windows/controller.rs | 164 ++++++++++++++++++++++++++++ src/tokio/pty/windows/mod.rs | 1 + 2 files changed, 165 insertions(+) create mode 100644 src/tokio/pty/windows/controller.rs diff --git a/src/tokio/pty/windows/controller.rs b/src/tokio/pty/windows/controller.rs new file mode 100644 index 0000000..7790d04 --- /dev/null +++ b/src/tokio/pty/windows/controller.rs @@ -0,0 +1,164 @@ +//! Shared Windows PTY controller ownership and Tokio named-pipe I/O. + +use std::{ + io, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + pin::Pin, + sync::{Arc, Weak}, + task::{Context, Poll}, +}; + +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::windows::named_pipe::NamedPipeClient, +}; +use windows::Win32::{ + Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE}, + System::Threading::GetCurrentProcess, +}; + +use super::{super::PtySize, console::PseudoConsole}; + +type SharedMaster = Arc; + +#[derive(Debug)] +struct Master { + _input_lifetime: OwnedHandle, + _output_lifetime: OwnedHandle, + console: PseudoConsole, +} + +#[derive(Debug)] +pub(in crate::tokio::pty) struct Input { + pipe: Option, + master: Option, +} + +impl AsyncWrite for Input { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Err(closed())); + }; + Pin::new(pipe).poll_write(cx, buffer) + } + + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffers: &[io::IoSlice<'_>], + ) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Err(closed())); + }; + Pin::new(pipe).poll_write_vectored(cx, buffers) + } + + fn is_write_vectored(&self) -> bool { + self.pipe + .as_ref() + .is_some_and(AsyncWrite::is_write_vectored) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Err(closed())); + }; + Pin::new(pipe).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Ok(())); + }; + match Pin::new(pipe).poll_shutdown(cx) { + Poll::Ready(Ok(())) => { + self.pipe.take(); + self.master.take(); + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +#[derive(Debug)] +pub(in crate::tokio::pty) struct Output { + pipe: NamedPipeClient, + _master: SharedMaster, +} + +impl AsyncRead for Output { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.pipe).poll_read(cx, buffer) + } +} + +#[derive(Clone, Debug)] +pub(in crate::tokio::pty) struct Resize { + master: Weak, +} + +impl Resize { + pub(in crate::tokio::pty) fn resize(&self, size: PtySize) -> io::Result<()> { + let master = self.master.upgrade().ok_or_else(closed)?; + master.console.resize(size) + } +} + +pub(super) fn controller( + console: PseudoConsole, + input: NamedPipeClient, + output: NamedPipeClient, +) -> io::Result<(Input, Output, Resize)> { + let input_lifetime = duplicate(HANDLE(input.as_raw_handle()))?; + let output_lifetime = duplicate(HANDLE(output.as_raw_handle()))?; + let master = Arc::new(Master { + _input_lifetime: input_lifetime, + _output_lifetime: output_lifetime, + console, + }); + let resize = Resize { + master: Arc::downgrade(&master), + }; + let input = Input { + pipe: Some(input), + master: Some(Arc::clone(&master)), + }; + let output = Output { + pipe: output, + _master: master, + }; + Ok((input, output, resize)) +} + +fn duplicate(handle: HANDLE) -> io::Result { + // SAFETY: the current-process pseudo-handle and source named-pipe handle are live, duplicate points + // to writable storage, and ownership of the non-inheritable result is transferred exactly once. + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + process, + handle, + process, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .map_err(io::Error::other)?; + Ok(OwnedHandle::from_raw_handle(duplicate.0)) + } +} + +fn closed() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "PTY master is closed") +} diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 7bb36a1..69216c2 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -17,6 +17,7 @@ mod attributes; mod child; pub(super) mod command; mod console; +mod controller; pub(super) mod environment; mod pipe; mod program; From 27f50b17a2c8b70b4f9963617b3a9f7e56ffa6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 01:10:05 +1200 Subject: [PATCH 012/114] feat(pty): create ConPTY processes exactly Port manual CreateProcessW assembly and defensive malformed-output cleanup from revisions a2571eff and 66124413. Adapt creation flags and direct kill-on-drop to WindowsSpawnPolicy while retaining independent rollback ownership for the provider lifecycle. Co-Authored-By: Claude --- src/tokio/pty/windows/mod.rs | 1 + src/tokio/pty/windows/spawn.rs | 260 +++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 src/tokio/pty/windows/spawn.rs diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 69216c2..bbe096a 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -21,6 +21,7 @@ mod controller; pub(super) mod environment; mod pipe; mod program; +mod spawn; #[derive(Debug, Eq, PartialEq)] struct PreparedWindowsCommand { diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs new file mode 100644 index 0000000..f5717ee --- /dev/null +++ b/src/tokio/pty/windows/spawn.rs @@ -0,0 +1,260 @@ +//! Exact `CreateProcessW` spawning for a prepared ConPTY command. + +use std::{ + ffi::c_void, + io, + mem::size_of, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, +}; + +use windows::{ + Win32::{ + Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WIN32_ERROR}, + System::Threading::{ + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, + GetCurrentProcess, GetProcessIdOfThread, INFINITE, OpenProcess, PROCESS_CREATION_FLAGS, + PROCESS_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, STARTF_USESTDHANDLES, + STARTUPINFOEXW, TerminateProcess, WaitForSingleObject, + }, + }, + core::{PCWSTR, PWSTR}, +}; + +use super::{ + PreparedWindowsCommand, attributes::AttributeList, child::ConPtyChild, + environment::PreparedEnvironment, +}; + +pub(super) fn spawn( + mut command: PreparedWindowsCommand, + attributes: &AttributeList, + input_server: OwnedHandle, + output_server: OwnedHandle, +) -> io::Result { + let startup = startup_info(attributes); + + let mut flags = PROCESS_CREATION_FLAGS(command.creation.spawn_creation_flags()) + | EXTENDED_STARTUPINFO_PRESENT; + let environment = match &command.environment { + PreparedEnvironment::Inherit => None, + PreparedEnvironment::Block(block) => { + flags |= CREATE_UNICODE_ENVIRONMENT; + Some(block.as_ptr().cast::()) + } + }; + let current_dir = command + .current_dir + .as_ref() + .map_or(PCWSTR::null(), |directory| PCWSTR(directory.as_ptr())); + let mut information = PROCESS_INFORMATION::default(); + + // SAFETY: every pointer references live, NUL-terminated or explicitly bounded storage for the + // duration of the call. The command line is uniquely mutable, the startup attribute list owns its + // aligned backing storage, no handles are inherited, and STARTF_USESTDHANDLES with three null slots + // asks ConPTY to install its console handles. The process-information value is writable output + // storage. + let created = unsafe { + CreateProcessW( + PCWSTR(command.application_name.as_ptr()), + Some(PWSTR(command.command_line.as_mut_ptr())), + None, + None, + false, + flags, + environment, + current_dir, + &startup.StartupInfo, + &mut information, + ) + }; + // ConPTY retains these synchronous endpoints internally. Dropping the parent copies before any + // validation or handle duplication lets both host-facing channels observe closure on every path. + drop(input_server); + drop(output_server); + created.map_err(win32_io_error)?; + + let handles = SpawnedProcess::new(information)?; + let cleanup = handles.cleanup()?; + let child = handles.into_child(command.creation.kills_on_drop()); + Ok(SpawnedChild { child, cleanup }) +} + +fn startup_info(attributes: &AttributeList) -> STARTUPINFOEXW { + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = HANDLE::default(); + startup.StartupInfo.hStdOutput = HANDLE::default(); + startup.StartupInfo.hStdError = HANDLE::default(); + startup.lpAttributeList = attributes.as_ptr(); + startup +} + +#[derive(Debug)] +pub(super) struct SpawnedChild { + pub(super) child: ConPtyChild, + pub(super) cleanup: SpawnCleanup, +} + +#[derive(Debug)] +pub(super) struct SpawnCleanup { + process: Option, +} + +impl SpawnCleanup { + pub(super) fn disarm(&mut self) { + self.process.take(); + } + + pub(super) fn rollback(&mut self) { + if let Some(process) = self.process.take() { + terminate_and_wait(&process); + } + } +} + +impl Drop for SpawnCleanup { + fn drop(&mut self) { + self.rollback(); + } +} + +struct SpawnedProcess { + process: Option, + primary_thread: Option, + pid: u32, +} + +impl SpawnedProcess { + fn new(information: PROCESS_INFORMATION) -> io::Result { + if information.hProcess.is_invalid() || information.hThread.is_invalid() { + cleanup_invalid_information(information); + return Err(io::Error::other( + "CreateProcessW returned invalid process information", + )); + } + + // SAFETY: CreateProcessW returned two distinct owned handles, each transferred exactly once. + let process = unsafe { OwnedHandle::from_raw_handle(information.hProcess.0) }; + // SAFETY: as above, ownership of the primary-thread handle is transferred exactly once. + let primary_thread = unsafe { OwnedHandle::from_raw_handle(information.hThread.0) }; + Ok(Self { + process: Some(process), + primary_thread: Some(primary_thread), + pid: information.dwProcessId, + }) + } + + fn cleanup(&self) -> io::Result { + let process = self + .process + .as_ref() + .expect("a spawned process guard must own its process handle"); + Ok(SpawnCleanup { + process: Some(duplicate(process)?), + }) + } + + fn into_child(mut self, kill_on_drop: bool) -> ConPtyChild { + let process = self + .process + .take() + .expect("a spawned process guard must own its process handle"); + let primary_thread = self + .primary_thread + .take() + .expect("a spawned process guard must own its primary-thread handle"); + ConPtyChild::new(process, primary_thread, self.pid, kill_on_drop) + } +} + +impl Drop for SpawnedProcess { + fn drop(&mut self) { + if let Some(process) = &self.process { + terminate_and_wait(process); + } + } +} + +fn cleanup_invalid_information(information: PROCESS_INFORMATION) { + let thread = (!information.hThread.is_invalid()).then(|| { + // SAFETY: a non-invalid CreateProcessW output handle is uniquely owned here. + unsafe { OwnedHandle::from_raw_handle(information.hThread.0) } + }); + let pid = thread.as_ref().map_or(0, |thread| { + // SAFETY: thread owns the live primary-thread handle returned by CreateProcessW. + unsafe { GetProcessIdOfThread(HANDLE(thread.as_raw_handle())) } + }); + let pid = if pid != 0 && (information.dwProcessId == 0 || information.dwProcessId == pid) { + pid + } else { + 0 + }; + let process = if !information.hProcess.is_invalid() { + // SAFETY: a non-invalid CreateProcessW output handle is uniquely owned here. + Some(unsafe { OwnedHandle::from_raw_handle(information.hProcess.0) }) + } else if pid != 0 { + // SAFETY: pid identifies the process CreateProcessW reported creating. The recovered handle is + // non-inheritable and is transferred exactly once if it can still be opened. + unsafe { OpenProcess(PROCESS_TERMINATE | PROCESS_SYNCHRONIZE, false, pid) } + .ok() + .map(|handle| unsafe { OwnedHandle::from_raw_handle(handle.0) }) + } else { + None + }; + if let Some(process) = process { + terminate_and_wait(&process); + } +} + +fn duplicate(handle: &OwnedHandle) -> io::Result { + // SAFETY: the current-process pseudo-handle and source process handle are live, duplicate points + // to writable storage, and ownership of the non-inheritable result is transferred exactly once. + unsafe { + let current = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + current, + HANDLE(handle.as_raw_handle()), + current, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .map_err(win32_io_error)?; + Ok(OwnedHandle::from_raw_handle(duplicate.0)) + } +} + +fn terminate_and_wait(process: &OwnedHandle) { + let process = HANDLE(process.as_raw_handle()); + // SAFETY: the guard owns the process handle and is cleaning up a process which cannot be returned + // to the caller. Waiting after termination prevents a detached live process. + let _ = unsafe { TerminateProcess(process, 1) }; + // SAFETY: the process handle owner remains live until after this wait. + let _ = unsafe { WaitForSingleObject(process, INFINITE) }; +} + +fn win32_io_error(error: windows::core::Error) -> io::Error { + WIN32_ERROR::from_error(&error) + .and_then(|error| i32::try_from(error.0).ok()) + .map_or_else(|| io::Error::other(error), io::Error::from_raw_os_error) +} + +#[cfg(test)] +mod tests { + use windows::Win32::System::Console::HPCON; + + use super::*; + + #[test] + fn requests_conpty_replacement_for_all_standard_handles() { + let attributes = AttributeList::new(HPCON(42)).unwrap(); + let startup = startup_info(&attributes); + assert_eq!(startup.StartupInfo.dwFlags, STARTF_USESTDHANDLES); + assert_eq!(startup.StartupInfo.hStdInput, HANDLE::default()); + assert_eq!(startup.StartupInfo.hStdOutput, HANDLE::default()); + assert_eq!(startup.StartupInfo.hStdError, HANDLE::default()); + } +} From ad4e1f1da2473744841667e86722673c9eb4e4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 01:12:34 +1200 Subject: [PATCH 013/114] feat(pty): integrate ConPTY spawn provider Adapt backend assembly from revisions 07ca549a, b2df8b6d, 7e42c7ec, and 66124413 to the shared SpawnAttempt and ProviderProduct lifecycle. Keep process rollback and controller visibility armed through public hooks and all internal Windows finalization, with wrapper-native one-shot extraction. Co-Authored-By: Claude --- src/tokio/pty.rs | 30 ++++++++--- src/tokio/pty/windows/backend.rs | 77 ++++++++++++++++++++++++++++ src/tokio/pty/windows/child.rs | 26 ++++++++-- src/tokio/pty/windows/command.rs | 1 + src/tokio/pty/windows/environment.rs | 2 + src/tokio/pty/windows/mod.rs | 4 ++ 6 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 src/tokio/pty/windows/backend.rs diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index b2f75ed..462810d 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -5,6 +5,7 @@ use std::{ }; #[cfg(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -15,14 +16,22 @@ use std::{ target_os = "openbsd", target_os = "solaris" ))] -use std::{ - future::Future, - process::ExitStatus, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, }; +#[cfg(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" +))] +use std::{future::Future, process::ExitStatus, sync::Arc}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; #[cfg(any( @@ -65,6 +74,7 @@ use super::{Command, CommandWrapper, ProviderProduct, SpawnAttempt, SpawnProvide ))] mod unix; #[cfg(not(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -77,7 +87,6 @@ mod unix; )))] mod unsupported; #[cfg(windows)] -#[allow(dead_code)] mod windows; #[cfg(any( target_os = "android", @@ -92,6 +101,7 @@ mod windows; ))] use unix as imp; #[cfg(not(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -103,6 +113,8 @@ use unix as imp; target_os = "solaris" )))] use unsupported as imp; +#[cfg(windows)] +use windows as imp; /// A pseudo-terminal spawn provider for Tokio commands. /// @@ -370,6 +382,7 @@ impl PtyController { } #[cfg(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -387,6 +400,7 @@ pub(super) struct ControllerSlot { } #[cfg(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs new file mode 100644 index 0000000..c16c9a4 --- /dev/null +++ b/src/tokio/pty/windows/backend.rs @@ -0,0 +1,77 @@ +//! End-to-end assembly of the Windows ConPTY spawn provider. + +use std::{io, sync::Arc}; + +use crate::{ + SpawnTransaction, + tokio::{ProviderProduct, SpawnAttempt}, +}; + +use super::{ + super::{ControllerSlot, PtyController, PtySize}, + api, + attributes::AttributeList, + console::{self, PseudoConsole, Release}, + controller, + pipe::PipePair, + prepare, + spawn::{self as process, SpawnCleanup}, +}; + +pub(in crate::tokio::pty) fn check_available() -> io::Result<()> { + api::get().map(|_| ()) +} + +pub(in crate::tokio::pty) fn spawn( + attempt: &mut SpawnAttempt, + size: PtySize, +) -> io::Result { + console::coordinate(size)?; + let prepared = prepare(attempt)?; + + let input = PipePair::input()?; + let output = PipePair::output()?; + let console = PseudoConsole::create(size, input.server_handle(), output.server_handle())?; + let release = console.releaser(); + let attributes = AttributeList::new(console.handle())?; + let (input_server, input_host) = input.into_parts(); + let (output_server, output_host) = output.into_parts(); + let (input, output, resize) = controller::controller(console, input_host, output_host)?; + let controller = Arc::new(ControllerSlot::new(PtyController::new( + input, output, resize, + ))); + + let process::SpawnedChild { mut child, cleanup } = + process::spawn(prepared, &attributes, input_server, output_server)?; + child.install_controller(Arc::clone(&controller)); + + Ok(ProviderProduct::new( + Box::new(child), + Box::new(ConPtyTransaction { + cleanup, + controller, + release, + }), + )) +} + +#[derive(Debug)] +struct ConPtyTransaction { + cleanup: SpawnCleanup, + controller: Arc, + release: Release, +} + +impl SpawnTransaction for ConPtyTransaction { + fn commit(&mut self) -> io::Result<()> { + self.release.release()?; + self.cleanup.disarm(); + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + self.controller.rollback(); + self.cleanup.rollback(); + Ok(()) + } +} diff --git a/src/tokio/pty/windows/child.rs b/src/tokio/pty/windows/child.rs index d526ef7..c7493d4 100644 --- a/src/tokio/pty/windows/child.rs +++ b/src/tokio/pty/windows/child.rs @@ -9,6 +9,7 @@ use std::{ }, pin::Pin, process::ExitStatus, + sync::Arc, }; use tokio::{ @@ -28,6 +29,8 @@ use windows::Win32::{ use crate::{ChildExitStatus, tokio::ChildWrapper}; +use super::super::{ControllerSlot, PtyController}; + #[derive(Debug)] pub(super) struct ConPtyChild { process: OwnedHandle, @@ -36,6 +39,7 @@ pub(super) struct ConPtyChild { pid: u32, kill_on_drop: bool, cleanup_armed: bool, + controller: Option>, exit_status: ChildExitStatus, wait_task: Option>>, stdin: Option, @@ -56,6 +60,7 @@ impl ConPtyChild { pid, kill_on_drop, cleanup_armed: true, + controller: None, exit_status: ChildExitStatus::Running, wait_task: None, stdin: None, @@ -90,6 +95,11 @@ impl ConPtyChild { } } + pub(super) fn install_controller(&mut self, controller: Arc) { + debug_assert!(self.controller.is_none()); + self.controller = Some(controller); + } + fn raw_process_handle(&self) -> HANDLE { HANDLE(self.process.as_raw_handle()) } @@ -117,11 +127,20 @@ impl ChildWrapper for ConPtyChild { Some(self.resume_primary_thread()) } - fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + fn disarm_job_object_layer(&mut self) -> io::Result<()> { self.cleanup_armed = false; + if let Some(controller) = &self.controller { + controller.commit(); + } Ok(()) } + fn take_pty_controller_layer(&mut self) -> Option { + self.controller + .as_ref() + .and_then(|controller| controller.take()) + } + fn stdin(&mut self) -> &mut Option { &mut self.stdin } @@ -273,6 +292,7 @@ mod tests { pid: child.id(), kill_on_drop, cleanup_armed: false, + controller: None, exit_status: ChildExitStatus::Running, wait_task: None, stdin: None, @@ -386,11 +406,11 @@ mod tests { } #[test] - fn disarming_spawn_cleanup_preserves_a_running_process() { + fn final_spawn_disarm_preserves_a_running_process() { let mut native = spawn_long_running(); let mut child = wrap(&native, false); child.cleanup_armed = true; - child.disarm_spawn_cleanup_layer().unwrap(); + child.disarm_job_object_layer().unwrap(); drop(child); assert!(native.try_wait().unwrap().is_none()); native.kill().unwrap(); diff --git a/src/tokio/pty/windows/command.rs b/src/tokio/pty/windows/command.rs index da33365..a620713 100644 --- a/src/tokio/pty/windows/command.rs +++ b/src/tokio/pty/windows/command.rs @@ -34,6 +34,7 @@ impl WideCString { self.0.as_mut_ptr() } + #[cfg(test)] pub(super) fn as_units(&self) -> &[u16] { &self.0 } diff --git a/src/tokio/pty/windows/environment.rs b/src/tokio/pty/windows/environment.rs index 0162c2a..63130c9 100644 --- a/src/tokio/pty/windows/environment.rs +++ b/src/tokio/pty/windows/environment.rs @@ -29,6 +29,7 @@ pub(super) enum PreparedEnvironment { } impl PreparedEnvironment { + #[cfg(test)] pub(super) fn as_ptr(&self) -> *const u16 { match self { Self::Inherit => std::ptr::null(), @@ -36,6 +37,7 @@ impl PreparedEnvironment { } } + #[cfg(test)] pub(super) fn is_inherited(&self) -> bool { matches!(self, Self::Inherit) } diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index bbe096a..4c6db42 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -14,6 +14,7 @@ use environment::{PreparedEnvironment, prepare_environment}; mod api; mod attributes; +mod backend; mod child; pub(super) mod command; mod console; @@ -23,6 +24,9 @@ mod pipe; mod program; mod spawn; +pub(super) use backend::{check_available, spawn}; +pub(super) use controller::{Input, Output, Resize}; + #[derive(Debug, Eq, PartialEq)] struct PreparedWindowsCommand { application_name: WideCString, From be2be8a4fe856a07b2663d13a76d516c39157aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 15:18:38 +1200 Subject: [PATCH 014/114] test(pty): adopt wrapper-native ConPTY API Restore the native Windows transport coverage through Command::wrap(Pty), one-shot controller extraction, portable wrappers, and reusable tracked state. Exclude supported Windows from the unsupported-backend suite. Co-Authored-By: Claude --- tests/tokio_pty_unsupported.rs | 3 +- tests/tokio_pty_windows.rs | 717 +++++++++++++++++++++++++++++++++ 2 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 tests/tokio_pty_windows.rs diff --git a/tests/tokio_pty_unsupported.rs b/tests/tokio_pty_unsupported.rs index b20847c..3a96260 100644 --- a/tests/tokio_pty_unsupported.rs +++ b/tests/tokio_pty_unsupported.rs @@ -9,7 +9,8 @@ target_os = "macos", target_os = "netbsd", target_os = "openbsd", - target_os = "solaris" + target_os = "solaris", + windows )) ))] diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs new file mode 100644 index 0000000..b0832d8 --- /dev/null +++ b/tests/tokio_pty_windows.rs @@ -0,0 +1,717 @@ +#![cfg(all(windows, feature = "pty"))] + +use std::{ + env, + io::{self, Read, Write}, + path::{Path, PathBuf}, + process::ExitStatus, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +#[cfg(feature = "creation-flags")] +use process_wrap::tokio::CreationFlags; +#[cfg(feature = "job-object")] +use process_wrap::tokio::JobObject; +#[cfg(feature = "kill-on-drop")] +use process_wrap::tokio::KillOnDrop; +use process_wrap::tokio::{ + ChildWrapper, Command, CommandWrapper, Pty, PtyController, PtyOutput, PtySize, SpawnAttempt, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::timeout, +}; +use windows::Win32::{ + Foundation::HANDLE, + System::Console::{ + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, + ENABLE_VIRTUAL_TERMINAL_INPUT, GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, + STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + }, +}; + +#[cfg(feature = "job-object")] +#[path = "support/windows_thread.rs"] +mod windows_thread; + +const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; +const TIMEOUT: Duration = Duration::from_secs(10); + +fn helper(mode: &str) -> io::Result { + let mut command = Command::new(env::current_exe()?); + command + .args(["--exact", "conpty_child_helper", "--nocapture"]) + .env(HELPER_MODE, mode); + Ok(command) +} + +fn take_controller(child: &mut dyn ChildWrapper) -> PtyController { + let controller = child + .take_pty_controller() + .expect("a successful PTY spawn installs one controller"); + assert!( + child.take_pty_controller().is_none(), + "the PTY controller can only be extracted once" + ); + controller +} + +fn spawn_with_terminal( + command: &mut Command, + size: PtySize, +) -> io::Result<(Box, PtyController)> { + command.wrap(Pty::new(size)); + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + Ok((child, controller)) +} + +fn spawn_descendant(release: &Path) -> io::Result { + std::process::Command::new(env::current_exe()?) + .args(["--exact", "conpty_child_helper", "--nocapture"]) + .env(HELPER_MODE, "descendant") + .env("PW_RELEASE", release) + .env_remove("PW_DESCENDANT_PID") + .spawn() +} + +struct ReleaseOnDrop(PathBuf); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = std::fs::File::create(&self.0); + } +} + +async fn read_through(output: &mut PtyOutput, needle: &[u8]) -> io::Result> { + timeout(TIMEOUT, async { + let mut bytes = Vec::new(); + let mut buffer = [0; 1024]; + while !bytes.windows(needle.len()).any(|window| window == needle) { + let read = output.read(&mut buffer).await?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "ConPTY output closed before the expected marker", + )); + } + bytes.extend_from_slice(&buffer[..read]); + } + Ok(bytes) + }) + .await + .map_err(io::Error::other)? +} + +async fn wait_and_drain( + child: &mut dyn ChildWrapper, + output: &mut PtyOutput, + bytes: &mut Vec, +) -> io::Result { + let (status, _) = timeout(TIMEOUT, async { + tokio::try_join!(child.wait(), output.read_to_end(bytes)) + }) + .await + .map_err(io::Error::other)??; + Ok(status) +} + +fn terminal_handles() -> io::Result<(HANDLE, HANDLE, HANDLE)> { + // SAFETY: these calls only retrieve this process's standard handles. + unsafe { + Ok(( + GetStdHandle(STD_INPUT_HANDLE).map_err(io::Error::other)?, + GetStdHandle(STD_OUTPUT_HANDLE).map_err(io::Error::other)?, + GetStdHandle(STD_ERROR_HANDLE).map_err(io::Error::other)?, + )) + } +} + +fn is_console(handle: HANDLE) -> bool { + let mut mode = Default::default(); + // SAFETY: handle is borrowed from a live standard handle and mode is writable storage. + unsafe { GetConsoleMode(handle, &mut mode) }.is_ok() +} + +#[test] +fn conpty_child_helper() -> io::Result<()> { + let Ok(mode) = env::var(HELPER_MODE) else { + return Ok(()); + }; + let (stdin_handle, stdout_handle, stderr_handle) = terminal_handles()?; + + match mode.as_str() { + "terminal" => { + print!( + "PW-TERMINALS:{}{}{}:{}:REMOVED={}:CWD={}", + is_console(stdin_handle) as u8, + is_console(stdout_handle) as u8, + is_console(stderr_handle) as u8, + env::var("PW_VALUE").unwrap_or_default(), + env::var_os("PW_REMOVED").is_none() as u8, + env::current_dir()?.display(), + ); + eprint!("PW-STDERR"); + io::stdout().flush()?; + io::stderr().flush()?; + } + "io" => { + let mut console_mode = Default::default(); + // SAFETY: stdin_handle is the live console input handle and console_mode is writable. + unsafe { GetConsoleMode(stdin_handle, &mut console_mode) }.map_err(io::Error::other)?; + console_mode &= !(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT); + console_mode |= ENABLE_VIRTUAL_TERMINAL_INPUT; + // SAFETY: stdin_handle remains live and console_mode is a valid combination of input flags. + unsafe { SetConsoleMode(stdin_handle, console_mode) }.map_err(io::Error::other)?; + print!("PW-READY"); + io::stdout().flush()?; + let mut bytes = [0; 4]; + io::stdin().read_exact(&mut bytes)?; + let mut stdout = io::stdout(); + stdout.write_all(b"PW-BYTES:")?; + for byte in bytes { + write!(stdout, "{byte:02x}")?; + } + stdout.flush()?; + } + "size" => { + print_size(stdout_handle)?; + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + print_size(stdout_handle)?; + io::stdout().flush()?; + } + "descendant-parent" => { + let release = env::var_os("PW_RELEASE").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") + })?; + drop(spawn_descendant(Path::new(&release))?); + } + "descendant" => { + let release = env::var_os("PW_RELEASE").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") + })?; + print!("PW-DESCENDANT-READY"); + io::stdout().flush()?; + while !Path::new(&release).exists() { + std::thread::sleep(Duration::from_millis(10)); + } + } + "tree" => { + let release = env::var_os("PW_RELEASE").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") + })?; + let pid_file = env::var_os("PW_DESCENDANT_PID").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_DESCENDANT_PID is unset") + })?; + let descendant = spawn_descendant(Path::new(&release))?; + std::fs::write(pid_file, descendant.id().to_string())?; + drop(descendant); + print!("PW-TREE-READY"); + io::stdout().flush()?; + let mut byte = [0]; + loop { + io::stdin().read_exact(&mut byte)?; + } + } + "wait" => { + print!("PW-READY"); + io::stdout().flush()?; + let mut byte = [0]; + loop { + io::stdin().read_exact(&mut byte)?; + } + } + other => panic!("unknown ConPTY helper mode {other}"), + } + Ok(()) +} + +fn print_size(output: HANDLE) -> io::Result<()> { + let mut info = Default::default(); + // SAFETY: output is a live console output handle and info is writable output storage. + unsafe { GetConsoleScreenBufferInfo(output, &mut info) }.map_err(io::Error::other)?; + print!("PW-SIZE:{}x{}", info.dwSize.Y, info.dwSize.X); + Ok(()) +} + +#[tokio::test] +async fn spawns_with_three_console_standard_handles() -> io::Result<()> { + let mut command = helper("terminal")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + + let mut bytes = read_through(&mut output, b"PW-TERMINALS:111").await?; + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + Ok(()) +} + +#[tokio::test] +async fn exposes_terminal_handles_and_preserves_environment_cwd_and_merged_output() -> io::Result<()> +{ + let directory = tempfile::tempdir()?; + let system_root = env::var_os("SystemRoot").expect("Windows must define SystemRoot"); + let mut command = helper("terminal")?; + command + .env("PW_DISCARDED", "before-clear") + .env_clear() + .env("SystemRoot", system_root) + .env(HELPER_MODE, "terminal") + .env("PW_VALUE", "child-value") + .env("PW_REMOVED", "discarded") + .env_remove("PW_REMOVED") + .current_dir(directory.path()); + + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + let marker = format!( + "PW-TERMINALS:111:child-value:REMOVED=1:CWD={}", + directory.path().display() + ); + assert!( + bytes + .windows(marker.len()) + .any(|window| window == marker.as_bytes()), + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!( + bytes + .windows(b"PW-STDERR".len()) + .any(|window| window == b"PW-STDERR"), + "{}", + String::from_utf8_lossy(&bytes) + ); + Ok(()) +} + +#[tokio::test] +async fn passes_bidirectional_terminal_bytes() -> io::Result<()> { + let mut command = helper("io")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (mut input, mut output, _resize) = controller.into_parts(); + let mut bytes = read_through(&mut output, b"PW-READY").await?; + + let controls = [b'A', 0x1b, b'B', b'C']; + input.write_all(&controls).await?; + input.shutdown().await?; + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + let expected = b"PW-BYTES:411b4243"; + assert!( + bytes + .windows(expected.len()) + .any(|window| window == expected), + "{:?}", + bytes + ); + Ok(()) +} + +#[tokio::test] +async fn reports_initial_size_and_resize() -> io::Result<()> { + let mut command = helper("size")?; + let initial = PtySize::new(31, 97)?; + let (mut child, controller) = spawn_with_terminal(&mut command, initial)?; + let (mut input, mut output, resize) = controller.into_parts(); + read_through(&mut output, b"PW-SIZE:31x97").await?; + resize.resize(PtySize::new(42, 113)?)?; + input.write_all(b"go\r").await?; + read_through(&mut output, b"PW-SIZE:42x113").await?; + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + Ok(()) +} + +#[tokio::test] +async fn canceled_wait_kill_and_repeated_waits_remain_valid() -> io::Result<()> { + let mut command = helper("wait")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + assert!( + timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + child.start_kill()?; + let status = timeout(TIMEOUT, child.wait()).await??; + assert_eq!(child.try_wait()?, Some(status)); + assert_eq!(child.wait().await?, status); + drop(input); + let mut trailing = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut trailing)).await??; + Ok(()) +} + +#[tokio::test] +async fn shutting_down_input_releases_its_owner_without_early_hangup() -> io::Result<()> { + let mut command = helper("wait")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (mut input, mut output, resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + + input.shutdown().await?; + assert_eq!( + input.write_all(b"closed").await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + assert!(child.try_wait()?.is_none()); + resize.resize(PtySize::default())?; + drop(output); + assert_eq!( + resize.resize(PtySize::default()).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + timeout(TIMEOUT, child.wait()).await??; + Ok(()) +} + +#[tokio::test] +async fn dropping_output_keeps_the_terminal_live_while_input_exists() -> io::Result<()> { + let mut command = helper("wait")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + drop(output); + assert!(child.try_wait()?.is_none()); + resize.resize(PtySize::default())?; + drop(input); + assert_eq!( + resize.resize(PtySize::default()).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + timeout(TIMEOUT, child.wait()).await??; + Ok(()) +} + +#[tokio::test] +async fn direct_child_wait_is_independent_from_descendant_output_eof() -> io::Result<()> { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("descendant-parent")?; + command.env("PW_RELEASE", &release); + + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = read_through(&mut output, b"PW-DESCENDANT-READY").await?; + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + assert!( + timeout(Duration::from_millis(100), output.read_to_end(&mut bytes)) + .await + .is_err(), + "ConPTY output reached EOF while a descendant remained attached" + ); + std::fs::File::create(&release)?; + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + Ok(()) +} + +#[tokio::test] +async fn failed_spawn_leaves_the_tracked_command_reusable() -> io::Result<()> { + let directory = tempfile::tempdir()?; + let executable = directory.path().join("retry-helper.exe"); + let mut command = Command::new(&executable); + command + .args(["--exact", "conpty_child_helper", "--nocapture"]) + .env(HELPER_MODE, "terminal") + .wrap(Pty::default()); + let error = command + .spawn() + .err() + .expect("the first spawn must fail while the executable is absent"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + + std::fs::copy(env::current_exe()?, &executable)?; + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[tokio::test] +async fn accepts_ordered_raw_argument_fragments() -> io::Result<()> { + let mut command = Command::new(env::current_exe()?); + command + .arg("--exact") + .raw_arg("\"conpty_child_helper\"") + .arg("--nocapture") + .env(HELPER_MODE, "terminal"); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(feature = "creation-flags")] +#[tokio::test] +async fn creation_flags_compose_without_a_job_object() -> io::Result<()> { + use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; + + let mut command = helper("terminal")?; + command.wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(feature = "job-object")] +#[tokio::test] +async fn job_object_resumes_its_temporarily_suspended_primary_thread() -> io::Result<()> { + let mut command = helper("terminal")?; + command.wrap(JobObject); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[tokio::test] +async fn job_object_composes_with_creation_flags_in_both_orders() -> io::Result<()> { + use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; + + for reverse in [false, true] { + let mut command = helper("terminal")?; + if reverse { + command + .wrap(JobObject) + .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)); + } else { + command + .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)) + .wrap(JobObject); + } + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + } + Ok(()) +} + +#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[tokio::test] +async fn job_object_preserves_explicit_suspension() -> io::Result<()> { + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + let mut command = helper("terminal")?; + command + .wrap(CreationFlags(CREATE_SUSPENDED)) + .wrap(JobObject); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + let pid = child.id().expect("ConPTY children expose a process ID"); + assert!( + windows_thread::process_has_suspended_thread(pid)?, + "an explicitly suspended ConPTY child had no suspended thread" + ); + windows_thread::resume_process_threads(pid)?; + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(feature = "kill-on-drop")] +async fn assert_killed_on_drop(mut command: Command) -> io::Result<()> { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows::Win32::{ + Foundation::WAIT_OBJECT_0, + System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }; + + let (child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let pid = child.id().expect("ConPTY children expose a process ID"); + let (_input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + // SAFETY: pid identifies the live child. Ownership of the non-inheritable handle is transferred. + let process = + unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }.map_err(io::Error::other)?; + // SAFETY: OpenProcess returned a unique owned handle. + let process = unsafe { OwnedHandle::from_raw_handle(process.0) }; + drop(child); + let wait = tokio::task::spawn_blocking(move || { + // SAFETY: process owns the live synchronization handle for this wait. + unsafe { WaitForSingleObject(HANDLE(process.as_raw_handle()), 10_000) } + }); + assert_eq!(wait.await.map_err(io::Error::other)?, WAIT_OBJECT_0); + Ok(()) +} + +#[cfg(feature = "kill-on-drop")] +#[tokio::test] +async fn kill_on_drop_terminates_a_conpty_child() -> io::Result<()> { + let mut command = helper("wait")?; + command.wrap(KillOnDrop); + assert_killed_on_drop(command).await +} + +#[cfg(all(feature = "job-object", feature = "kill-on-drop"))] +#[tokio::test] +async fn job_object_composes_with_kill_on_drop_in_both_orders() -> io::Result<()> { + for reverse in [false, true] { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let pid_file = directory.path().join("descendant-pid"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("tree")?; + command + .env("PW_RELEASE", &release) + .env("PW_DESCENDANT_PID", &pid_file); + if reverse { + command.wrap(JobObject).wrap(KillOnDrop); + } else { + command.wrap(KillOnDrop).wrap(JobObject); + } + + let (child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-TREE-READY").await?; + let pid = std::fs::read_to_string(&pid_file)? + .parse::() + .map_err(io::Error::other)?; + let guard = windows_thread::ProcessGuard::open(pid)?; + drop(child); + timeout(TIMEOUT, async { + while !guard.has_exited()? { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Ok::<(), io::Error>(()) + }) + .await + .map_err(io::Error::other)??; + guard.disarm()?; + drop(input); + let mut bytes = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + } + Ok(()) +} + +#[derive(Debug)] +struct PortableWrapper(Arc); + +impl CommandWrapper for PortableWrapper { + fn pre_spawn(&mut self, _attempt: &mut SpawnAttempt, _command: &Command) -> io::Result<()> { + self.0.store(true, Ordering::SeqCst); + Ok(()) + } +} + +#[tokio::test] +async fn portable_custom_wrappers_compose_with_conpty() -> io::Result<()> { + let called = Arc::new(AtomicBool::new(false)); + let mut command = helper("terminal")?; + command + .wrap(Pty::default()) + .wrap(PortableWrapper(Arc::clone(&called))); + + let mut child = command.spawn()?; + assert!(called.load(Ordering::SeqCst)); + assert!(child.stdin().is_none()); + assert!(child.stdout().is_none()); + assert!(child.stderr().is_none()); + assert!(child.try_inner_child().is_none()); + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} From 40cfb1f216ffeb54a3c99773611583dba5239b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 15:44:00 +1200 Subject: [PATCH 015/114] test(pty): cover ConPTY lifecycle failures Exercise controller commit gating, real child and descendant cleanup across hook and Windows finalization errors and panics, provider conflicts, command reuse, duplicate registration, wrapper order, and terminal capabilities. Co-Authored-By: Claude --- tests/tokio_pty_windows.rs | 719 +++++++++++++++++++++++++++++++++---- 1 file changed, 659 insertions(+), 60 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index b0832d8..af3b8ee 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -3,13 +3,14 @@ use std::{ env, io::{self, Read, Write}, + panic::{AssertUnwindSafe, catch_unwind}, path::{Path, PathBuf}, process::ExitStatus, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }, - time::Duration, + time::{Duration, Instant}, }; #[cfg(feature = "creation-flags")] @@ -19,22 +20,30 @@ use process_wrap::tokio::JobObject; #[cfg(feature = "kill-on-drop")] use process_wrap::tokio::KillOnDrop; use process_wrap::tokio::{ - ChildWrapper, Command, CommandWrapper, Pty, PtyController, PtyOutput, PtySize, SpawnAttempt, + ChildWrapper, Command, CommandWrapper, ProviderProduct, Pty, PtyController, PtyOutput, PtySize, + SpawnAttempt, SpawnProvider, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, time::timeout, }; use windows::Win32::{ - Foundation::HANDLE, - System::Console::{ - ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, - ENABLE_VIRTUAL_TERMINAL_INPUT, GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, - STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}, + System::{ + Console::{ + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, + ENABLE_VIRTUAL_TERMINAL_INPUT, GetConsoleMode, GetConsoleScreenBufferInfo, + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + }, + Threading::{ + INFINITE, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, + WaitForSingleObject, + }, }, }; -#[cfg(feature = "job-object")] +#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[allow(dead_code)] #[path = "support/windows_thread.rs"] mod windows_thread; @@ -87,6 +96,76 @@ impl Drop for ReleaseOnDrop { } } +#[derive(Debug)] +struct ProcessExitGuard(Option); + +// SAFETY: the guard uniquely owns a process-wide Windows handle. Moving it to another thread +// transfers that ownership without exposing Rust memory or retaining an alias which could close it. +unsafe impl Send for ProcessExitGuard {} + +impl ProcessExitGuard { + fn open(pid: u32) -> io::Result { + // SAFETY: the access mask is valid for process handles, the numeric PID carries no borrowed + // memory, and a successful call returns a new handle owned by this guard. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } + .map_err(io::Error::other)?; + Ok(Self(Some(handle))) + } + + fn has_exited(&self) -> io::Result { + // SAFETY: the guard still owns this live process handle for the duration of the nonblocking wait. + let wait = unsafe { WaitForSingleObject(self.handle(), 0) }; + if wait == WAIT_FAILED { + Err(io::Error::last_os_error()) + } else { + Ok(wait == WAIT_OBJECT_0) + } + } + + fn disarm(mut self) -> io::Result<()> { + if let Some(handle) = self.0.take() { + // SAFETY: taking the handle removes the guard's only owner, so this closes it exactly once. + unsafe { CloseHandle(handle) }?; + } + Ok(()) + } + + fn handle(&self) -> HANDLE { + self.0 + .expect("only ProcessExitGuard::disarm clears the handle, and it consumes the guard") + } +} + +impl Drop for ProcessExitGuard { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + // SAFETY: taking the handle removes the guard's only owner. It remains live until the final + // CloseHandle below, and all three operations use it only as a process handle. + unsafe { TerminateProcess(handle, 1) }.ok(); + // SAFETY: the uniquely owned process handle remains live until the following close. + unsafe { WaitForSingleObject(handle, INFINITE) }; + // SAFETY: this is the sole remaining owner and no subsequent operation uses the handle. + unsafe { CloseHandle(handle) }.ok(); + } + } +} + +async fn wait_for_process_exit(guard: ProcessExitGuard) -> io::Result<()> { + let deadline = Instant::now() + TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "ConPTY process survived the failed spawn lifecycle", + )); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + async fn read_through(output: &mut PtyOutput, needle: &[u8]) -> io::Result> { timeout(TIMEOUT, async { let mut bytes = Vec::new(); @@ -539,30 +618,39 @@ async fn job_object_composes_with_creation_flags_in_both_orders() -> io::Result< use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; for reverse in [false, true] { - let mut command = helper("terminal")?; - if reverse { - command - .wrap(JobObject) - .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)); - } else { - command - .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)) - .wrap(JobObject); + for pty_first in [false, true] { + let mut command = helper("terminal")?; + if pty_first { + command.wrap(Pty::default()); + } + if reverse { + command + .wrap(JobObject) + .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)); + } else { + command + .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)) + .wrap(JobObject); + } + if !pty_first { + command.wrap(Pty::default()); + } + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); } - let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; - let (input, mut output, _resize) = controller.into_parts(); - drop(input); - let mut bytes = Vec::new(); - assert!( - wait_and_drain(child.as_mut(), &mut output, &mut bytes) - .await? - .success() - ); - assert!( - bytes - .windows(16) - .any(|window| window == b"PW-TERMINALS:111") - ); } Ok(()) } @@ -637,41 +725,552 @@ async fn kill_on_drop_terminates_a_conpty_child() -> io::Result<()> { #[tokio::test] async fn job_object_composes_with_kill_on_drop_in_both_orders() -> io::Result<()> { for reverse in [false, true] { - let directory = tempfile::tempdir()?; - let release = directory.path().join("release-descendant"); - let pid_file = directory.path().join("descendant-pid"); - let _release_on_drop = ReleaseOnDrop(release.clone()); - let mut command = helper("tree")?; - command - .env("PW_RELEASE", &release) - .env("PW_DESCENDANT_PID", &pid_file); - if reverse { - command.wrap(JobObject).wrap(KillOnDrop); + for pty_first in [false, true] { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let pid_file = directory.path().join("descendant-pid"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("tree")?; + command + .env("PW_RELEASE", &release) + .env("PW_DESCENDANT_PID", &pid_file); + if pty_first { + command.wrap(Pty::default()); + } + if reverse { + command.wrap(JobObject).wrap(KillOnDrop); + } else { + command.wrap(KillOnDrop).wrap(JobObject); + } + if !pty_first { + command.wrap(Pty::default()); + } + + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-TREE-READY").await?; + let pid = std::fs::read_to_string(&pid_file)? + .parse::() + .map_err(io::Error::other)?; + let guard = ProcessExitGuard::open(pid)?; + drop(child); + wait_for_process_exit(guard).await?; + drop(input); + let mut bytes = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug)] +enum LifecycleFailure { + Error, + Panic, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LifecycleStage { + PostSpawn, + WrapChild, + FinalizeSpawn, + DisarmSpawnCleanup, + DisarmJobObject, +} + +fn fail_lifecycle(failure: LifecycleFailure) -> io::Result { + match failure { + LifecycleFailure::Error => Err(io::Error::other("ConPTY lifecycle failed")), + LifecycleFailure::Panic => panic!("ConPTY lifecycle failed"), + } +} + +#[derive(Debug)] +struct FailFinalizationChild { + inner: Box, + failure: LifecycleFailure, + stage: LifecycleStage, +} + +impl ChildWrapper for FailFinalizationChild { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner.as_mut() + } + + fn into_inner(self: Box) -> Box { + self.inner + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::FinalizeSpawn { + fail_lifecycle(self.failure) } else { - command.wrap(KillOnDrop).wrap(JobObject); + Ok(()) } + } - let (child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; - let (input, mut output, _resize) = controller.into_parts(); - read_through(&mut output, b"PW-TREE-READY").await?; - let pid = std::fs::read_to_string(&pid_file)? - .parse::() - .map_err(io::Error::other)?; - let guard = windows_thread::ProcessGuard::open(pid)?; - drop(child); - timeout(TIMEOUT, async { - while !guard.has_exited()? { - tokio::time::sleep(Duration::from_millis(10)).await; + fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::DisarmSpawnCleanup { + fail_lifecycle(self.failure) + } else { + Ok(()) + } + } + + fn disarm_job_object_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::DisarmJobObject { + fail_lifecycle(self.failure) + } else { + Ok(()) + } + } +} + +#[derive(Debug)] +struct FailLifecycleOnce { + failure: LifecycleFailure, + stage: LifecycleStage, + failed: bool, + process: Arc>>, +} + +impl FailLifecycleOnce { + fn capture(&self, child: &mut dyn ChildWrapper) -> io::Result<()> { + assert!(child.take_pty_controller().is_none()); + let pid = child + .id() + .expect("a newly spawned ConPTY child exposes its process ID"); + *self.process.lock().unwrap() = Some(ProcessExitGuard::open(pid)?); + Ok(()) + } +} + +impl CommandWrapper for FailLifecycleOnce { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result<()> { + if self.stage != LifecycleStage::PostSpawn || self.failed { + return Ok(()); + } + self.capture(child)?; + self.failed = true; + fail_lifecycle(self.failure) + } + + fn wrap_child( + &mut self, + mut child: Box, + _command: &Command, + ) -> io::Result> { + if self.failed || self.stage == LifecycleStage::PostSpawn { + return Ok(child); + } + self.capture(child.as_mut())?; + self.failed = true; + if self.stage == LifecycleStage::WrapChild { + fail_lifecycle(self.failure) + } else { + Ok(Box::new(FailFinalizationChild { + inner: child, + failure: self.failure, + stage: self.stage, + })) + } + } +} + +#[cfg(feature = "job-object")] +#[derive(Debug)] +struct FailAfterDescendantOnce { + failure: LifecycleFailure, + stage: LifecycleStage, + failed: bool, + pid_file: PathBuf, + descendant: Arc>>, +} + +#[cfg(feature = "job-object")] +impl FailAfterDescendantOnce { + fn capture_descendant(&self) -> io::Result<()> { + let deadline = Instant::now() + TIMEOUT; + let pid = loop { + if let Ok(pid) = std::fs::read_to_string(&self.pid_file) + .and_then(|pid| pid.trim().parse().map_err(io::Error::other)) + { + break pid; + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "ConPTY descendant did not report its process ID", + )); } - Ok::<(), io::Error>(()) - }) - .await - .map_err(io::Error::other)??; - guard.disarm()?; + std::thread::sleep(Duration::from_millis(10)); + }; + *self.descendant.lock().unwrap() = Some(ProcessExitGuard::open(pid)?); + Ok(()) + } +} + +#[cfg(feature = "job-object")] +impl CommandWrapper for FailAfterDescendantOnce { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result<()> { + if self.stage != LifecycleStage::PostSpawn || self.failed { + return Ok(()); + } + assert!(child.take_pty_controller().is_none()); + self.capture_descendant()?; + self.failed = true; + fail_lifecycle(self.failure) + } + + fn wrap_child( + &mut self, + mut child: Box, + _command: &Command, + ) -> io::Result> { + if self.failed || self.stage == LifecycleStage::PostSpawn { + return Ok(child); + } + assert!(child.take_pty_controller().is_none()); + self.capture_descendant()?; + self.failed = true; + if self.stage == LifecycleStage::WrapChild { + fail_lifecycle(self.failure) + } else { + Ok(Box::new(FailFinalizationChild { + inner: child, + failure: self.failure, + stage: self.stage, + })) + } + } +} + +#[cfg(feature = "job-object")] +#[tokio::test] +async fn armed_job_reaps_descendants_after_later_conpty_failures() -> io::Result<()> { + for stage in [ + LifecycleStage::PostSpawn, + LifecycleStage::WrapChild, + LifecycleStage::FinalizeSpawn, + LifecycleStage::DisarmSpawnCleanup, + LifecycleStage::DisarmJobObject, + ] { + for failure in [LifecycleFailure::Error, LifecycleFailure::Panic] { + for job_outer in [false, true] { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let pid_file = directory.path().join("descendant-pid"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let descendant = Arc::new(Mutex::new(None)); + let fail = FailAfterDescendantOnce { + failure, + stage, + failed: false, + pid_file: pid_file.clone(), + descendant: Arc::clone(&descendant), + }; + let mut command = helper("tree")?; + command + .env("PW_RELEASE", &release) + .env("PW_DESCENDANT_PID", &pid_file) + .wrap(Pty::default()); + if job_outer { + command.wrap(fail).wrap(JobObject); + } else { + command.wrap(JobObject).wrap(fail); + } + + let result = catch_unwind(AssertUnwindSafe(|| command.spawn())); + match failure { + LifecycleFailure::Error => assert_eq!( + result.unwrap().unwrap_err().to_string(), + "ConPTY lifecycle failed" + ), + LifecycleFailure::Panic => assert_eq!( + *result + .expect_err("the Windows lifecycle hook must panic") + .downcast::<&'static str>() + .unwrap(), + "ConPTY lifecycle failed" + ), + } + let guard = descendant + .lock() + .unwrap() + .take() + .expect("the failing lifecycle hook captures the descendant process"); + wait_for_process_exit(guard).await?; + } + } + } + Ok(()) +} + +#[tokio::test] +async fn failed_windows_lifecycle_reaps_the_child_and_remains_reusable() -> io::Result<()> { + for stage in [ + LifecycleStage::PostSpawn, + LifecycleStage::WrapChild, + LifecycleStage::FinalizeSpawn, + LifecycleStage::DisarmSpawnCleanup, + LifecycleStage::DisarmJobObject, + ] { + for failure in [LifecycleFailure::Error, LifecycleFailure::Panic] { + let process = Arc::new(Mutex::new(None)); + let mut command = helper("wait")?; + command.wrap(Pty::default()).wrap(FailLifecycleOnce { + failure, + stage, + failed: false, + process: Arc::clone(&process), + }); + + let result = catch_unwind(AssertUnwindSafe(|| command.spawn())); + match failure { + LifecycleFailure::Error => assert_eq!( + result.unwrap().unwrap_err().to_string(), + "ConPTY lifecycle failed" + ), + LifecycleFailure::Panic => assert_eq!( + *result + .expect_err("the Windows lifecycle hook must panic") + .downcast::<&'static str>() + .unwrap(), + "ConPTY lifecycle failed" + ), + } + let guard = process + .lock() + .unwrap() + .take() + .expect("the failing lifecycle hook captures the child process"); + wait_for_process_exit(guard).await?; + + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + child.start_kill()?; + timeout(TIMEOUT, child.wait()).await??; + drop(input); + timeout(TIMEOUT, output.read_to_end(&mut Vec::new())).await??; + } + } + Ok(()) +} + +#[derive(Debug)] +struct OtherProvider; + +impl SpawnProvider for OtherProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> io::Result { + panic!("provider conflicts are rejected before spawning") + } +} + +impl CommandWrapper for OtherProvider { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(self) + } +} + +#[test] +fn rejects_another_spawn_provider_before_callbacks() { + let mut command = Command::new("ignored"); + command.wrap(Pty::default()).wrap(OtherProvider); + let error = command.spawn().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), "multiple spawn providers are registered"); +} + +#[derive(Debug)] +struct MakeAttemptNative; + +impl CommandWrapper for MakeAttemptNative { + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _command: &Command) -> io::Result<()> { + let _ = attempt.native_mut(); + Ok(()) + } +} + +#[test] +fn rejects_opaque_base_and_attempt_state() { + let native = tokio::process::Command::new("ignored"); + let mut command = Command::from(native); + command.wrap(Pty::default()); + let error = command.spawn().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only command" + ); + + let mut command = Command::new("ignored"); + command.wrap(Pty::default()).wrap(MakeAttemptNative); + let error = command.spawn().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only spawn attempt" + ); +} + +#[test] +fn explicit_spawners_cannot_bypass_pty() { + let mut command = Command::new("ignored"); + command.wrap(Pty::default()); + let error = command + .spawn_with(|_| panic!("explicit spawner must not run")) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + + let error = command + .spawn_with_child(|_| panic!("explicit child spawner must not run")) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); +} + +#[tokio::test] +async fn duplicate_pty_registration_uses_the_later_size() -> io::Result<()> { + let mut command = helper("size")?; + command + .wrap(Pty::new(PtySize::new(31, 97)?)) + .wrap(Pty::new(PtySize::new(42, 113)?)); + + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (mut input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-SIZE:42x113").await?; + input.write_all(b"go\r").await?; + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + Ok(()) +} + +#[tokio::test] +async fn provider_and_controller_are_reusable() -> io::Result<()> { + let mut command = helper("terminal")?; + command.wrap(Pty::default()); + + for _ in 0..3 { + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); drop(input); let mut bytes = Vec::new(); - timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + } + Ok(()) +} + +#[tokio::test] +async fn non_pty_children_have_no_controller() -> io::Result<()> { + let mut command = Command::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }); + let mut child = command.spawn()?; + assert!(child.take_pty_controller().is_none()); + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + Ok(()) +} + +#[derive(Debug)] +struct TransparentChild(Box); + +impl ChildWrapper for TransparentChild { + fn inner(&self) -> &dyn ChildWrapper { + self.0.as_ref() } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.0.as_mut() + } + + fn into_inner(self: Box) -> Box { + self.0 + } +} + +#[derive(Debug)] +struct ObserveControllerLifecycle { + post_spawn: Arc, + wrap_child: Arc, +} + +impl CommandWrapper for ObserveControllerLifecycle { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result<()> { + assert!(child.take_pty_controller().is_none()); + self.post_spawn.store(true, Ordering::SeqCst); + Ok(()) + } + + fn wrap_child( + &mut self, + mut child: Box, + _command: &Command, + ) -> io::Result> { + assert!(child.take_pty_controller().is_none()); + self.wrap_child.store(true, Ordering::SeqCst); + Ok(Box::new(TransparentChild(child))) + } +} + +#[tokio::test] +async fn controller_commits_after_hooks_and_traverses_outer_wrappers() -> io::Result<()> { + let post_spawn = Arc::new(AtomicBool::new(false)); + let wrap_child = Arc::new(AtomicBool::new(false)); + let mut command = helper("terminal")?; + command + .wrap(Pty::default()) + .wrap(ObserveControllerLifecycle { + post_spawn: Arc::clone(&post_spawn), + wrap_child: Arc::clone(&wrap_child), + }); + + let mut child = command.spawn()?; + assert!(post_spawn.load(Ordering::SeqCst)); + assert!(wrap_child.load(Ordering::SeqCst)); + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); Ok(()) } From 6ac03ba24c5e1484ac49524435431b74e8f1a0a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 15:45:59 +1200 Subject: [PATCH 016/114] fix(pty): let ConPTY install standard handles Leave STARTUPINFO standard-handle flags clear when attaching the pseudoconsole attribute. STARTF_USESTDHANDLES requires valid inheritable handles and is not part of the documented ConPTY startup sequence. Co-Authored-By: Claude --- src/tokio/pty/windows/spawn.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index f5717ee..cccfb35 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -13,8 +13,8 @@ use windows::{ System::Threading::{ CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, GetProcessIdOfThread, INFINITE, OpenProcess, PROCESS_CREATION_FLAGS, - PROCESS_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, STARTF_USESTDHANDLES, - STARTUPINFOEXW, TerminateProcess, WaitForSingleObject, + PROCESS_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, STARTUPINFOEXW, + TerminateProcess, WaitForSingleObject, }, }, core::{PCWSTR, PWSTR}, @@ -50,9 +50,8 @@ pub(super) fn spawn( // SAFETY: every pointer references live, NUL-terminated or explicitly bounded storage for the // duration of the call. The command line is uniquely mutable, the startup attribute list owns its - // aligned backing storage, no handles are inherited, and STARTF_USESTDHANDLES with three null slots - // asks ConPTY to install its console handles. The process-information value is writable output - // storage. + // aligned backing storage, no native handles are inherited, and ConPTY installs the child console + // through the pseudoconsole attribute. The process-information value is writable output storage. let created = unsafe { CreateProcessW( PCWSTR(command.application_name.as_ptr()), @@ -82,10 +81,6 @@ pub(super) fn spawn( fn startup_info(attributes: &AttributeList) -> STARTUPINFOEXW { let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = size_of::() as u32; - startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; - startup.StartupInfo.hStdInput = HANDLE::default(); - startup.StartupInfo.hStdOutput = HANDLE::default(); - startup.StartupInfo.hStdError = HANDLE::default(); startup.lpAttributeList = attributes.as_ptr(); startup } @@ -249,10 +244,10 @@ mod tests { use super::*; #[test] - fn requests_conpty_replacement_for_all_standard_handles() { + fn leaves_standard_handle_replacement_to_conpty() { let attributes = AttributeList::new(HPCON(42)).unwrap(); let startup = startup_info(&attributes); - assert_eq!(startup.StartupInfo.dwFlags, STARTF_USESTDHANDLES); + assert_eq!(startup.StartupInfo.dwFlags, Default::default()); assert_eq!(startup.StartupInfo.hStdInput, HANDLE::default()); assert_eq!(startup.StartupInfo.hStdOutput, HANDLE::default()); assert_eq!(startup.StartupInfo.hStdError, HANDLE::default()); From 92c085e251743a1ffdd1a9e7fef268cfff596b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 26 Aug 2026 15:53:46 +1200 Subject: [PATCH 017/114] fix(pty): release canceled ConPTY waits Co-Authored-By: Claude --- src/tokio/pty/windows/child.rs | 55 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/src/tokio/pty/windows/child.rs b/src/tokio/pty/windows/child.rs index c7493d4..9cf9200 100644 --- a/src/tokio/pty/windows/child.rs +++ b/src/tokio/pty/windows/child.rs @@ -9,7 +9,10 @@ use std::{ }, pin::Pin, process::ExitStatus, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use tokio::{ @@ -23,7 +26,7 @@ use windows::Win32::{ DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, }, System::Threading::{ - GetCurrentProcess, GetExitCodeProcess, INFINITE, TerminateProcess, WaitForSingleObject, + GetCurrentProcess, GetExitCodeProcess, TerminateProcess, WaitForSingleObject, }, }; @@ -34,7 +37,6 @@ use super::super::{ControllerSlot, PtyController}; #[derive(Debug)] pub(super) struct ConPtyChild { process: OwnedHandle, - #[cfg_attr(not(feature = "job-object"), allow(dead_code))] primary_thread: Option, pid: u32, kill_on_drop: bool, @@ -42,6 +44,7 @@ pub(super) struct ConPtyChild { controller: Option>, exit_status: ChildExitStatus, wait_task: Option>>, + wait_cancel: Arc, stdin: Option, stdout: Option, stderr: Option, @@ -63,6 +66,7 @@ impl ConPtyChild { controller: None, exit_status: ChildExitStatus::Running, wait_task: None, + wait_cancel: Arc::new(AtomicBool::new(false)), stdin: None, stdout: None, stderr: None, @@ -128,6 +132,7 @@ impl ChildWrapper for ConPtyChild { } fn disarm_job_object_layer(&mut self) -> io::Result<()> { + self.primary_thread.take(); self.cleanup_armed = false; if let Some(controller) = &self.controller { controller.commit(); @@ -185,10 +190,19 @@ impl ChildWrapper for ConPtyChild { if self.wait_task.is_none() { let process = duplicate_handle(&self.process)?; + let cancel = Arc::clone(&self.wait_cancel); self.wait_task = Some(spawn_blocking(move || { - process_status(HANDLE(process.as_raw_handle()), INFINITE)?.ok_or_else(|| { - io::Error::other("infinite process wait returned without an exit status") - }) + loop { + if cancel.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "ConPTY process wait was canceled because the child was dropped", + )); + } + if let Some(status) = process_status(HANDLE(process.as_raw_handle()), 50)? { + return Ok(status); + } + } })); } let result = self @@ -206,6 +220,7 @@ impl ChildWrapper for ConPtyChild { impl Drop for ConPtyChild { fn drop(&mut self) { + self.wait_cancel.store(true, Ordering::Release); if (self.cleanup_armed || self.kill_on_drop) && matches!(self.exit_status, ChildExitStatus::Running) && !matches!(process_status(self.raw_process_handle(), 0), Ok(Some(_))) @@ -295,6 +310,7 @@ mod tests { controller: None, exit_status: ChildExitStatus::Running, wait_task: None, + wait_cancel: Arc::new(AtomicBool::new(false)), stdin: None, stdout: None, stderr: None, @@ -396,6 +412,31 @@ mod tests { assert_eq!(wait_native(&mut native), status); } + #[tokio::test] + async fn dropping_after_a_canceled_wait_releases_the_blocking_task() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + assert!( + tokio::time::timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + let task = child + .wait_task + .take() + .expect("a canceled wait retains its blocking task"); + drop(child); + let error = tokio::time::timeout(Duration::from_secs(5), task) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + assert!(native.try_wait().unwrap().is_none()); + native.kill().unwrap(); + let _ = wait_native(&mut native); + } + #[test] fn armed_spawn_cleanup_terminates_a_running_process() { let mut native = spawn_long_running(); @@ -409,8 +450,10 @@ mod tests { fn final_spawn_disarm_preserves_a_running_process() { let mut native = spawn_long_running(); let mut child = wrap(&native, false); + child.primary_thread = Some(duplicate_process(&native)); child.cleanup_armed = true; child.disarm_job_object_layer().unwrap(); + assert!(child.primary_thread_handle().is_none()); drop(child); assert!(native.try_wait().unwrap().is_none()); native.kill().unwrap(); From 314cb3059f50b3f278df855af69ea88af852234d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 21:08:54 +1200 Subject: [PATCH 018/114] fix(pty): detach ConPTY waits from Tokio runtimes Move cancellation-safe process waits onto a dedicated OS thread so a retained child cannot hold a Tokio blocking pool open during runtime shutdown. Co-Authored-By: Claude Opus 5 (1M context) --- src/tokio/pty/windows/child.rs | 203 ++++++++++++++++++++++++++++----- 1 file changed, 176 insertions(+), 27 deletions(-) diff --git a/src/tokio/pty/windows/child.rs b/src/tokio/pty/windows/child.rs index 9cf9200..9e58ed9 100644 --- a/src/tokio/pty/windows/child.rs +++ b/src/tokio/pty/windows/child.rs @@ -10,15 +10,13 @@ use std::{ pin::Pin, process::ExitStatus, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }, + task::{Context, Poll, Waker}, }; -use tokio::{ - process::{ChildStderr, ChildStdin, ChildStdout}, - task::{JoinHandle, spawn_blocking}, -}; +use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; #[cfg(feature = "job-object")] use windows::Win32::System::Threading::ResumeThread; use windows::Win32::{ @@ -43,13 +41,136 @@ pub(super) struct ConPtyChild { cleanup_armed: bool, controller: Option>, exit_status: ChildExitStatus, - wait_task: Option>>, + wait_task: Option, wait_cancel: Arc, stdin: Option, stdout: Option, stderr: Option, } +#[derive(Debug)] +struct WaitTask { + state: Arc, + _thread: std::thread::JoinHandle<()>, +} + +#[derive(Debug, Default)] +struct WaitState { + result: Mutex, +} + +#[derive(Debug, Default)] +struct WaitResult { + result: Option>, + waker: Option, +} + +impl WaitTask { + fn spawn(process: OwnedHandle, cancel: Arc) -> io::Result { + let state = Arc::new(WaitState::default()); + let thread_state = Arc::clone(&state); + let thread = std::thread::Builder::new() + .name("process-wrap-conpty-wait".into()) + .spawn(move || { + let result = loop { + if cancel.load(Ordering::Acquire) { + break Err(io::Error::new( + io::ErrorKind::Interrupted, + "ConPTY process wait was canceled because the child was dropped", + )); + } + match process_status(HANDLE(process.as_raw_handle()), 50) { + Ok(Some(status)) => break Ok(status), + Ok(None) => {} + Err(error) => break Err(error), + } + }; + thread_state.complete(result); + })?; + Ok(Self { + state, + _thread: thread, + }) + } + + fn clear_waker(&self) { + self.state + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waker + .take(); + } + + #[cfg(test)] + fn id(&self) -> std::thread::ThreadId { + self._thread.thread().id() + } +} + +impl Future for WaitTask { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut state = self + .state + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(result) = state.result.take() { + return Poll::Ready(result); + } + if state + .waker + .as_ref() + .is_none_or(|waker| !waker.will_wake(cx.waker())) + { + state.waker = Some(cx.waker().clone()); + } + Poll::Pending + } +} + +impl Drop for WaitTask { + fn drop(&mut self) { + self.clear_waker(); + } +} + +impl WaitState { + fn complete(&self, result: io::Result) { + let waker = { + let mut state = self + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.result = Some(result); + state.waker.take() + }; + if let Some(waker) = waker { + waker.wake(); + } + } +} + +struct RegisteredWait<'a> { + task: &'a mut WaitTask, +} + +impl Future for RegisteredWait<'_> { + type Output = io::Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut *self.task).poll(cx) + } +} + +impl Drop for RegisteredWait<'_> { + fn drop(&mut self) { + self.task.clear_waker(); + } +} + impl ConPtyChild { pub(super) fn new( process: OwnedHandle, @@ -191,27 +312,17 @@ impl ChildWrapper for ConPtyChild { if self.wait_task.is_none() { let process = duplicate_handle(&self.process)?; let cancel = Arc::clone(&self.wait_cancel); - self.wait_task = Some(spawn_blocking(move || { - loop { - if cancel.load(Ordering::Acquire) { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "ConPTY process wait was canceled because the child was dropped", - )); - } - if let Some(status) = process_status(HANDLE(process.as_raw_handle()), 50)? { - return Ok(status); - } - } - })); + self.wait_task = Some(WaitTask::spawn(process, cancel)?); + } + let result = RegisteredWait { + task: self + .wait_task + .as_mut() + .expect("an in-progress ConPTY wait must retain its task"), } - let result = self - .wait_task - .as_mut() - .expect("an in-progress ConPTY wait must retain its task") - .await; + .await; self.wait_task.take(); - let status = result.map_err(io::Error::other)??; + let status = result?; self.exit_status = ChildExitStatus::Exited(status); Ok(status) }) @@ -412,8 +523,47 @@ mod tests { assert_eq!(wait_native(&mut native), status); } + #[test] + fn canceled_wait_does_not_block_runtime_shutdown_while_child_lives() { + let mut native = spawn_long_running(); + let mut child = Some(wrap(&native, false)); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + assert!( + tokio::time::timeout(Duration::from_millis(20), child.as_mut().unwrap().wait(),) + .await + .is_err() + ); + }); + + let (shutdown_finished, observe_shutdown) = std::sync::mpsc::channel(); + let shutdown = std::thread::spawn(move || { + drop(runtime); + let _ = shutdown_finished.send(()); + }); + let runtime_stopped = observe_shutdown + .recv_timeout(Duration::from_secs(2)) + .is_ok(); + if !runtime_stopped { + drop(child.take()); + } + shutdown.join().unwrap(); + assert!( + runtime_stopped, + "dropping the runtime waited for the canceled child wait" + ); + + assert!(native.try_wait().unwrap().is_none()); + drop(child.take()); + native.kill().unwrap(); + let _ = wait_native(&mut native); + } + #[tokio::test] - async fn dropping_after_a_canceled_wait_releases_the_blocking_task() { + async fn dropping_after_a_canceled_wait_releases_the_waiter_thread() { let mut native = spawn_long_running(); let mut child = wrap(&native, false); assert!( @@ -429,7 +579,6 @@ mod tests { let error = tokio::time::timeout(Duration::from_secs(5), task) .await .unwrap() - .unwrap() .unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::Interrupted); assert!(native.try_wait().unwrap().is_none()); From c59cc9800ce537e21ce69362e1d44cc515e06930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 21:22:24 +1200 Subject: [PATCH 019/114] fix(job-object): keep rollback armed through finalization Defer the exact JobObject cleanup owner until every other child-layer finalization hook has succeeded, and isolate descendant cleanup regressions from console or ConPTY closure. Co-Authored-By: Claude Opus 5 (1M context) --- src/std/core.rs | 65 ++++++++++++++++++- src/std/job_object.rs | 3 + src/tokio/core.rs | 65 ++++++++++++++++++- src/tokio/job_object.rs | 3 + .../std_windows/creation_flags_job_object.rs | 42 ++++++++++-- tests/tokio_pty_windows.rs | 29 +++++++-- .../creation_flags_job_object.rs | 42 ++++++++++-- 7 files changed, 222 insertions(+), 27 deletions(-) diff --git a/src/std/core.rs b/src/std/core.rs index 91ecf72..6b1d11d 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -134,9 +134,19 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { Ok(()) } - /// Disarm the JobObject cleanup state owned by this child layer. + /// Report whether this layer owns the JobObject rollback guard. /// - /// This process-wrap-internal phase runs after every other fallible finalizer and cleanup disarm. + /// Process-wrap defers the sole owning layer until every other fallible child-layer hook succeeds. + #[doc(hidden)] + #[cfg(windows)] + fn owns_job_object_cleanup_layer(&self) -> bool { + false + } + + /// Disarm JobObject-phase cleanup state owned by this child layer. + /// + /// Non-owning hooks run first. The sole layer identified by + /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs last. #[doc(hidden)] #[cfg(windows)] fn disarm_job_object_layer(&mut self) -> Result<()> { @@ -407,11 +417,60 @@ impl dyn ChildWrapper + '_ { } } + #[cfg(windows)] + fn visit_spawn_layers_until( + &mut self, + mut visit: impl FnMut(&mut dyn ChildWrapper) -> Result, + ) -> Result<()> { + let mut inner = self; + loop { + if visit(inner)? { + return Ok(()); + } + + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return Ok(()); + } + inner = next; + } + } + #[cfg(windows)] pub(crate) fn finalize_spawn(&mut self) -> Result<()> { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; self.visit_spawn_layers(|inner| inner.disarm_spawn_cleanup_layer())?; - self.visit_spawn_layers(|inner| inner.disarm_job_object_layer()) + + let mut owners = 0; + self.visit_spawn_layers(|inner| { + if inner.owns_job_object_cleanup_layer() { + owners += 1; + Ok(()) + } else { + inner.disarm_job_object_layer() + } + })?; + if owners > 1 { + return Err(std::io::Error::other( + "multiple child layers own JobObject cleanup", + )); + } + if owners == 0 { + return Ok(()); + } + + self.visit_spawn_layers_until(|inner| { + if inner.owns_job_object_cleanup_layer() { + inner.disarm_job_object_layer()?; + Ok(true) + } else { + Ok(false) + } + }) } /// Try to obtain a reference to the underlying native [`Child`]. diff --git a/src/std/job_object.rs b/src/std/job_object.rs index c1e41aa..58c5a5b 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -220,6 +220,9 @@ impl ChildWrapper for JobObjectChild { fn process_handle(&self) -> Option> { self.inner.try_process_handle() } + fn owns_job_object_cleanup_layer(&self) -> bool { + true + } fn disarm_job_object_layer(&mut self) -> Result<()> { set_job_kill_on_drop(self.job_port.job, self.final_kill_on_drop)?; self.spawn_finalized = true; diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 3d40452..a9717e1 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -157,9 +157,19 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { Ok(()) } - /// Disarm the JobObject cleanup state owned by this child layer. + /// Report whether this layer owns the JobObject rollback guard. /// - /// This process-wrap-internal phase runs after every other fallible finalizer and cleanup disarm. + /// Process-wrap defers the sole owning layer until every other fallible child-layer hook succeeds. + #[doc(hidden)] + #[cfg(windows)] + fn owns_job_object_cleanup_layer(&self) -> bool { + false + } + + /// Disarm JobObject-phase cleanup state owned by this child layer. + /// + /// Non-owning hooks run first. The sole layer identified by + /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs last. #[doc(hidden)] #[cfg(windows)] fn disarm_job_object_layer(&mut self) -> Result<()> { @@ -473,11 +483,60 @@ impl dyn ChildWrapper + '_ { } } + #[cfg(windows)] + fn visit_spawn_layers_until( + &mut self, + mut visit: impl FnMut(&mut dyn ChildWrapper) -> Result, + ) -> Result<()> { + let mut inner = self; + loop { + if visit(inner)? { + return Ok(()); + } + + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return Ok(()); + } + inner = next; + } + } + #[cfg(windows)] pub(crate) fn finalize_spawn(&mut self) -> Result<()> { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; self.visit_spawn_layers(|inner| inner.disarm_spawn_cleanup_layer())?; - self.visit_spawn_layers(|inner| inner.disarm_job_object_layer()) + + let mut owners = 0; + self.visit_spawn_layers(|inner| { + if inner.owns_job_object_cleanup_layer() { + owners += 1; + Ok(()) + } else { + inner.disarm_job_object_layer() + } + })?; + if owners > 1 { + return Err(std::io::Error::other( + "multiple child layers own JobObject cleanup", + )); + } + if owners == 0 { + return Ok(()); + } + + self.visit_spawn_layers_until(|inner| { + if inner.owns_job_object_cleanup_layer() { + inner.disarm_job_object_layer()?; + Ok(true) + } else { + Ok(false) + } + }) } /// Try to obtain a reference to the underlying native [`Child`]. diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 74a2929..55b18e5 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -233,6 +233,9 @@ impl ChildWrapper for JobObjectChild { fn process_handle(&self) -> Option> { self.inner.try_process_handle() } + fn owns_job_object_cleanup_layer(&self) -> bool { + true + } fn disarm_job_object_layer(&mut self) -> Result<()> { set_job_kill_on_drop(self.job_port.job, self.final_kill_on_drop)?; self.spawn_finalized = true; diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index 4ca145e..404d87e 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -1,9 +1,10 @@ use std::{ fs, io::{Error, ErrorKind}, + os::windows::process::CommandExt, panic::{AssertUnwindSafe, catch_unwind}, path::PathBuf, - process::{Command as StdCommand, ExitStatus}, + process::{Command as StdCommand, ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -12,7 +13,8 @@ use std::{ }; use windows::Win32::System::Threading::{ - CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, PROCESS_CREATION_FLAGS, + CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, + PROCESS_CREATION_FLAGS, }; use super::{ @@ -135,6 +137,7 @@ enum FailureHook { WrapChild, FinalizeSpawn, DisarmSpawnCleanup, + DisarmJobObject, } #[derive(Debug)] @@ -178,6 +181,17 @@ impl ChildWrapper for FailFinalizationChild { Ok(()) } } + + fn disarm_job_object_layer(&mut self) -> Result<()> { + if self.hook == FailureHook::DisarmJobObject { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } } #[derive(Debug)] @@ -245,7 +259,9 @@ impl CommandWrapper for FailAfterDescendant { self.observe_descendant()?; if matches!( self.hook, - FailureHook::FinalizeSpawn | FailureHook::DisarmSpawnCleanup + FailureHook::FinalizeSpawn + | FailureHook::DisarmSpawnCleanup + | FailureHook::DisarmJobObject ) { return Ok(Box::new(FailFinalizationChild { inner: child, @@ -262,12 +278,18 @@ impl CommandWrapper for FailAfterDescendant { } } -fn descendant_pid_file() -> PathBuf { - std::env::temp_dir().join(format!( +fn descendant_pid_file() -> Result { + let path = std::env::temp_dir().join(format!( "process-wrap-{}-{}.pid", std::process::id(), PID_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) - )) + )); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + Ok(path) } fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { @@ -393,6 +415,10 @@ fn lifecycle_descendant_leaf() { fn lifecycle_descendant_parent() { let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) .args(["lifecycle_descendant_leaf", "--ignored", "--nocapture"]) + .creation_flags(DETACHED_PROCESS.0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .unwrap(); fs::write( @@ -414,10 +440,12 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { (FailureHook::FinalizeSpawn, true, false), (FailureHook::DisarmSpawnCleanup, false, false), (FailureHook::DisarmSpawnCleanup, true, false), + (FailureHook::DisarmJobObject, false, false), + (FailureHook::DisarmJobObject, true, false), ]; for failure in [Failure::Error, Failure::Panic] { for (hook, job_first, unwrap_child) in cases { - let pid_file = descendant_pid_file(); + let pid_file = descendant_pid_file()?; let guard = Arc::new(Mutex::new(None)); let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { command diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index af3b8ee..32d4c49 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -3,9 +3,10 @@ use std::{ env, io::{self, Read, Write}, + os::windows::process::CommandExt, panic::{AssertUnwindSafe, catch_unwind}, path::{Path, PathBuf}, - process::ExitStatus, + process::{ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -36,8 +37,8 @@ use windows::Win32::{ GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, }, Threading::{ - INFINITE, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, - WaitForSingleObject, + DETACHED_PROCESS, INFINITE, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, + TerminateProcess, WaitForSingleObject, }, }, }; @@ -79,12 +80,26 @@ fn spawn_with_terminal( Ok((child, controller)) } -fn spawn_descendant(release: &Path) -> io::Result { - std::process::Command::new(env::current_exe()?) +fn descendant_command(release: &Path) -> io::Result { + let mut command = std::process::Command::new(env::current_exe()?); + command .args(["--exact", "conpty_child_helper", "--nocapture"]) .env(HELPER_MODE, "descendant") .env("PW_RELEASE", release) - .env_remove("PW_DESCENDANT_PID") + .env_remove("PW_DESCENDANT_PID"); + Ok(command) +} + +fn spawn_descendant(release: &Path) -> io::Result { + descendant_command(release)?.spawn() +} + +fn spawn_detached_descendant(release: &Path) -> io::Result { + descendant_command(release)? + .creation_flags(DETACHED_PROCESS.0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() } @@ -288,7 +303,7 @@ fn conpty_child_helper() -> io::Result<()> { let pid_file = env::var_os("PW_DESCENDANT_PID").ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "PW_DESCENDANT_PID is unset") })?; - let descendant = spawn_descendant(Path::new(&release))?; + let descendant = spawn_detached_descendant(Path::new(&release))?; std::fs::write(pid_file, descendant.id().to_string())?; drop(descendant); print!("PW-TREE-READY"); diff --git a/tests/tokio_windows/creation_flags_job_object.rs b/tests/tokio_windows/creation_flags_job_object.rs index bfd161b..5bd43f3 100644 --- a/tests/tokio_windows/creation_flags_job_object.rs +++ b/tests/tokio_windows/creation_flags_job_object.rs @@ -1,9 +1,10 @@ use std::{ fs, io::{Error, ErrorKind}, + os::windows::process::CommandExt, panic::{AssertUnwindSafe, catch_unwind}, path::PathBuf, - process::{Command as StdCommand, ExitStatus}, + process::{Command as StdCommand, ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -12,7 +13,8 @@ use std::{ }; use windows::Win32::System::Threading::{ - CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, PROCESS_CREATION_FLAGS, + CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, + PROCESS_CREATION_FLAGS, }; use super::{ @@ -141,6 +143,7 @@ enum FailureHook { WrapChild, FinalizeSpawn, DisarmSpawnCleanup, + DisarmJobObject, } #[derive(Debug)] @@ -184,6 +187,17 @@ impl ChildWrapper for FailFinalizationChild { Ok(()) } } + + fn disarm_job_object_layer(&mut self) -> Result<()> { + if self.hook == FailureHook::DisarmJobObject { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } } #[derive(Debug)] @@ -257,7 +271,9 @@ impl CommandWrapper for FailAfterDescendant { self.observe_descendant()?; if matches!( self.hook, - FailureHook::FinalizeSpawn | FailureHook::DisarmSpawnCleanup + FailureHook::FinalizeSpawn + | FailureHook::DisarmSpawnCleanup + | FailureHook::DisarmJobObject ) { return Ok(Box::new(FailFinalizationChild { inner: child, @@ -274,12 +290,18 @@ impl CommandWrapper for FailAfterDescendant { } } -fn descendant_pid_file() -> PathBuf { - std::env::temp_dir().join(format!( +fn descendant_pid_file() -> Result { + let path = std::env::temp_dir().join(format!( "process-wrap-{}-{}.pid", std::process::id(), PID_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) - )) + )); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + Ok(path) } async fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { @@ -414,6 +436,10 @@ fn lifecycle_descendant_leaf() { fn lifecycle_descendant_parent() { let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) .args(["lifecycle_descendant_leaf", "--ignored", "--nocapture"]) + .creation_flags(DETACHED_PROCESS.0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .unwrap(); fs::write( @@ -435,10 +461,12 @@ async fn armed_job_kills_descendants_after_later_failures() -> Result<()> { (FailureHook::FinalizeSpawn, true, false), (FailureHook::DisarmSpawnCleanup, false, false), (FailureHook::DisarmSpawnCleanup, true, false), + (FailureHook::DisarmJobObject, false, false), + (FailureHook::DisarmJobObject, true, false), ]; for failure in [Failure::Error, Failure::Panic] { for (hook, job_first, unwrap_child) in cases { - let pid_file = descendant_pid_file(); + let pid_file = descendant_pid_file()?; let guard = Arc::new(Mutex::new(None)); let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { command From b78fc3bf04d3eb788051a52c3e0625fb4e047eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 21:37:20 +1200 Subject: [PATCH 020/114] feat(pty): expose backend capability queries Expose PTY backend availability without requiring a configured spawn. Co-Authored-By: Claude Code --- src/tokio/pty.rs | 19 ++++++++++++++++++- tests/tokio_pty_provider.rs | 20 ++++++++++++++++++++ tests/tokio_pty_unsupported.rs | 28 +++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index 462810d..7ad8547 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -145,6 +145,23 @@ pub struct Pty { } impl Pty { + /// Check whether the selected PTY backend is available on this platform and runtime. + /// + /// This does not validate a [`Pty`] configuration or attempt a spawn. A successful result only + /// reports backend capability; a particular spawn can still fail for its command, configuration, + /// or environment. + pub fn check_supported() -> io::Result<()> { + imp::check_available() + } + + /// Return whether the selected PTY backend is available on this platform and runtime. + /// + /// This is equivalent to [`Pty::check_supported`].is_ok(). A `true` result does not guarantee a + /// particular spawn will succeed. + pub fn is_supported() -> bool { + Self::check_supported().is_ok() + } + /// Construct a PTY provider with the requested initial terminal size. pub fn new(size: PtySize) -> Self { Self { size } @@ -168,7 +185,7 @@ impl CommandWrapper for Pty { impl SpawnProvider for Pty { fn check_available(&self) -> io::Result<()> { - imp::check_available() + Self::check_supported() } fn validate_command(&self, _command: &Command) -> io::Result<()> { diff --git a/tests/tokio_pty_provider.rs b/tests/tokio_pty_provider.rs index 1a6e64d..fd0c3ef 100644 --- a/tests/tokio_pty_provider.rs +++ b/tests/tokio_pty_provider.rs @@ -29,6 +29,26 @@ use process_wrap::tokio::{ }; use tokio::{io::AsyncReadExt, time::timeout}; +#[test] +fn capability_queries_agree_on_supported_platforms() { + let checked = Pty::check_supported().is_ok(); + assert!(checked); + assert_eq!(Pty::is_supported(), checked); +} + +#[test] +fn capability_queries_ignore_invalid_configuration_on_supported_platforms() { + let invalid = Pty::new(PtySize { + rows: 0, + columns: 0, + pixel_width: 0, + pixel_height: 0, + }); + + assert!(Pty::check_supported().is_ok()); + assert!(invalid.check_available().is_ok()); +} + fn controller(child: &mut Box) -> PtyController { child .take_pty_controller() diff --git a/tests/tokio_pty_unsupported.rs b/tests/tokio_pty_unsupported.rs index 3a96260..7137aa1 100644 --- a/tests/tokio_pty_unsupported.rs +++ b/tests/tokio_pty_unsupported.rs @@ -22,7 +22,33 @@ use std::{ }, }; -use process_wrap::tokio::{Command, CommandWrapper, Pty, PtySize, SpawnAttempt}; +use process_wrap::tokio::{Command, CommandWrapper, Pty, PtySize, SpawnAttempt, SpawnProvider}; + +#[test] +fn capability_queries_agree_on_unsupported_platforms() { + let checked = Pty::check_supported(); + assert_eq!(checked.unwrap_err().kind(), io::ErrorKind::Unsupported); + assert!(!Pty::is_supported()); +} + +#[test] +fn capability_queries_ignore_invalid_configuration_on_unsupported_platforms() { + let invalid = Pty::new(PtySize { + rows: 0, + columns: 0, + pixel_width: 0, + pixel_height: 0, + }); + + assert_eq!( + Pty::check_supported().unwrap_err().kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + invalid.check_available().unwrap_err().kind(), + io::ErrorKind::Unsupported + ); +} #[test] fn spawning_is_explicitly_unsupported() { From c63b7673a16d637a5be9b11cb8494c3baffce29c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 22:20:29 +1200 Subject: [PATCH 021/114] test(pty): cover capability queries across targets Run the focused unsupported-target test through cross-compilation and cover Windows runtime availability. Co-Authored-By: Claude Code --- Cargo.toml | 4 +++- tests/tokio_pty_windows.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dba3c74..c7452cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,10 +44,12 @@ windows = { version = "0.62.2", optional = true } # as process-wrap is carefully designed to never expose windows's types [dev-dependencies] -remoteprocess = "0.5.0" tempfile = { version = "3.20.0", default-features = false } tokio = { version = "1.38.2", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } +[target.'cfg(any(target_os = "freebsd", target_os = "linux", target_os = "macos"))'.dev-dependencies] +remoteprocess = "0.5.0" + [features] default = ["creation-flags", "job-object", "kill-on-drop", "process-group", "process-session", "tracing"] diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 32d4c49..53d8772 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -70,6 +70,25 @@ fn take_controller(child: &mut dyn ChildWrapper) -> PtyController { controller } +#[test] +fn capability_queries_agree_with_windows_backend() { + let checked = Pty::check_supported().is_ok(); + assert_eq!(Pty::is_supported(), checked); +} + +#[test] +fn capability_queries_ignore_invalid_configuration_on_windows() { + let invalid = Pty::new(PtySize { + rows: 0, + columns: 0, + pixel_width: 0, + pixel_height: 0, + }); + let checked = Pty::check_supported().is_ok(); + + assert_eq!(invalid.check_available().is_ok(), checked); +} + fn spawn_with_terminal( command: &mut Command, size: PtySize, From 9c8469ab476331b8882c805f95b529601b8e1978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 22:27:21 +1200 Subject: [PATCH 022/114] fix(pty): attach ConPTY standard handles Co-Authored-By: Claude Code --- src/tokio/pty/windows/spawn.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index cccfb35..4515bef 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -13,8 +13,8 @@ use windows::{ System::Threading::{ CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, GetProcessIdOfThread, INFINITE, OpenProcess, PROCESS_CREATION_FLAGS, - PROCESS_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, STARTUPINFOEXW, - TerminateProcess, WaitForSingleObject, + PROCESS_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, STARTF_USESTDHANDLES, + STARTUPINFOEXW, TerminateProcess, WaitForSingleObject, }, }, core::{PCWSTR, PWSTR}, @@ -50,8 +50,9 @@ pub(super) fn spawn( // SAFETY: every pointer references live, NUL-terminated or explicitly bounded storage for the // duration of the call. The command line is uniquely mutable, the startup attribute list owns its - // aligned backing storage, no native handles are inherited, and ConPTY installs the child console - // through the pseudoconsole attribute. The process-information value is writable output storage. + // aligned backing storage, no native handles are inherited, and STARTF_USESTDHANDLES with three null + // slots asks ConPTY to install its console handles. The process-information value is writable output + // storage. let created = unsafe { CreateProcessW( PCWSTR(command.application_name.as_ptr()), @@ -81,6 +82,10 @@ pub(super) fn spawn( fn startup_info(attributes: &AttributeList) -> STARTUPINFOEXW { let mut startup = STARTUPINFOEXW::default(); startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = HANDLE::default(); + startup.StartupInfo.hStdOutput = HANDLE::default(); + startup.StartupInfo.hStdError = HANDLE::default(); startup.lpAttributeList = attributes.as_ptr(); startup } @@ -239,15 +244,15 @@ fn win32_io_error(error: windows::core::Error) -> io::Error { #[cfg(test)] mod tests { - use windows::Win32::System::Console::HPCON; + use windows::Win32::System::{Console::HPCON, Threading::STARTF_USESTDHANDLES}; use super::*; #[test] - fn leaves_standard_handle_replacement_to_conpty() { + fn requests_conpty_replacement_for_all_standard_handles() { let attributes = AttributeList::new(HPCON(42)).unwrap(); let startup = startup_info(&attributes); - assert_eq!(startup.StartupInfo.dwFlags, Default::default()); + assert_eq!(startup.StartupInfo.dwFlags, STARTF_USESTDHANDLES); assert_eq!(startup.StartupInfo.hStdInput, HANDLE::default()); assert_eq!(startup.StartupInfo.hStdOutput, HANDLE::default()); assert_eq!(startup.StartupInfo.hStdError, HANDLE::default()); From a1aedf6ac37c2080008727df610500ef90a05a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 22:34:31 +1200 Subject: [PATCH 023/114] docs(pty): document Windows ConPTY support Describe the supported Windows floor and conditional PTY capability API. Co-Authored-By: Claude Code --- README.md | 13 +++++++++++-- src/lib.rs | 11 ++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index efd61b0..d8559f2 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,10 @@ 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. +NetBSD 10 and newer, OpenBSD, DragonFly BSD, illumos, Solaris, and Windows 11 24H2 (build 26100) +or Windows Server 2025. The Windows backend uses native ConPTY. That Windows floor is required +because descendant-aware output EOF requires `ReleasePseudoConsole`. The feature implies `tokio1`, +selecting the Tokio frontend and its terminal dependencies explicitly. ```toml [dependencies] @@ -120,6 +122,13 @@ let terminal_bytes = drain.await??; dbg!(status, terminal_bytes); ``` +On every supported platform, including Windows, register `Pty` with the same +`Command::wrap(Pty::default()).spawn()` API and take the same `PtyController`. The controller has +the ownership and lifecycle contract described below on Unix and Windows alike. To select a PTY +conditionally, use `Pty::check_supported()` for a capability result or `Pty::is_supported()` for a +boolean. These report platform and runtime capability only; they do not suppress later +configuration, compatibility, or spawn errors. + 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; diff --git a/src/lib.rs b/src/lib.rs index e104f30..6c49007 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,7 +112,9 @@ //! //! 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`. +//! illumos, Solaris, and Windows 11 24H2 (build 26100) or Windows Server 2025. The Windows backend +//! uses native ConPTY. That Windows floor is required because descendant-aware output EOF requires +//! `ReleasePseudoConsole`. Unavailable platforms report `std::io::ErrorKind::Unsupported`. //! //! ```rust,no_run //! # #[cfg(feature = "pty")] @@ -134,6 +136,13 @@ //! # fn main() {} //! ``` //! +//! On every supported platform, including Windows, register `Pty` with the same +//! `Command::wrap(Pty::default()).spawn()` API and take the same `PtyController`. Its ownership and +//! lifecycle contract below applies on Unix and Windows alike. To select a PTY conditionally, use +//! `Pty::check_supported()` for a capability result or `Pty::is_supported()` for a boolean. These +//! report platform and runtime capability only; they do not suppress later configuration, +//! compatibility, or spawn errors. +//! //! 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 From a480ad1738a457d2a57f1850f4c76dd8db33622d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 22:47:51 +1200 Subject: [PATCH 024/114] fix(pty): release ConPTY startup before hooks Co-Authored-By: Claude Code --- src/tokio/pty/windows/backend.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs index c16c9a4..49e6d23 100644 --- a/src/tokio/pty/windows/backend.rs +++ b/src/tokio/pty/windows/backend.rs @@ -11,7 +11,7 @@ use super::{ super::{ControllerSlot, PtyController, PtySize}, api, attributes::AttributeList, - console::{self, PseudoConsole, Release}, + console::{self, PseudoConsole}, controller, pipe::PipePair, prepare, @@ -44,13 +44,13 @@ pub(in crate::tokio::pty) fn spawn( let process::SpawnedChild { mut child, cleanup } = process::spawn(prepared, &attributes, input_server, output_server)?; child.install_controller(Arc::clone(&controller)); + release.release()?; Ok(ProviderProduct::new( Box::new(child), Box::new(ConPtyTransaction { cleanup, controller, - release, }), )) } @@ -59,12 +59,10 @@ pub(in crate::tokio::pty) fn spawn( struct ConPtyTransaction { cleanup: SpawnCleanup, controller: Arc, - release: Release, } impl SpawnTransaction for ConPtyTransaction { fn commit(&mut self) -> io::Result<()> { - self.release.release()?; self.cleanup.disarm(); Ok(()) } From bf2544e3b6781d2e30c9d0a2780299a8407bb431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 23:15:01 +1200 Subject: [PATCH 025/114] revert: retain ConPTY startup release until commit Co-Authored-By: Claude Code --- src/tokio/pty/windows/backend.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs index 49e6d23..c16c9a4 100644 --- a/src/tokio/pty/windows/backend.rs +++ b/src/tokio/pty/windows/backend.rs @@ -11,7 +11,7 @@ use super::{ super::{ControllerSlot, PtyController, PtySize}, api, attributes::AttributeList, - console::{self, PseudoConsole}, + console::{self, PseudoConsole, Release}, controller, pipe::PipePair, prepare, @@ -44,13 +44,13 @@ pub(in crate::tokio::pty) fn spawn( let process::SpawnedChild { mut child, cleanup } = process::spawn(prepared, &attributes, input_server, output_server)?; child.install_controller(Arc::clone(&controller)); - release.release()?; Ok(ProviderProduct::new( Box::new(child), Box::new(ConPtyTransaction { cleanup, controller, + release, }), )) } @@ -59,10 +59,12 @@ pub(in crate::tokio::pty) fn spawn( struct ConPtyTransaction { cleanup: SpawnCleanup, controller: Arc, + release: Release, } impl SpawnTransaction for ConPtyTransaction { fn commit(&mut self) -> io::Result<()> { + self.release.release()?; self.cleanup.disarm(); Ok(()) } From 3e8865e52bc2ee105a8c46b157682c1c66ffc559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Fri, 25 Sep 2026 23:24:07 +1200 Subject: [PATCH 026/114] test(pty): diagnose pre-commit ConPTY child failure Co-Authored-By: Claude Code --- tests/tokio_pty_windows.rs | 94 +++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 53d8772..64251d4 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -49,6 +49,7 @@ use windows::Win32::{ mod windows_thread; const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; +const HELPER_DIAGNOSTIC_STAGE_FILE: &str = "PROCESS_WRAP_CONPTY_DIAGNOSTIC_STAGE_FILE"; const TIMEOUT: Duration = Duration::from_secs(10); fn helper(mode: &str) -> io::Result { @@ -59,6 +60,71 @@ fn helper(mode: &str) -> io::Result { Ok(command) } +fn record_diagnostic_stage(path: Option<&Path>, stage: &str) -> io::Result<()> { + if let Some(path) = path { + writeln!( + std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path)?, + "{stage}" + )?; + } + Ok(()) +} + +fn last_diagnostic_stage(path: &Path) -> String { + match std::fs::read_to_string(path) { + Ok(stages) => stages + .lines() + .last() + .map_or_else(|| "empty".to_owned(), ToOwned::to_owned), + Err(error) if error.kind() == io::ErrorKind::NotFound => "absent".to_owned(), + Err(error) => format!("read error: {error}"), + } +} + +fn descendant_pid_timeout_error(stage: String, child: io::Result>) -> io::Error { + let child = match child { + Ok(Some(status)) => format!("exited with {status}"), + Ok(None) => "still running".to_owned(), + Err(error) => format!("try_wait error: {error}"), + }; + io::Error::new( + io::ErrorKind::TimedOut, + format!( + "ConPTY descendant did not report its process ID (last stage: {stage}; direct child: {child})" + ), + ) +} + +#[test] +fn diagnostic_stage_recording_is_ordered() -> io::Result<()> { + let directory = tempfile::tempdir()?; + let stage_file = directory.path().join("diagnostic-stage"); + + record_diagnostic_stage(Some(&stage_file), "mode-read")?; + record_diagnostic_stage(Some(&stage_file), "terminal-handles-ready")?; + + assert_eq!( + std::fs::read_to_string(&stage_file)?, + "mode-read\nterminal-handles-ready\n" + ); + assert_eq!(last_diagnostic_stage(&stage_file), "terminal-handles-ready"); + Ok(()) +} + +#[test] +fn descendant_pid_timeout_describes_stage_and_child() { + let error = descendant_pid_timeout_error("terminal-handles-ready".to_owned(), Ok(None)); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert_eq!( + error.to_string(), + "ConPTY descendant did not report its process ID (last stage: terminal-handles-ready; direct child: still running)" + ); +} + fn take_controller(child: &mut dyn ChildWrapper) -> PtyController { let controller = child .take_pty_controller() @@ -105,7 +171,8 @@ fn descendant_command(release: &Path) -> io::Result { .args(["--exact", "conpty_child_helper", "--nocapture"]) .env(HELPER_MODE, "descendant") .env("PW_RELEASE", release) - .env_remove("PW_DESCENDANT_PID"); + .env_remove("PW_DESCENDANT_PID") + .env_remove(HELPER_DIAGNOSTIC_STAGE_FILE); Ok(command) } @@ -255,7 +322,10 @@ fn conpty_child_helper() -> io::Result<()> { let Ok(mode) = env::var(HELPER_MODE) else { return Ok(()); }; + let diagnostic_stage_file = env::var_os(HELPER_DIAGNOSTIC_STAGE_FILE).map(PathBuf::from); + record_diagnostic_stage(diagnostic_stage_file.as_deref(), "mode-read")?; let (stdin_handle, stdout_handle, stderr_handle) = terminal_handles()?; + record_diagnostic_stage(diagnostic_stage_file.as_deref(), "terminal-handles-ready")?; match mode.as_str() { "terminal" => { @@ -322,8 +392,14 @@ fn conpty_child_helper() -> io::Result<()> { let pid_file = env::var_os("PW_DESCENDANT_PID").ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "PW_DESCENDANT_PID is unset") })?; + record_diagnostic_stage(diagnostic_stage_file.as_deref(), "tree-environment-ready")?; let descendant = spawn_detached_descendant(Path::new(&release))?; + record_diagnostic_stage( + diagnostic_stage_file.as_deref(), + &format!("detached-descendant-spawned:{}", descendant.id()), + )?; std::fs::write(pid_file, descendant.id().to_string())?; + record_diagnostic_stage(diagnostic_stage_file.as_deref(), "descendant-pid-written")?; drop(descendant); print!("PW-TREE-READY"); io::stdout().flush()?; @@ -928,12 +1004,13 @@ struct FailAfterDescendantOnce { stage: LifecycleStage, failed: bool, pid_file: PathBuf, + stage_file: PathBuf, descendant: Arc>>, } #[cfg(feature = "job-object")] impl FailAfterDescendantOnce { - fn capture_descendant(&self) -> io::Result<()> { + fn capture_descendant(&self, child: &mut dyn ChildWrapper) -> io::Result<()> { let deadline = Instant::now() + TIMEOUT; let pid = loop { if let Ok(pid) = std::fs::read_to_string(&self.pid_file) @@ -942,9 +1019,9 @@ impl FailAfterDescendantOnce { break pid; } if Instant::now() >= deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "ConPTY descendant did not report its process ID", + return Err(descendant_pid_timeout_error( + last_diagnostic_stage(&self.stage_file), + child.try_wait(), )); } std::thread::sleep(Duration::from_millis(10)); @@ -966,7 +1043,7 @@ impl CommandWrapper for FailAfterDescendantOnce { return Ok(()); } assert!(child.take_pty_controller().is_none()); - self.capture_descendant()?; + self.capture_descendant(child)?; self.failed = true; fail_lifecycle(self.failure) } @@ -980,7 +1057,7 @@ impl CommandWrapper for FailAfterDescendantOnce { return Ok(child); } assert!(child.take_pty_controller().is_none()); - self.capture_descendant()?; + self.capture_descendant(child.as_mut())?; self.failed = true; if self.stage == LifecycleStage::WrapChild { fail_lifecycle(self.failure) @@ -1009,6 +1086,7 @@ async fn armed_job_reaps_descendants_after_later_conpty_failures() -> io::Result let directory = tempfile::tempdir()?; let release = directory.path().join("release-descendant"); let pid_file = directory.path().join("descendant-pid"); + let stage_file = directory.path().join("diagnostic-stage"); let _release_on_drop = ReleaseOnDrop(release.clone()); let descendant = Arc::new(Mutex::new(None)); let fail = FailAfterDescendantOnce { @@ -1016,12 +1094,14 @@ async fn armed_job_reaps_descendants_after_later_conpty_failures() -> io::Result stage, failed: false, pid_file: pid_file.clone(), + stage_file: stage_file.clone(), descendant: Arc::clone(&descendant), }; let mut command = helper("tree")?; command .env("PW_RELEASE", &release) .env("PW_DESCENDANT_PID", &pid_file) + .env(HELPER_DIAGNOSTIC_STAGE_FILE, &stage_file) .wrap(Pty::default()); if job_outer { command.wrap(fail).wrap(JobObject); From 29ed0fb51e4ea35f01948ce4325b59cbaad39fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 00:08:28 +1200 Subject: [PATCH 027/114] test(pty): wait for descendants after ConPTY commit Co-Authored-By: Claude Code --- tests/tokio_pty_windows.rs | 98 ++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 31 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 64251d4..df3f973 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -1009,8 +1009,20 @@ struct FailAfterDescendantOnce { } #[cfg(feature = "job-object")] -impl FailAfterDescendantOnce { - fn capture_descendant(&self, child: &mut dyn ChildWrapper) -> io::Result<()> { +#[derive(Debug)] +struct FailAfterDescendantChild { + inner: Box, + failure: LifecycleFailure, + stage: LifecycleStage, + pid_file: PathBuf, + stage_file: PathBuf, + descendant: Arc>>, +} + +#[cfg(feature = "job-object")] +impl FailAfterDescendantChild { + fn capture_descendant(&mut self) -> io::Result<()> { + assert!(self.inner_mut().take_pty_controller().is_none()); let deadline = Instant::now() + TIMEOUT; let pid = loop { if let Ok(pid) = std::fs::read_to_string(&self.pid_file) @@ -1021,7 +1033,7 @@ impl FailAfterDescendantOnce { if Instant::now() >= deadline { return Err(descendant_pid_timeout_error( last_diagnostic_stage(&self.stage_file), - child.try_wait(), + self.inner_mut().try_wait(), )); } std::thread::sleep(Duration::from_millis(10)); @@ -1029,45 +1041,71 @@ impl FailAfterDescendantOnce { *self.descendant.lock().unwrap() = Some(ProcessExitGuard::open(pid)?); Ok(()) } + + fn fail_after_descendant(&mut self) -> io::Result<()> { + self.capture_descendant()?; + fail_lifecycle(self.failure) + } } #[cfg(feature = "job-object")] -impl CommandWrapper for FailAfterDescendantOnce { - fn post_spawn( - &mut self, - _attempt: &mut SpawnAttempt, - child: &mut dyn ChildWrapper, - _command: &Command, - ) -> io::Result<()> { - if self.stage != LifecycleStage::PostSpawn || self.failed { - return Ok(()); +impl ChildWrapper for FailAfterDescendantChild { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner.as_mut() + } + + fn into_inner(self: Box) -> Box { + self.inner + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::FinalizeSpawn { + self.fail_after_descendant() + } else { + Ok(()) + } + } + + fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::DisarmSpawnCleanup { + self.fail_after_descendant() + } else { + Ok(()) + } + } + + fn disarm_job_object_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::DisarmJobObject { + self.fail_after_descendant() + } else { + Ok(()) } - assert!(child.take_pty_controller().is_none()); - self.capture_descendant(child)?; - self.failed = true; - fail_lifecycle(self.failure) } +} +#[cfg(feature = "job-object")] +impl CommandWrapper for FailAfterDescendantOnce { fn wrap_child( &mut self, - mut child: Box, + child: Box, _command: &Command, ) -> io::Result> { - if self.failed || self.stage == LifecycleStage::PostSpawn { + if self.failed { return Ok(child); } - assert!(child.take_pty_controller().is_none()); - self.capture_descendant(child.as_mut())?; self.failed = true; - if self.stage == LifecycleStage::WrapChild { - fail_lifecycle(self.failure) - } else { - Ok(Box::new(FailFinalizationChild { - inner: child, - failure: self.failure, - stage: self.stage, - })) - } + Ok(Box::new(FailAfterDescendantChild { + inner: child, + failure: self.failure, + stage: self.stage, + pid_file: self.pid_file.clone(), + stage_file: self.stage_file.clone(), + descendant: Arc::clone(&self.descendant), + })) } } @@ -1075,8 +1113,6 @@ impl CommandWrapper for FailAfterDescendantOnce { #[tokio::test] async fn armed_job_reaps_descendants_after_later_conpty_failures() -> io::Result<()> { for stage in [ - LifecycleStage::PostSpawn, - LifecycleStage::WrapChild, LifecycleStage::FinalizeSpawn, LifecycleStage::DisarmSpawnCleanup, LifecycleStage::DisarmJobObject, From f9f6f40cd0c80537104edfb1bb2778ff5303ebc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 00:29:41 +1200 Subject: [PATCH 028/114] test(pty): cover post-exit ConPTY kills Co-Authored-By: Claude Code --- src/tokio/pty/windows/child.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/tokio/pty/windows/child.rs b/src/tokio/pty/windows/child.rs index 9e58ed9..0189f05 100644 --- a/src/tokio/pty/windows/child.rs +++ b/src/tokio/pty/windows/child.rs @@ -472,6 +472,36 @@ mod tests { assert_eq!(wait_native(&mut native), status); } + #[tokio::test] + async fn start_kill_succeeds_after_natural_exit() { + let mut native = spawn_exit_259(); + let mut child = wrap(&native, false); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.code(), Some(259)); + child.start_kill().unwrap(); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn kill_succeeds_after_natural_exit() { + let mut native = spawn_exit_259(); + let mut child = wrap(&native, false); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.code(), Some(259)); + Pin::from(ChildWrapper::kill(&mut child)).await.unwrap(); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(wait_native(&mut native), status); + } + #[tokio::test] async fn kills_and_reaps_the_direct_process() { let mut native = spawn_long_running(); From 3ad885836714fee185742393e8c46f372a051cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 01:07:35 +1200 Subject: [PATCH 029/114] test(pty): remove invalid pre-return descendant case Co-Authored-By: Claude Code --- tests/tokio_pty_windows.rs | 252 +------------------------------------ 1 file changed, 1 insertion(+), 251 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index df3f973..c22e719 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -49,7 +49,6 @@ use windows::Win32::{ mod windows_thread; const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; -const HELPER_DIAGNOSTIC_STAGE_FILE: &str = "PROCESS_WRAP_CONPTY_DIAGNOSTIC_STAGE_FILE"; const TIMEOUT: Duration = Duration::from_secs(10); fn helper(mode: &str) -> io::Result { @@ -60,71 +59,6 @@ fn helper(mode: &str) -> io::Result { Ok(command) } -fn record_diagnostic_stage(path: Option<&Path>, stage: &str) -> io::Result<()> { - if let Some(path) = path { - writeln!( - std::fs::OpenOptions::new() - .append(true) - .create(true) - .open(path)?, - "{stage}" - )?; - } - Ok(()) -} - -fn last_diagnostic_stage(path: &Path) -> String { - match std::fs::read_to_string(path) { - Ok(stages) => stages - .lines() - .last() - .map_or_else(|| "empty".to_owned(), ToOwned::to_owned), - Err(error) if error.kind() == io::ErrorKind::NotFound => "absent".to_owned(), - Err(error) => format!("read error: {error}"), - } -} - -fn descendant_pid_timeout_error(stage: String, child: io::Result>) -> io::Error { - let child = match child { - Ok(Some(status)) => format!("exited with {status}"), - Ok(None) => "still running".to_owned(), - Err(error) => format!("try_wait error: {error}"), - }; - io::Error::new( - io::ErrorKind::TimedOut, - format!( - "ConPTY descendant did not report its process ID (last stage: {stage}; direct child: {child})" - ), - ) -} - -#[test] -fn diagnostic_stage_recording_is_ordered() -> io::Result<()> { - let directory = tempfile::tempdir()?; - let stage_file = directory.path().join("diagnostic-stage"); - - record_diagnostic_stage(Some(&stage_file), "mode-read")?; - record_diagnostic_stage(Some(&stage_file), "terminal-handles-ready")?; - - assert_eq!( - std::fs::read_to_string(&stage_file)?, - "mode-read\nterminal-handles-ready\n" - ); - assert_eq!(last_diagnostic_stage(&stage_file), "terminal-handles-ready"); - Ok(()) -} - -#[test] -fn descendant_pid_timeout_describes_stage_and_child() { - let error = descendant_pid_timeout_error("terminal-handles-ready".to_owned(), Ok(None)); - - assert_eq!(error.kind(), io::ErrorKind::TimedOut); - assert_eq!( - error.to_string(), - "ConPTY descendant did not report its process ID (last stage: terminal-handles-ready; direct child: still running)" - ); -} - fn take_controller(child: &mut dyn ChildWrapper) -> PtyController { let controller = child .take_pty_controller() @@ -171,8 +105,7 @@ fn descendant_command(release: &Path) -> io::Result { .args(["--exact", "conpty_child_helper", "--nocapture"]) .env(HELPER_MODE, "descendant") .env("PW_RELEASE", release) - .env_remove("PW_DESCENDANT_PID") - .env_remove(HELPER_DIAGNOSTIC_STAGE_FILE); + .env_remove("PW_DESCENDANT_PID"); Ok(command) } @@ -322,10 +255,7 @@ fn conpty_child_helper() -> io::Result<()> { let Ok(mode) = env::var(HELPER_MODE) else { return Ok(()); }; - let diagnostic_stage_file = env::var_os(HELPER_DIAGNOSTIC_STAGE_FILE).map(PathBuf::from); - record_diagnostic_stage(diagnostic_stage_file.as_deref(), "mode-read")?; let (stdin_handle, stdout_handle, stderr_handle) = terminal_handles()?; - record_diagnostic_stage(diagnostic_stage_file.as_deref(), "terminal-handles-ready")?; match mode.as_str() { "terminal" => { @@ -392,14 +322,8 @@ fn conpty_child_helper() -> io::Result<()> { let pid_file = env::var_os("PW_DESCENDANT_PID").ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "PW_DESCENDANT_PID is unset") })?; - record_diagnostic_stage(diagnostic_stage_file.as_deref(), "tree-environment-ready")?; let descendant = spawn_detached_descendant(Path::new(&release))?; - record_diagnostic_stage( - diagnostic_stage_file.as_deref(), - &format!("detached-descendant-spawned:{}", descendant.id()), - )?; std::fs::write(pid_file, descendant.id().to_string())?; - record_diagnostic_stage(diagnostic_stage_file.as_deref(), "descendant-pid-written")?; drop(descendant); print!("PW-TREE-READY"); io::stdout().flush()?; @@ -997,180 +921,6 @@ impl CommandWrapper for FailLifecycleOnce { } } -#[cfg(feature = "job-object")] -#[derive(Debug)] -struct FailAfterDescendantOnce { - failure: LifecycleFailure, - stage: LifecycleStage, - failed: bool, - pid_file: PathBuf, - stage_file: PathBuf, - descendant: Arc>>, -} - -#[cfg(feature = "job-object")] -#[derive(Debug)] -struct FailAfterDescendantChild { - inner: Box, - failure: LifecycleFailure, - stage: LifecycleStage, - pid_file: PathBuf, - stage_file: PathBuf, - descendant: Arc>>, -} - -#[cfg(feature = "job-object")] -impl FailAfterDescendantChild { - fn capture_descendant(&mut self) -> io::Result<()> { - assert!(self.inner_mut().take_pty_controller().is_none()); - let deadline = Instant::now() + TIMEOUT; - let pid = loop { - if let Ok(pid) = std::fs::read_to_string(&self.pid_file) - .and_then(|pid| pid.trim().parse().map_err(io::Error::other)) - { - break pid; - } - if Instant::now() >= deadline { - return Err(descendant_pid_timeout_error( - last_diagnostic_stage(&self.stage_file), - self.inner_mut().try_wait(), - )); - } - std::thread::sleep(Duration::from_millis(10)); - }; - *self.descendant.lock().unwrap() = Some(ProcessExitGuard::open(pid)?); - Ok(()) - } - - fn fail_after_descendant(&mut self) -> io::Result<()> { - self.capture_descendant()?; - fail_lifecycle(self.failure) - } -} - -#[cfg(feature = "job-object")] -impl ChildWrapper for FailAfterDescendantChild { - fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() - } - - fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() - } - - fn into_inner(self: Box) -> Box { - self.inner - } - - fn finalize_spawn_layer(&mut self) -> io::Result<()> { - if self.stage == LifecycleStage::FinalizeSpawn { - self.fail_after_descendant() - } else { - Ok(()) - } - } - - fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { - if self.stage == LifecycleStage::DisarmSpawnCleanup { - self.fail_after_descendant() - } else { - Ok(()) - } - } - - fn disarm_job_object_layer(&mut self) -> io::Result<()> { - if self.stage == LifecycleStage::DisarmJobObject { - self.fail_after_descendant() - } else { - Ok(()) - } - } -} - -#[cfg(feature = "job-object")] -impl CommandWrapper for FailAfterDescendantOnce { - fn wrap_child( - &mut self, - child: Box, - _command: &Command, - ) -> io::Result> { - if self.failed { - return Ok(child); - } - self.failed = true; - Ok(Box::new(FailAfterDescendantChild { - inner: child, - failure: self.failure, - stage: self.stage, - pid_file: self.pid_file.clone(), - stage_file: self.stage_file.clone(), - descendant: Arc::clone(&self.descendant), - })) - } -} - -#[cfg(feature = "job-object")] -#[tokio::test] -async fn armed_job_reaps_descendants_after_later_conpty_failures() -> io::Result<()> { - for stage in [ - LifecycleStage::FinalizeSpawn, - LifecycleStage::DisarmSpawnCleanup, - LifecycleStage::DisarmJobObject, - ] { - for failure in [LifecycleFailure::Error, LifecycleFailure::Panic] { - for job_outer in [false, true] { - let directory = tempfile::tempdir()?; - let release = directory.path().join("release-descendant"); - let pid_file = directory.path().join("descendant-pid"); - let stage_file = directory.path().join("diagnostic-stage"); - let _release_on_drop = ReleaseOnDrop(release.clone()); - let descendant = Arc::new(Mutex::new(None)); - let fail = FailAfterDescendantOnce { - failure, - stage, - failed: false, - pid_file: pid_file.clone(), - stage_file: stage_file.clone(), - descendant: Arc::clone(&descendant), - }; - let mut command = helper("tree")?; - command - .env("PW_RELEASE", &release) - .env("PW_DESCENDANT_PID", &pid_file) - .env(HELPER_DIAGNOSTIC_STAGE_FILE, &stage_file) - .wrap(Pty::default()); - if job_outer { - command.wrap(fail).wrap(JobObject); - } else { - command.wrap(JobObject).wrap(fail); - } - - let result = catch_unwind(AssertUnwindSafe(|| command.spawn())); - match failure { - LifecycleFailure::Error => assert_eq!( - result.unwrap().unwrap_err().to_string(), - "ConPTY lifecycle failed" - ), - LifecycleFailure::Panic => assert_eq!( - *result - .expect_err("the Windows lifecycle hook must panic") - .downcast::<&'static str>() - .unwrap(), - "ConPTY lifecycle failed" - ), - } - let guard = descendant - .lock() - .unwrap() - .take() - .expect("the failing lifecycle hook captures the descendant process"); - wait_for_process_exit(guard).await?; - } - } - } - Ok(()) -} - #[tokio::test] async fn failed_windows_lifecycle_reaps_the_child_and_remains_reusable() -> io::Result<()> { for stage in [ From 4a7a76cefd50ee6096c0b0f0fe4fe4802367b542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 01:42:15 +1200 Subject: [PATCH 030/114] fix(pty): reap failed ConPTY spawns off-thread Co-Authored-By: Claude Code --- src/tokio/pty/windows/backend.rs | 3 +- src/tokio/pty/windows/spawn.rs | 579 ++++++++++++++++++++++++++++++- 2 files changed, 563 insertions(+), 19 deletions(-) diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs index c16c9a4..53d9250 100644 --- a/src/tokio/pty/windows/backend.rs +++ b/src/tokio/pty/windows/backend.rs @@ -71,7 +71,6 @@ impl SpawnTransaction for ConPtyTransaction { fn rollback(&mut self) -> io::Result<()> { self.controller.rollback(); - self.cleanup.rollback(); - Ok(()) + self.cleanup.rollback() } } diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index 4515bef..b00ef5a 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -5,11 +5,15 @@ use std::{ io, mem::size_of, os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + sync::{Arc, Mutex, OnceLock}, }; use windows::{ Win32::{ - Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WIN32_ERROR}, + Foundation::{ + DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0, + WAIT_TIMEOUT, WIN32_ERROR, + }, System::Threading::{ CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, GetProcessIdOfThread, INFINITE, OpenProcess, PROCESS_CREATION_FLAGS, @@ -106,16 +110,14 @@ impl SpawnCleanup { self.process.take(); } - pub(super) fn rollback(&mut self) { - if let Some(process) = self.process.take() { - terminate_and_wait(&process); - } + pub(super) fn rollback(&mut self) -> io::Result<()> { + self.process.take().map_or(Ok(()), terminate_and_reap) } } impl Drop for SpawnCleanup { fn drop(&mut self) { - self.rollback(); + let _ = self.rollback(); } } @@ -170,8 +172,8 @@ impl SpawnedProcess { impl Drop for SpawnedProcess { fn drop(&mut self) { - if let Some(process) = &self.process { - terminate_and_wait(process); + if let Some(process) = self.process.take() { + let _ = terminate_and_reap(process); } } } @@ -203,7 +205,7 @@ fn cleanup_invalid_information(information: PROCESS_INFORMATION) { None }; if let Some(process) = process { - terminate_and_wait(&process); + let _ = terminate_and_reap(process); } } @@ -227,13 +229,258 @@ fn duplicate(handle: &OwnedHandle) -> io::Result { } } -fn terminate_and_wait(process: &OwnedHandle) { - let process = HANDLE(process.as_raw_handle()); - // SAFETY: the guard owns the process handle and is cleaning up a process which cannot be returned - // to the caller. Waiting after termination prevents a detached live process. - let _ = unsafe { TerminateProcess(process, 1) }; - // SAFETY: the process handle owner remains live until after this wait. - let _ = unsafe { WaitForSingleObject(process, INFINITE) }; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WaitOutcome { + Signaled, + Timeout, +} + +/// Sole owner of the exact handle selected for rollback cleanup. +/// +/// `process` is `Some` from construction until `Drop`; moving this value transfers that ownership +/// unchanged between the caller, reaper handoff cell, reaper thread, and quarantine. Only `Drop` +/// takes and closes the handle. +#[derive(Debug)] +struct ReapHandle { + process: Option, + #[cfg(test)] + released: Option>, +} + +impl ReapHandle { + fn new(process: OwnedHandle) -> Self { + Self { + process: Some(process), + #[cfg(test)] + released: None, + } + } + + #[cfg(test)] + fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { + Self { + process: Some(process), + released: Some(released), + } + } + + fn raw(&self) -> HANDLE { + HANDLE( + self.process + .as_ref() + .expect("a reap handle owns its exact process handle until release") + .as_raw_handle(), + ) + } +} + +impl Drop for ReapHandle { + fn drop(&mut self) { + #[cfg(test)] + let raw = self.raw().0 as usize; + // Dropping the OwnedHandle closes exactly the handle transferred into this owner. Do this before + // notifying tests so release observations cannot race the close. + drop(self.process.take()); + #[cfg(test)] + if let Some(released) = self.released.take() { + let _ = released.send(raw); + } + } +} + +trait CleanupOperations: Clone + Send + Sync + 'static { + fn terminate(&self, process: HANDLE) -> io::Result<()>; + fn wait(&self, process: HANDLE, timeout: u32) -> io::Result; + + /// Start the named reaper task. Returning an error means the task was not started. + fn spawn_reaper(&self, task: Box) -> io::Result<()>; + + fn quarantine(&self, process: ReapHandle); + fn take_quarantined(&self) -> Vec; +} + +#[derive(Clone, Copy, Debug)] +struct WindowsCleanupOperations; + +impl CleanupOperations for WindowsCleanupOperations { + fn terminate(&self, process: HANDLE) -> io::Result<()> { + // SAFETY: every production ReapHandle originates from CreateProcessW, DuplicateHandle with + // DUPLICATE_SAME_ACCESS, or OpenProcess with PROCESS_TERMINATE. Its OwnedHandle remains inside the + // ReapHandle for this call, so the exact handle stays open throughout the operation. + unsafe { TerminateProcess(process, 1) }.map_err(win32_io_error) + } + + fn wait(&self, process: HANDLE, timeout: u32) -> io::Result { + // SAFETY: every production ReapHandle originates from CreateProcessW, DuplicateHandle with + // DUPLICATE_SAME_ACCESS, or OpenProcess with PROCESS_SYNCHRONIZE. Its OwnedHandle remains inside the + // ReapHandle for this call. Callers pass either zero for a nonblocking probe or INFINITE on the + // dedicated reaper thread. + match unsafe { WaitForSingleObject(process, timeout) } { + WAIT_OBJECT_0 => Ok(WaitOutcome::Signaled), + WAIT_TIMEOUT => Ok(WaitOutcome::Timeout), + WAIT_FAILED => Err(io::Error::last_os_error()), + other => Err(io::Error::other(format!( + "unexpected ConPTY rollback wait result {}", + other.0 + ))), + } + } + + fn spawn_reaper(&self, task: Box) -> io::Result<()> { + std::thread::Builder::new() + .name("process-wrap-conpty-reaper".into()) + .spawn(task) + .map(drop) + } + + fn quarantine(&self, process: ReapHandle) { + quarantined_processes() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(process); + } + + fn take_quarantined(&self) -> Vec { + std::mem::take( + &mut *quarantined_processes() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } +} + +fn quarantined_processes() -> &'static Mutex> { + static QUARANTINED: OnceLock>> = OnceLock::new(); + QUARANTINED.get_or_init(|| Mutex::new(Vec::new())) +} + +#[derive(Debug)] +struct ReaperStartError { + error: io::Error, + process: ReapHandle, +} + +fn terminate_and_reap(process: OwnedHandle) -> io::Result<()> { + terminate_and_reap_with(ReapHandle::new(process), WindowsCleanupOperations) +} + +fn terminate_and_reap_with( + process: ReapHandle, + operations: O, +) -> io::Result<()> { + let retry_error = retry_quarantined(operations.clone()); + let termination_error = operations.terminate(process.raw()).err(); + + let current_error = match operations.wait(process.raw(), 0) { + Ok(WaitOutcome::Signaled) => None, + Ok(WaitOutcome::Timeout) => { + if let Some(error) = termination_error.as_ref() { + warn_cleanup( + error, + "failed to terminate an unsignaled ConPTY rollback process", + ); + } + let scheduling_error = defer_reap(process, operations.clone()).err(); + termination_error.or(scheduling_error) + } + Err(wait_error) => { + if let Some(error) = termination_error.as_ref() { + warn_cleanup(error, "failed to terminate a ConPTY rollback process"); + } + warn_cleanup(&wait_error, "failed to probe a ConPTY rollback process"); + let _ = defer_reap(process, operations.clone()); + Some(wait_error) + } + }; + + current_error.or(retry_error).map_or(Ok(()), Err) +} + +fn retry_quarantined(operations: O) -> Option { + let mut first_error = None; + for process in operations.take_quarantined() { + if let Err(failure) = start_reaper(process, operations.clone()) { + warn_cleanup( + &failure.error, + "failed to retry a quarantined ConPTY rollback process", + ); + if first_error.is_none() { + first_error = Some(failure.error); + } + operations.quarantine(failure.process); + } + } + first_error +} + +fn defer_reap(process: ReapHandle, operations: O) -> io::Result<()> { + match start_reaper(process, operations.clone()) { + Ok(()) => Ok(()), + Err(failure) => { + warn_cleanup( + &failure.error, + "failed to start a ConPTY rollback reaper; quarantining the exact process handle", + ); + operations.quarantine(failure.process); + Err(failure.error) + } + } +} + +fn start_reaper( + process: ReapHandle, + operations: O, +) -> Result<(), ReaperStartError> { + // The caller's Arc keeps the transfer cell and exact handle alive if thread creation fails. On + // success, the task takes the handle before waiting and the detached JoinHandle owns no process + // resource. + let transfer = Arc::new(Mutex::new(Some(process))); + let task_transfer = Arc::clone(&transfer); + let task_operations = operations.clone(); + let task = Box::new(move || { + let process = task_transfer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("a started ConPTY reaper receives the exact handle once"); + match task_operations.wait(process.raw(), INFINITE) { + Ok(WaitOutcome::Signaled) => drop(process), + Ok(WaitOutcome::Timeout) => { + let error = io::Error::other( + "an infinite ConPTY rollback reaper wait unexpectedly timed out", + ); + warn_cleanup( + &error, + "ConPTY rollback reaper could not establish process completion; quarantining the exact process handle", + ); + task_operations.quarantine(process); + } + Err(error) => { + warn_cleanup( + &error, + "ConPTY rollback reaper wait failed; quarantining the exact process handle", + ); + task_operations.quarantine(process); + } + } + }); + + if let Err(error) = operations.spawn_reaper(task) { + let process = transfer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("a failed ConPTY reaper start leaves the exact handle with its caller"); + return Err(ReaperStartError { error, process }); + } + Ok(()) +} + +fn warn_cleanup(error: &io::Error, action: &'static str) { + #[cfg(feature = "tracing")] + tracing::warn!(?error, action, "secondary ConPTY cleanup failure"); + #[cfg(not(feature = "tracing"))] + let _ = (error, action); } fn win32_io_error(error: windows::core::Error) -> io::Error { @@ -244,10 +491,206 @@ fn win32_io_error(error: windows::core::Error) -> io::Error { #[cfg(test)] mod tests { - use windows::Win32::System::{Console::HPCON, Threading::STARTF_USESTDHANDLES}; + use std::{ + collections::VecDeque, + os::windows::io::FromRawHandle, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread::ThreadId, + time::Duration, + }; + + use windows::{ + Win32::System::{ + Console::HPCON, + Threading::{CreateEventW, STARTF_USESTDHANDLES}, + }, + core::PCWSTR, + }; use super::*; + #[derive(Clone, Copy, Debug)] + enum WaitPlan { + Signaled, + Timeout, + Error(i32), + } + + impl WaitPlan { + fn result(self) -> io::Result { + match self { + Self::Signaled => Ok(WaitOutcome::Signaled), + Self::Timeout => Ok(WaitOutcome::Timeout), + Self::Error(code) => Err(io::Error::from_raw_os_error(code)), + } + } + } + + #[derive(Debug)] + struct WaitRecord { + handle: usize, + timeout: u32, + thread: ThreadId, + } + + #[derive(Debug, Default)] + struct ReaperGate { + signaled: bool, + waiters: usize, + } + + #[derive(Debug)] + struct TestState { + terminate_error: Option, + probes: Mutex>, + spawn_failures: AtomicUsize, + spawn_calls: AtomicUsize, + waits: Mutex>, + gate: Mutex, + gate_changed: Condvar, + quarantine: Mutex>, + } + + #[derive(Clone, Debug)] + struct TestOperations { + state: Arc, + } + + impl TestOperations { + fn new( + terminate_error: Option, + probes: impl IntoIterator, + spawn_failures: usize, + ) -> Self { + Self { + state: Arc::new(TestState { + terminate_error, + probes: Mutex::new(probes.into_iter().collect()), + spawn_failures: AtomicUsize::new(spawn_failures), + spawn_calls: AtomicUsize::new(0), + waits: Mutex::new(Vec::new()), + gate: Mutex::new(ReaperGate::default()), + gate_changed: Condvar::new(), + quarantine: Mutex::new(Vec::new()), + }), + } + } + + fn wait_for_reaper(&self) { + let gate = self.state.gate.lock().unwrap(); + let (gate, timeout) = self + .state + .gate_changed + .wait_timeout_while(gate, Duration::from_secs(2), |gate| gate.waiters == 0) + .unwrap(); + assert!(!timeout.timed_out(), "the injected reaper did not start"); + assert!(gate.waiters > 0); + } + + fn signal_reapers(&self) { + let mut gate = self.state.gate.lock().unwrap(); + gate.signaled = true; + self.state.gate_changed.notify_all(); + } + + fn quarantine_len(&self) -> usize { + self.state.quarantine.lock().unwrap().len() + } + + fn spawn_calls(&self) -> usize { + self.state.spawn_calls.load(Ordering::SeqCst) + } + } + + impl CleanupOperations for TestOperations { + fn terminate(&self, _process: HANDLE) -> io::Result<()> { + match self.state.terminate_error { + Some(code) => Err(io::Error::from_raw_os_error(code)), + None => Ok(()), + } + } + + fn wait(&self, process: HANDLE, timeout: u32) -> io::Result { + self.state.waits.lock().unwrap().push(WaitRecord { + handle: process.0 as usize, + timeout, + thread: std::thread::current().id(), + }); + if timeout == 0 { + return self + .state + .probes + .lock() + .unwrap() + .pop_front() + .expect("each cleanup has an injected zero-time probe") + .result(); + } + assert_eq!(timeout, INFINITE); + + let mut gate = self.state.gate.lock().unwrap(); + gate.waiters += 1; + self.state.gate_changed.notify_all(); + while !gate.signaled { + gate = self.state.gate_changed.wait(gate).unwrap(); + } + Ok(WaitOutcome::Signaled) + } + + fn spawn_reaper(&self, task: Box) -> io::Result<()> { + self.state.spawn_calls.fetch_add(1, Ordering::SeqCst); + let fail = self + .state + .spawn_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok(); + if fail { + return Err(io::Error::other("injected ConPTY reaper start failure")); + } + std::thread::Builder::new() + .name("test-process-wrap-conpty-reaper".into()) + .spawn(task) + .map(drop) + } + + fn quarantine(&self, process: ReapHandle) { + self.state.quarantine.lock().unwrap().push(process); + } + + fn take_quarantined(&self) -> Vec { + std::mem::take(&mut *self.state.quarantine.lock().unwrap()) + } + } + + fn tracked_handle() -> (ReapHandle, usize, mpsc::Receiver) { + // SAFETY: null security attributes and name request a new, unnamed event. On success the returned + // handle is uniquely owned by this test and is transferred exactly once to OwnedHandle. + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.unwrap(); + let raw = event.0 as usize; + // SAFETY: CreateEventW returned a new owned handle, transferred exactly once here. + let event = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let (released, observe_release) = mpsc::channel(); + (ReapHandle::tracked(event, released), raw, observe_release) + } + + fn assert_not_released(released: &mpsc::Receiver) { + assert_eq!( + released.try_recv(), + Err(mpsc::TryRecvError::Empty), + "the exact handle was released before its reaper completed" + ); + } + + fn assert_released(released: &mpsc::Receiver, raw: usize) { + assert_eq!(released.recv_timeout(Duration::from_secs(2)).unwrap(), raw); + } + #[test] fn requests_conpty_replacement_for_all_standard_handles() { let attributes = AttributeList::new(HPCON(42)).unwrap(); @@ -257,4 +700,106 @@ mod tests { assert_eq!(startup.StartupInfo.hStdOutput, HANDLE::default()); assert_eq!(startup.StartupInfo.hStdError, HANDLE::default()); } + + #[test] + fn unsignaled_termination_hands_the_exact_handle_to_an_off_thread_reaper() { + let operations = TestOperations::new(None, [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let (completed, observe_completion) = mpsc::channel(); + let caller_operations = operations.clone(); + let caller = std::thread::spawn(move || { + let caller = std::thread::current().id(); + let result = terminate_and_reap_with(process, caller_operations); + completed.send((caller, result)).unwrap(); + }); + + let completion = observe_completion.recv_timeout(Duration::from_secs(2)); + if completion.is_err() { + operations.signal_reapers(); + caller.join().unwrap(); + panic!("cleanup waited for eventual signaling on its caller thread"); + } + let (caller_id, result) = completion.unwrap(); + result.unwrap(); + caller.join().unwrap(); + operations.wait_for_reaper(); + assert_not_released(&released); + + let waits = operations.state.waits.lock().unwrap(); + assert_eq!(waits.len(), 2); + assert_eq!(waits[0].handle, raw); + assert_eq!(waits[0].timeout, 0); + assert_eq!(waits[0].thread, caller_id); + assert_eq!(waits[1].handle, raw); + assert_eq!(waits[1].timeout, INFINITE); + assert_ne!(waits[1].thread, caller_id); + drop(waits); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[test] + fn failed_termination_of_an_unsignaled_process_is_reported_and_reaped() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + + let error = terminate_and_reap_with(process, operations.clone()).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(5)); + operations.wait_for_reaper(); + assert_not_released(&released); + assert_eq!(operations.quarantine_len(), 0); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[test] + fn failed_termination_of_an_already_signaled_process_is_a_natural_exit_race() { + let operations = TestOperations::new(Some(5), [WaitPlan::Signaled], 0); + let (process, raw, released) = tracked_handle(); + + terminate_and_reap_with(process, operations.clone()).unwrap(); + assert_eq!(operations.spawn_calls(), 0); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&released, raw); + } + + #[test] + fn failed_zero_time_wait_is_reported_while_the_exact_handle_stays_owned() { + let operations = TestOperations::new(None, [WaitPlan::Error(6)], 0); + let (process, raw, released) = tracked_handle(); + + let error = terminate_and_reap_with(process, operations.clone()).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(6)); + operations.wait_for_reaper(); + assert_not_released(&released); + assert_eq!(operations.quarantine_len(), 0); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[test] + fn failed_reaper_start_quarantines_then_retries_the_exact_handle() { + let operations = TestOperations::new(None, [WaitPlan::Timeout, WaitPlan::Signaled], 1); + let (process, raw, released) = tracked_handle(); + + let error = terminate_and_reap_with(process, operations.clone()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + let (next_process, next_raw, next_released) = tracked_handle(); + terminate_and_reap_with(next_process, operations.clone()).unwrap(); + operations.wait_for_reaper(); + assert_eq!(operations.spawn_calls(), 2); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&next_released, next_raw); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + } } From 55df8e499e7790579adf70ab8a4d6812ed5f9ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 16:07:39 +1200 Subject: [PATCH 031/114] fix(pty): isolate ConPTY cleanup diagnostics Co-Authored-By: Claude Opus 5 (1M context) --- src/tokio/pty/windows/spawn.rs | 545 +++++++++++++++++++++++++++++++-- 1 file changed, 515 insertions(+), 30 deletions(-) diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index b00ef5a..3edb2fc 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -235,14 +235,138 @@ enum WaitOutcome { Timeout, } +#[cfg(feature = "tracing")] +#[derive(Clone)] +struct CleanupDiagnostic { + id: u64, + action: &'static str, + error: String, + kind: io::ErrorKind, + raw_os_error: Option, + dispatch: tracing::Dispatch, +} + +#[cfg(feature = "tracing")] +#[derive(Default)] +struct DiagnosticState { + next_id: u64, + pending: Vec, + dispatch: Option, +} + +#[derive(Clone, Default)] +struct PendingDiagnostics { + #[cfg(feature = "tracing")] + state: Arc>, +} + +impl PendingDiagnostics { + fn bind_current_dispatch(&self) { + #[cfg(feature = "tracing")] + { + let dispatch = tracing::dispatcher::get_default(Clone::clone); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.dispatch.get_or_insert(dispatch); + } + } + + fn push(&self, action: &'static str, error: &io::Error) { + #[cfg(feature = "tracing")] + { + let fallback_dispatch = tracing::dispatcher::get_default(Clone::clone); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dispatch = state.dispatch.get_or_insert(fallback_dispatch).clone(); + let id = state.next_id; + state.next_id = state.next_id.wrapping_add(1); + state.pending.push(CleanupDiagnostic { + id, + action, + error: error.to_string(), + kind: error.kind(), + raw_os_error: error.raw_os_error(), + dispatch, + }); + } + #[cfg(not(feature = "tracing"))] + let _ = (action, error); + } + + fn is_empty(&self) -> bool { + #[cfg(feature = "tracing")] + { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .is_empty() + } + #[cfg(not(feature = "tracing"))] + { + true + } + } + + fn emit(&self) { + #[cfg(feature = "tracing")] + { + let pending = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .clone(); + let mut emitted = Vec::with_capacity(pending.len()); + for diagnostic in pending { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&diagnostic.dispatch, || { + tracing::warn!( + error = %diagnostic.error, + error_kind = ?diagnostic.kind, + raw_os_error = ?diagnostic.raw_os_error, + action = diagnostic.action, + "secondary ConPTY cleanup failure" + ); + }); + })); + if result.is_ok() { + emitted.push(diagnostic.id); + } + } + if !emitted.is_empty() { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .retain(|diagnostic| !emitted.contains(&diagnostic.id)); + } + } + } + + #[cfg(all(test, feature = "tracing"))] + fn len(&self) -> usize { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .len() + } +} + /// Sole owner of the exact handle selected for rollback cleanup. /// /// `process` is `Some` from construction until `Drop`; moving this value transfers that ownership /// unchanged between the caller, reaper handoff cell, reaper thread, and quarantine. Only `Drop` -/// takes and closes the handle. -#[derive(Debug)] +/// takes and closes the handle. Pending diagnostics move with this owner or share their state only +/// with an off-caller emitter after the handle has reached a durable owner. struct ReapHandle { process: Option, + diagnostics: PendingDiagnostics, #[cfg(test)] released: Option>, } @@ -251,6 +375,7 @@ impl ReapHandle { fn new(process: OwnedHandle) -> Self { Self { process: Some(process), + diagnostics: PendingDiagnostics::default(), #[cfg(test)] released: None, } @@ -260,6 +385,7 @@ impl ReapHandle { fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { Self { process: Some(process), + diagnostics: PendingDiagnostics::default(), released: Some(released), } } @@ -272,6 +398,20 @@ impl ReapHandle { .as_raw_handle(), ) } + + fn add_diagnostic(&self, action: &'static str, error: &io::Error) { + self.diagnostics.push(action, error); + } + + fn pending_diagnostics(&self) -> PendingDiagnostics { + self.diagnostics.clone() + } + + fn release(self) -> PendingDiagnostics { + let diagnostics = self.pending_diagnostics(); + drop(self); + diagnostics + } } impl Drop for ReapHandle { @@ -295,6 +435,9 @@ trait CleanupOperations: Clone + Send + Sync + 'static { /// Start the named reaper task. Returning an error means the task was not started. fn spawn_reaper(&self, task: Box) -> io::Result<()>; + /// Start an off-caller diagnostic task. Returning an error means the task was not started. + fn spawn_diagnostic(&self, task: Box) -> io::Result<()>; + fn quarantine(&self, process: ReapHandle); fn take_quarantined(&self) -> Vec; } @@ -333,6 +476,13 @@ impl CleanupOperations for WindowsCleanupOperations { .map(drop) } + fn spawn_diagnostic(&self, task: Box) -> io::Result<()> { + std::thread::Builder::new() + .name("process-wrap-conpty-diagnostic".into()) + .spawn(task) + .map(drop) + } + fn quarantine(&self, process: ReapHandle) { quarantined_processes() .lock() @@ -354,7 +504,6 @@ fn quarantined_processes() -> &'static Mutex> { QUARANTINED.get_or_init(|| Mutex::new(Vec::new())) } -#[derive(Debug)] struct ReaperStartError { error: io::Error, process: ReapHandle, @@ -368,6 +517,7 @@ fn terminate_and_reap_with( process: ReapHandle, operations: O, ) -> io::Result<()> { + process.diagnostics.bind_current_dispatch(); let retry_error = retry_quarantined(operations.clone()); let termination_error = operations.terminate(process.raw()).err(); @@ -375,9 +525,9 @@ fn terminate_and_reap_with( Ok(WaitOutcome::Signaled) => None, Ok(WaitOutcome::Timeout) => { if let Some(error) = termination_error.as_ref() { - warn_cleanup( - error, + process.add_diagnostic( "failed to terminate an unsignaled ConPTY rollback process", + error, ); } let scheduling_error = defer_reap(process, operations.clone()).err(); @@ -385,9 +535,9 @@ fn terminate_and_reap_with( } Err(wait_error) => { if let Some(error) = termination_error.as_ref() { - warn_cleanup(error, "failed to terminate a ConPTY rollback process"); + process.add_diagnostic("failed to terminate a ConPTY rollback process", error); } - warn_cleanup(&wait_error, "failed to probe a ConPTY rollback process"); + process.add_diagnostic("failed to probe a ConPTY rollback process", &wait_error); let _ = defer_reap(process, operations.clone()); Some(wait_error) } @@ -400,14 +550,17 @@ fn retry_quarantined(operations: O) -> Option { let mut first_error = None; for process in operations.take_quarantined() { if let Err(failure) = start_reaper(process, operations.clone()) { - warn_cleanup( - &failure.error, + let ReaperStartError { error, process } = failure; + process.add_diagnostic( "failed to retry a quarantined ConPTY rollback process", + &error, ); + let diagnostics = process.pending_diagnostics(); + operations.quarantine(process); + schedule_diagnostics(diagnostics, operations.clone()); if first_error.is_none() { - first_error = Some(failure.error); + first_error = Some(error); } - operations.quarantine(failure.process); } } first_error @@ -417,16 +570,29 @@ fn defer_reap(process: ReapHandle, operations: O) -> io::R match start_reaper(process, operations.clone()) { Ok(()) => Ok(()), Err(failure) => { - warn_cleanup( - &failure.error, + let ReaperStartError { error, process } = failure; + process.add_diagnostic( "failed to start a ConPTY rollback reaper; quarantining the exact process handle", + &error, ); - operations.quarantine(failure.process); - Err(failure.error) + let diagnostics = process.pending_diagnostics(); + operations.quarantine(process); + schedule_diagnostics(diagnostics, operations); + Err(error) } } } +fn schedule_diagnostics(diagnostics: PendingDiagnostics, operations: O) { + if diagnostics.is_empty() { + return; + } + let task = Box::new(move || diagnostics.emit()); + // A failure here cannot be diagnosed recursively without risking the same thread-creation error. + // The diagnostics remain shared with the quarantined exact-handle owner for a later retry. + let _ = operations.spawn_diagnostic(task); +} + fn start_reaper( process: ReapHandle, operations: O, @@ -444,23 +610,27 @@ fn start_reaper( .take() .expect("a started ConPTY reaper receives the exact handle once"); match task_operations.wait(process.raw(), INFINITE) { - Ok(WaitOutcome::Signaled) => drop(process), + Ok(WaitOutcome::Signaled) => process.release().emit(), Ok(WaitOutcome::Timeout) => { let error = io::Error::other( "an infinite ConPTY rollback reaper wait unexpectedly timed out", ); - warn_cleanup( - &error, + process.add_diagnostic( "ConPTY rollback reaper could not establish process completion; quarantining the exact process handle", + &error, ); + let diagnostics = process.pending_diagnostics(); task_operations.quarantine(process); + diagnostics.emit(); } Err(error) => { - warn_cleanup( - &error, + process.add_diagnostic( "ConPTY rollback reaper wait failed; quarantining the exact process handle", + &error, ); + let diagnostics = process.pending_diagnostics(); task_operations.quarantine(process); + diagnostics.emit(); } } }); @@ -476,13 +646,6 @@ fn start_reaper( Ok(()) } -fn warn_cleanup(error: &io::Error, action: &'static str) { - #[cfg(feature = "tracing")] - tracing::warn!(?error, action, "secondary ConPTY cleanup failure"); - #[cfg(not(feature = "tracing"))] - let _ = (error, action); -} - fn win32_io_error(error: windows::core::Error) -> io::Error { WIN32_ERROR::from_error(&error) .and_then(|error| i32::try_from(error.0).ok()) @@ -503,6 +666,11 @@ mod tests { time::Duration, }; + #[cfg(feature = "tracing")] + use tracing::{ + Event, Metadata, Subscriber, + span::{Attributes, Id, Record}, + }; use windows::{ Win32::System::{ Console::HPCON, @@ -513,6 +681,118 @@ mod tests { use super::*; + #[cfg(feature = "tracing")] + #[derive(Clone, Copy, Debug)] + enum SubscriberBehavior { + Block, + Panic, + } + + #[cfg(feature = "tracing")] + #[derive(Debug, Default)] + struct SubscriberGate { + events: usize, + released: bool, + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct SubscriberState { + behavior: SubscriberBehavior, + gate: Mutex, + changed: Condvar, + } + + #[cfg(feature = "tracing")] + impl SubscriberState { + fn new(behavior: SubscriberBehavior) -> Arc { + Arc::new(Self { + behavior, + gate: Mutex::new(SubscriberGate::default()), + changed: Condvar::new(), + }) + } + + fn wait_for_event(&self) { + self.wait_for_events(1); + } + + fn wait_for_events(&self, count: usize) { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while self.event_count() < count { + assert!( + std::time::Instant::now() < deadline, + "cleanup diagnostic was not emitted" + ); + std::thread::sleep(Duration::from_millis(1)); + } + } + + fn event_count(&self) -> usize { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .events + } + + fn release(&self) { + let mut gate = self + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.released = true; + self.changed.notify_all(); + } + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct AdversarialSubscriber { + state: Arc, + } + + #[cfg(feature = "tracing")] + impl Subscriber for AdversarialSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) { + let mut gate = self + .state + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.events += 1; + self.state.changed.notify_all(); + match self.state.behavior { + SubscriberBehavior::Block => { + while !gate.released { + gate = self.state.changed.wait(gate).unwrap(); + } + } + SubscriberBehavior::Panic => panic!("injected cleanup diagnostic panic"), + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} + } + + #[cfg(feature = "tracing")] + fn diagnostic_dispatch(state: Arc) -> tracing::Dispatch { + tracing::Dispatch::new(AdversarialSubscriber { state }) + } + #[derive(Clone, Copy, Debug)] enum WaitPlan { Signaled, @@ -543,10 +823,10 @@ mod tests { waiters: usize, } - #[derive(Debug)] struct TestState { terminate_error: Option, probes: Mutex>, + reaper_results: Mutex>, spawn_failures: AtomicUsize, spawn_calls: AtomicUsize, waits: Mutex>, @@ -555,7 +835,7 @@ mod tests { quarantine: Mutex>, } - #[derive(Clone, Debug)] + #[derive(Clone)] struct TestOperations { state: Arc, } @@ -570,6 +850,7 @@ mod tests { state: Arc::new(TestState { terminate_error, probes: Mutex::new(probes.into_iter().collect()), + reaper_results: Mutex::new(VecDeque::new()), spawn_failures: AtomicUsize::new(spawn_failures), spawn_calls: AtomicUsize::new(0), waits: Mutex::new(Vec::new()), @@ -597,10 +878,26 @@ mod tests { self.state.gate_changed.notify_all(); } + #[cfg(feature = "tracing")] + fn set_reaper_results(&self, results: impl IntoIterator) { + self.state.reaper_results.lock().unwrap().extend(results); + } + fn quarantine_len(&self) -> usize { self.state.quarantine.lock().unwrap().len() } + #[cfg(feature = "tracing")] + fn quarantined_diagnostic_count(&self) -> usize { + self.state + .quarantine + .lock() + .unwrap() + .iter() + .map(|process| process.diagnostics.len()) + .sum() + } + fn spawn_calls(&self) -> usize { self.state.spawn_calls.load(Ordering::SeqCst) } @@ -638,7 +935,14 @@ mod tests { while !gate.signaled { gate = self.state.gate_changed.wait(gate).unwrap(); } - Ok(WaitOutcome::Signaled) + drop(gate); + self.state + .reaper_results + .lock() + .unwrap() + .pop_front() + .unwrap_or(WaitPlan::Signaled) + .result() } fn spawn_reaper(&self, task: Box) -> io::Result<()> { @@ -659,6 +963,13 @@ mod tests { .map(drop) } + fn spawn_diagnostic(&self, task: Box) -> io::Result<()> { + std::thread::Builder::new() + .name("test-process-wrap-conpty-diagnostic".into()) + .spawn(task) + .map(drop) + } + fn quarantine(&self, process: ReapHandle) { self.state.quarantine.lock().unwrap().push(process); } @@ -802,4 +1113,178 @@ mod tests { operations.signal_reapers(); assert_released(&released, raw); } + + #[cfg(feature = "tracing")] + #[test] + fn blocking_diagnostic_subscriber_cannot_block_cleanup_or_handle_release() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let subscriber = SubscriberState::new(SubscriberBehavior::Block); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (completed, observe_completion) = mpsc::channel(); + let caller_operations = operations.clone(); + let caller = std::thread::spawn(move || { + let result = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, caller_operations) + }); + completed.send(result).unwrap(); + }); + + let completion = observe_completion.recv_timeout(Duration::from_secs(2)); + if completion.is_err() { + subscriber.release(); + let _ = observe_completion.recv_timeout(Duration::from_secs(2)); + operations.wait_for_reaper(); + operations.signal_reapers(); + let _ = released.recv_timeout(Duration::from_secs(2)); + caller.join().unwrap(); + panic!("cleanup dispatched a blocking diagnostic on its caller thread"); + } + assert_eq!(completion.unwrap().unwrap_err().raw_os_error(), Some(5)); + caller.join().unwrap(); + operations.wait_for_reaper(); + assert_eq!(subscriber.event_count(), 0); + assert_not_released(&released); + + operations.signal_reapers(); + subscriber.wait_for_event(); + assert_released(&released, raw); + subscriber.release(); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostic_subscriber_cannot_replace_the_primary_error() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&dispatch, || { + let _ = terminate_and_reap_with(process, operations.clone()); + Err::<(), _>(io::Error::other("primary lifecycle error")) + }) + })); + let error = result + .expect("cleanup diagnostics must not unwind through the caller") + .unwrap_err(); + assert_eq!(error.to_string(), "primary lifecycle error"); + operations.wait_for_reaper(); + assert_not_released(&released); + + operations.signal_reapers(); + subscriber.wait_for_event(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostic_subscriber_cannot_replace_the_primary_panic() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&dispatch, || { + let _ = terminate_and_reap_with(process, operations.clone()); + panic!("primary lifecycle panic"); + }) + })) + .expect_err("the primary lifecycle panic must be resumed"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "primary lifecycle panic" + ); + operations.wait_for_reaper(); + assert_not_released(&released); + + operations.signal_reapers(); + subscriber.wait_for_event(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostics_leave_start_and_retry_failures_with_quarantined_handle() { + let operations = TestOperations::new( + None, + [WaitPlan::Timeout, WaitPlan::Signaled, WaitPlan::Signaled], + 2, + ); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + let error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap_err(); + assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); + subscriber.wait_for_events(1); + assert_eq!(operations.quarantine_len(), 1); + assert_eq!(operations.quarantined_diagnostic_count(), 1); + assert_not_released(&released); + + let (second, second_raw, second_released) = tracked_handle(); + let retry_error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(second, operations.clone()) + }) + .unwrap_err(); + assert_eq!( + retry_error.to_string(), + "injected ConPTY reaper start failure" + ); + subscriber.wait_for_events(3); + assert_eq!(operations.quarantine_len(), 1); + assert_eq!(operations.quarantined_diagnostic_count(), 2); + assert_released(&second_released, second_raw); + assert_not_released(&released); + + let (third, third_raw, third_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(third, operations.clone()) + }) + .unwrap(); + operations.wait_for_reaper(); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&third_released, third_raw); + assert_not_released(&released); + + operations.signal_reapers(); + subscriber.wait_for_events(5); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostic_preserves_a_handle_after_reaper_wait_failure() { + let operations = TestOperations::new(None, [WaitPlan::Timeout, WaitPlan::Signaled], 0); + operations.set_reaper_results([WaitPlan::Error(6), WaitPlan::Signaled]); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap(); + operations.wait_for_reaper(); + operations.signal_reapers(); + subscriber.wait_for_events(1); + assert_eq!(operations.quarantine_len(), 1); + assert_eq!(operations.quarantined_diagnostic_count(), 1); + assert_not_released(&released); + + let (next, next_raw, next_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(next, operations.clone()) + }) + .unwrap(); + subscriber.wait_for_events(2); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&next_released, next_raw); + assert_released(&released, raw); + } } From 150f5c534e78e076e5a28456fe425fc37a289491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 16:49:21 +1200 Subject: [PATCH 032/114] fix(pty): serialize ConPTY cleanup diagnostics Co-Authored-By: Claude Opus 5 (1M context) --- src/tokio/pty/windows/spawn.rs | 417 ++++++++++++++++++++++++++++----- 1 file changed, 364 insertions(+), 53 deletions(-) diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index 3edb2fc..b017ea8 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -249,9 +249,13 @@ struct CleanupDiagnostic { #[cfg(feature = "tracing")] #[derive(Default)] struct DiagnosticState { + // At most one of `emitting` and `queued` is true. `queued` reserves exactly one shared-state + // clone for the retry queue, including the short interval before that clone is inserted. next_id: u64, pending: Vec, dispatch: Option, + emitting: bool, + queued: bool, } #[derive(Clone, Default)] @@ -297,30 +301,72 @@ impl PendingDiagnostics { let _ = (action, error); } - fn is_empty(&self) -> bool { + fn try_begin_emit(&self, queued_retry: bool) -> bool { #[cfg(feature = "tracing")] { - self.state + let mut state = self + .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .is_empty() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if queued_retry { + if !state.queued { + return false; + } + debug_assert!(!state.emitting); + state.queued = false; + } else if state.queued { + return false; + } + if state.pending.is_empty() || state.emitting { + return false; + } + state.emitting = true; + true } #[cfg(not(feature = "tracing"))] { - true + let _ = queued_retry; + false } } - fn emit(&self) { + fn emission_start_failed(&self) -> bool { #[cfg(feature = "tracing")] { - let pending = self + let mut state = self .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .clone(); + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(state.emitting); + debug_assert!(!state.queued); + state.emitting = false; + if state.pending.is_empty() || state.queued { + false + } else { + state.queued = true; + true + } + } + #[cfg(not(feature = "tracing"))] + { + false + } + } + + /// Emit one snapshot. Returns true after atomically reserving a later retry for entries that + /// panicked or arrived while this emitter was active. + fn emit(&self) -> bool { + #[cfg(feature = "tracing")] + { + let pending = { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(state.emitting); + debug_assert!(!state.queued); + state.pending.clone() + }; let mut emitted = Vec::with_capacity(pending.len()); for diagnostic in pending { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -338,13 +384,28 @@ impl PendingDiagnostics { emitted.push(diagnostic.id); } } + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); if !emitted.is_empty() { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + state .pending .retain(|diagnostic| !emitted.contains(&diagnostic.id)); } + debug_assert!(state.emitting); + debug_assert!(!state.queued); + state.emitting = false; + if state.pending.is_empty() || state.queued { + false + } else { + state.queued = true; + true + } + } + #[cfg(not(feature = "tracing"))] + { + false } } @@ -362,8 +423,8 @@ impl PendingDiagnostics { /// /// `process` is `Some` from construction until `Drop`; moving this value transfers that ownership /// unchanged between the caller, reaper handoff cell, reaper thread, and quarantine. Only `Drop` -/// takes and closes the handle. Pending diagnostics move with this owner or share their state only -/// with an off-caller emitter after the handle has reached a durable owner. +/// takes and closes the handle. Pending diagnostics move with this owner or share their state with +/// an off-caller emitter and retry queue only after the handle has reached a durable owner. struct ReapHandle { process: Option, diagnostics: PendingDiagnostics, @@ -406,12 +467,6 @@ impl ReapHandle { fn pending_diagnostics(&self) -> PendingDiagnostics { self.diagnostics.clone() } - - fn release(self) -> PendingDiagnostics { - let diagnostics = self.pending_diagnostics(); - drop(self); - diagnostics - } } impl Drop for ReapHandle { @@ -438,6 +493,8 @@ trait CleanupOperations: Clone + Send + Sync + 'static { /// Start an off-caller diagnostic task. Returning an error means the task was not started. fn spawn_diagnostic(&self, task: Box) -> io::Result<()>; + fn queue_diagnostics(&self, diagnostics: PendingDiagnostics); + fn take_queued_diagnostics(&self) -> Vec; fn quarantine(&self, process: ReapHandle); fn take_quarantined(&self) -> Vec; } @@ -483,6 +540,21 @@ impl CleanupOperations for WindowsCleanupOperations { .map(drop) } + fn queue_diagnostics(&self, diagnostics: PendingDiagnostics) { + queued_diagnostics() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(diagnostics); + } + + fn take_queued_diagnostics(&self) -> Vec { + std::mem::take( + &mut *queued_diagnostics() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } + fn quarantine(&self, process: ReapHandle) { quarantined_processes() .lock() @@ -499,6 +571,11 @@ impl CleanupOperations for WindowsCleanupOperations { } } +fn queued_diagnostics() -> &'static Mutex> { + static QUEUED: OnceLock>> = OnceLock::new(); + QUEUED.get_or_init(|| Mutex::new(Vec::new())) +} + fn quarantined_processes() -> &'static Mutex> { static QUARANTINED: OnceLock>> = OnceLock::new(); QUARANTINED.get_or_init(|| Mutex::new(Vec::new())) @@ -518,6 +595,7 @@ fn terminate_and_reap_with( operations: O, ) -> io::Result<()> { process.diagnostics.bind_current_dispatch(); + retry_queued_diagnostics(operations.clone()); let retry_error = retry_quarantined(operations.clone()); let termination_error = operations.terminate(process.raw()).err(); @@ -549,17 +627,21 @@ fn terminate_and_reap_with( fn retry_quarantined(operations: O) -> Option { let mut first_error = None; for process in operations.take_quarantined() { - if let Err(failure) = start_reaper(process, operations.clone()) { - let ReaperStartError { error, process } = failure; - process.add_diagnostic( - "failed to retry a quarantined ConPTY rollback process", - &error, - ); - let diagnostics = process.pending_diagnostics(); - operations.quarantine(process); - schedule_diagnostics(diagnostics, operations.clone()); - if first_error.is_none() { - first_error = Some(error); + let diagnostics = process.pending_diagnostics(); + match start_reaper(process, operations.clone()) { + Ok(()) => schedule_diagnostics(diagnostics, operations.clone()), + Err(failure) => { + let ReaperStartError { error, process } = failure; + process.add_diagnostic( + "failed to retry a quarantined ConPTY rollback process", + &error, + ); + let diagnostics = process.pending_diagnostics(); + operations.quarantine(process); + schedule_diagnostics(diagnostics, operations.clone()); + if first_error.is_none() { + first_error = Some(error); + } } } } @@ -567,8 +649,12 @@ fn retry_quarantined(operations: O) -> Option { } fn defer_reap(process: ReapHandle, operations: O) -> io::Result<()> { + let diagnostics = process.pending_diagnostics(); match start_reaper(process, operations.clone()) { - Ok(()) => Ok(()), + Ok(()) => { + schedule_diagnostics(diagnostics, operations); + Ok(()) + } Err(failure) => { let ReaperStartError { error, process } = failure; process.add_diagnostic( @@ -583,14 +669,33 @@ fn defer_reap(process: ReapHandle, operations: O) -> io::R } } +fn retry_queued_diagnostics(operations: O) { + for diagnostics in operations.take_queued_diagnostics() { + if diagnostics.try_begin_emit(true) { + start_diagnostic_worker(diagnostics, operations.clone()); + } + } +} + fn schedule_diagnostics(diagnostics: PendingDiagnostics, operations: O) { - if diagnostics.is_empty() { - return; + if diagnostics.try_begin_emit(false) { + start_diagnostic_worker(diagnostics, operations); + } +} + +fn start_diagnostic_worker(diagnostics: PendingDiagnostics, operations: O) { + let task_diagnostics = diagnostics.clone(); + let task_operations = operations.clone(); + let task = Box::new(move || { + if task_diagnostics.emit() { + task_operations.queue_diagnostics(task_diagnostics); + } + }); + if operations.spawn_diagnostic(task).is_err() && diagnostics.emission_start_failed() { + // Diagnosing this recursively could repeat the same thread-creation failure. The state was + // atomically returned to queued/non-emitting and is retried by a later cleanup opportunity. + operations.queue_diagnostics(diagnostics); } - let task = Box::new(move || diagnostics.emit()); - // A failure here cannot be diagnosed recursively without risking the same thread-creation error. - // The diagnostics remain shared with the quarantined exact-handle owner for a later retry. - let _ = operations.spawn_diagnostic(task); } fn start_reaper( @@ -610,7 +715,7 @@ fn start_reaper( .take() .expect("a started ConPTY reaper receives the exact handle once"); match task_operations.wait(process.raw(), INFINITE) { - Ok(WaitOutcome::Signaled) => process.release().emit(), + Ok(WaitOutcome::Signaled) => drop(process), Ok(WaitOutcome::Timeout) => { let error = io::Error::other( "an infinite ConPTY rollback reaper wait unexpectedly timed out", @@ -621,7 +726,7 @@ fn start_reaper( ); let diagnostics = process.pending_diagnostics(); task_operations.quarantine(process); - diagnostics.emit(); + schedule_diagnostics(diagnostics, task_operations.clone()); } Err(error) => { process.add_diagnostic( @@ -630,7 +735,7 @@ fn start_reaper( ); let diagnostics = process.pending_diagnostics(); task_operations.quarantine(process); - diagnostics.emit(); + schedule_diagnostics(diagnostics, task_operations.clone()); } } }); @@ -686,6 +791,7 @@ mod tests { enum SubscriberBehavior { Block, Panic, + Record, } #[cfg(feature = "tracing")] @@ -718,14 +824,21 @@ mod tests { } fn wait_for_events(&self, count: usize) { + assert!( + self.observe_events(count), + "cleanup diagnostic was not emitted" + ); + } + + fn observe_events(&self, count: usize) -> bool { let deadline = std::time::Instant::now() + Duration::from_secs(2); while self.event_count() < count { - assert!( - std::time::Instant::now() < deadline, - "cleanup diagnostic was not emitted" - ); + if std::time::Instant::now() >= deadline { + return false; + } std::thread::sleep(Duration::from_millis(1)); } + true } fn event_count(&self) -> usize { @@ -780,6 +893,7 @@ mod tests { } } SubscriberBehavior::Panic => panic!("injected cleanup diagnostic panic"), + SubscriberBehavior::Record => {} } } @@ -829,9 +943,14 @@ mod tests { reaper_results: Mutex>, spawn_failures: AtomicUsize, spawn_calls: AtomicUsize, + diagnostic_spawn_failures: AtomicUsize, + diagnostic_spawn_calls: AtomicUsize, + active_diagnostic_threads: AtomicUsize, + max_active_diagnostic_threads: AtomicUsize, waits: Mutex>, gate: Mutex, gate_changed: Condvar, + queued_diagnostics: Mutex>, quarantine: Mutex>, } @@ -853,9 +972,14 @@ mod tests { reaper_results: Mutex::new(VecDeque::new()), spawn_failures: AtomicUsize::new(spawn_failures), spawn_calls: AtomicUsize::new(0), + diagnostic_spawn_failures: AtomicUsize::new(0), + diagnostic_spawn_calls: AtomicUsize::new(0), + active_diagnostic_threads: AtomicUsize::new(0), + max_active_diagnostic_threads: AtomicUsize::new(0), waits: Mutex::new(Vec::new()), gate: Mutex::new(ReaperGate::default()), gate_changed: Condvar::new(), + queued_diagnostics: Mutex::new(Vec::new()), quarantine: Mutex::new(Vec::new()), }), } @@ -887,6 +1011,11 @@ mod tests { self.state.quarantine.lock().unwrap().len() } + #[cfg(feature = "tracing")] + fn queued_diagnostics_len(&self) -> usize { + self.state.queued_diagnostics.lock().unwrap().len() + } + #[cfg(feature = "tracing")] fn quarantined_diagnostic_count(&self) -> usize { self.state @@ -901,6 +1030,42 @@ mod tests { fn spawn_calls(&self) -> usize { self.state.spawn_calls.load(Ordering::SeqCst) } + + #[cfg(feature = "tracing")] + fn set_diagnostic_spawn_failures(&self, failures: usize) { + self.state + .diagnostic_spawn_failures + .store(failures, Ordering::SeqCst); + } + + #[cfg(feature = "tracing")] + fn diagnostic_spawn_calls(&self) -> usize { + self.state.diagnostic_spawn_calls.load(Ordering::SeqCst) + } + + #[cfg(feature = "tracing")] + fn active_diagnostic_threads(&self) -> usize { + self.state.active_diagnostic_threads.load(Ordering::SeqCst) + } + + #[cfg(feature = "tracing")] + fn wait_for_no_diagnostic_threads(&self) { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while self.active_diagnostic_threads() != 0 { + assert!( + std::time::Instant::now() < deadline, + "diagnostic emitters did not finish" + ); + std::thread::sleep(Duration::from_millis(1)); + } + } + + #[cfg(feature = "tracing")] + fn max_active_diagnostic_threads(&self) -> usize { + self.state + .max_active_diagnostic_threads + .load(Ordering::SeqCst) + } } impl CleanupOperations for TestOperations { @@ -964,12 +1129,52 @@ mod tests { } fn spawn_diagnostic(&self, task: Box) -> io::Result<()> { + self.state + .diagnostic_spawn_calls + .fetch_add(1, Ordering::SeqCst); + let fail = self + .state + .diagnostic_spawn_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok(); + if fail { + return Err(io::Error::other( + "injected ConPTY diagnostic thread start failure", + )); + } + let state = Arc::clone(&self.state); std::thread::Builder::new() .name("test-process-wrap-conpty-diagnostic".into()) - .spawn(task) + .spawn(move || { + let active = state + .active_diagnostic_threads + .fetch_add(1, Ordering::SeqCst) + + 1; + state + .max_active_diagnostic_threads + .fetch_max(active, Ordering::SeqCst); + task(); + state + .active_diagnostic_threads + .fetch_sub(1, Ordering::SeqCst); + }) .map(drop) } + fn queue_diagnostics(&self, diagnostics: PendingDiagnostics) { + self.state + .queued_diagnostics + .lock() + .unwrap() + .push(diagnostics); + } + + fn take_queued_diagnostics(&self) -> Vec { + std::mem::take(&mut *self.state.queued_diagnostics.lock().unwrap()) + } + fn quarantine(&self, process: ReapHandle) { self.state.quarantine.lock().unwrap().push(process); } @@ -1116,7 +1321,7 @@ mod tests { #[cfg(feature = "tracing")] #[test] - fn blocking_diagnostic_subscriber_cannot_block_cleanup_or_handle_release() { + fn blocking_diagnostic_subscriber_runs_before_signal_without_blocking_cleanup_or_reaping() { let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); let (process, raw, released) = tracked_handle(); let subscriber = SubscriberState::new(SubscriberBehavior::Block); @@ -1143,13 +1348,110 @@ mod tests { assert_eq!(completion.unwrap().unwrap_err().raw_os_error(), Some(5)); caller.join().unwrap(); operations.wait_for_reaper(); - assert_eq!(subscriber.event_count(), 0); + subscriber.wait_for_event(); + assert_eq!(operations.diagnostic_spawn_calls(), 1); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_eq!(subscriber.event_count(), 1); assert_not_released(&released); operations.signal_reapers(); - subscriber.wait_for_event(); assert_released(&released, raw); subscriber.release(); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(subscriber.event_count(), 1); + } + + #[cfg(feature = "tracing")] + #[test] + fn repeated_start_failures_keep_one_blocked_diagnostic_emitter() { + let operations = TestOperations::new( + None, + [WaitPlan::Timeout, WaitPlan::Signaled, WaitPlan::Signaled], + 2, + ); + let subscriber = SubscriberState::new(SubscriberBehavior::Block); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap_err(); + subscriber.wait_for_event(); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + let (second, second_raw, second_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(second, operations.clone()) + }) + .unwrap_err(); + assert_released(&second_released, second_raw); + let spawn_calls_while_blocked = operations.diagnostic_spawn_calls(); + let max_active_while_blocked = operations.max_active_diagnostic_threads(); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + subscriber.release(); + operations.wait_for_no_diagnostic_threads(); + + let (third, third_raw, third_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(third, operations.clone()) + }) + .unwrap(); + operations.wait_for_reaper(); + subscriber.wait_for_events(2); + assert_released(&third_released, third_raw); + + operations.signal_reapers(); + assert_released(&released, raw); + assert_eq!(spawn_calls_while_blocked, 1); + assert_eq!(max_active_while_blocked, 1); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_eq!(subscriber.event_count(), 2); + } + + #[cfg(feature = "tracing")] + #[test] + fn diagnostic_start_failure_retries_once_while_reaper_remains_unsignaled() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout, WaitPlan::Signaled], 0); + operations.set_diagnostic_spawn_failures(1); + let subscriber = SubscriberState::new(SubscriberBehavior::Record); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + let error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap_err(); + assert_eq!(error.raw_os_error(), Some(5)); + operations.wait_for_reaper(); + assert_eq!(operations.diagnostic_spawn_calls(), 1); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(subscriber.event_count(), 0); + assert_not_released(&released); + + let (next, next_raw, next_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(next, operations.clone()) + }) + .unwrap(); + assert_released(&next_released, next_raw); + let emitted_before_signal = subscriber.observe_events(1); + operations.wait_for_no_diagnostic_threads(); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + assert!( + emitted_before_signal, + "a later cleanup did not retry the retained diagnostic" + ); + assert_eq!(operations.diagnostic_spawn_calls(), 2); + assert_eq!(operations.queued_diagnostics_len(), 0); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_eq!(subscriber.event_count(), 1); } #[cfg(feature = "tracing")] @@ -1223,6 +1525,8 @@ mod tests { .unwrap_err(); assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); subscriber.wait_for_events(1); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(operations.queued_diagnostics_len(), 1); assert_eq!(operations.quarantine_len(), 1); assert_eq!(operations.quarantined_diagnostic_count(), 1); assert_not_released(&released); @@ -1236,7 +1540,11 @@ mod tests { retry_error.to_string(), "injected ConPTY reaper start failure" ); - subscriber.wait_for_events(3); + subscriber.wait_for_events(2); + operations.wait_for_no_diagnostic_threads(); + let events_after_second_attempt = subscriber.event_count(); + assert!(events_after_second_attempt <= 3); + assert_eq!(operations.queued_diagnostics_len(), 1); assert_eq!(operations.quarantine_len(), 1); assert_eq!(operations.quarantined_diagnostic_count(), 2); assert_released(&second_released, second_raw); @@ -1248,12 +1556,13 @@ mod tests { }) .unwrap(); operations.wait_for_reaper(); + subscriber.wait_for_events(events_after_second_attempt + 2); + operations.wait_for_no_diagnostic_threads(); assert_eq!(operations.quarantine_len(), 0); assert_released(&third_released, third_raw); assert_not_released(&released); operations.signal_reapers(); - subscriber.wait_for_events(5); assert_released(&released, raw); } @@ -1273,6 +1582,8 @@ mod tests { operations.wait_for_reaper(); operations.signal_reapers(); subscriber.wait_for_events(1); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(operations.queued_diagnostics_len(), 1); assert_eq!(operations.quarantine_len(), 1); assert_eq!(operations.quarantined_diagnostic_count(), 1); assert_not_released(&released); From 983e0c943b4359796241a2f385093a6688298147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 17:13:04 +1200 Subject: [PATCH 033/114] fix(pty): preserve Windows executable entries Co-Authored-By: Claude Code --- src/tokio/pty/windows/program.rs | 117 +++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/src/tokio/pty/windows/program.rs b/src/tokio/pty/windows/program.rs index 201f67b..7fa0a6d 100644 --- a/src/tokio/pty/windows/program.rs +++ b/src/tokio/pty/windows/program.rs @@ -8,7 +8,13 @@ use std::{ path::{Component, Path, PathBuf}, }; -use windows::Win32::System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW}; +use windows::{ + Win32::{ + Storage::FileSystem::{GetFileAttributesW, INVALID_FILE_ATTRIBUTES}, + System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW}, + }, + core::PCWSTR, +}; use super::{ command::{WideCString, encode}, @@ -40,7 +46,7 @@ pub(super) fn resolve<'a>( return WideCString::from_os(program, "PTY program"); } let with_exe = append_exe(path); - if with_exe.exists() { + if entry_exists(&with_exe) { return WideCString::from_os(with_exe.as_os_str(), "PTY program"); } return WideCString::from_os(program, "PTY program"); @@ -105,7 +111,17 @@ fn candidate(mut directory: PathBuf, program: &OsStr, has_extension: bool) -> Op if !has_extension { directory = append_exe(&directory); } - directory.exists().then_some(directory) + entry_exists(&directory).then_some(directory) +} + +fn entry_exists(path: &Path) -> bool { + let Ok(encoded) = WideCString::from_os(path.as_os_str(), "PTY program candidate") else { + return false; + }; + // SAFETY: `WideCString::from_os` rejects interior NULs and appends a trailing + // NUL. `encoded.as_ptr()` points to that readable allocation, which remains + // live and unmodified while `GetFileAttributesW` reads it during this call. + unsafe { GetFileAttributesW(PCWSTR(encoded.as_ptr())) != INVALID_FILE_ATTRIBUTES } } fn append_exe(path: &Path) -> PathBuf { @@ -166,11 +182,19 @@ fn query_directory(mut query: impl FnMut(Option<&mut [u16]>) -> u32) -> io::Resu #[cfg(test)] mod tests { use std::{ - fs::File, - os::windows::ffi::{OsStrExt, OsStringExt}, + fs::{self, File}, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + fs::symlink_file, + }, + process::{Command, Stdio}, }; use tempfile::tempdir; + use windows::{ + Win32::Storage::FileSystem::{GetFileAttributesW, INVALID_FILE_ATTRIBUTES}, + core::PCWSTR, + }; use super::*; @@ -192,6 +216,47 @@ mod tests { path.encode_wide().chain([0]).collect() } + fn create_dangling_executable_entry(entry: &Path) { + let target = entry.with_file_name("missing-target"); + File::create(&target).unwrap(); + + if let Err(symlink_error) = symlink_file(&target, entry) { + fs::remove_file(&target).unwrap(); + fs::create_dir(&target).unwrap(); + let junction = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(entry) + .arg(&target) + .output() + .unwrap(); + assert!( + junction.status.success(), + "file symlink creation failed ({symlink_error}); junction creation failed (status {}; stdout: {}; stderr: {})", + junction.status, + String::from_utf8_lossy(&junction.stdout), + String::from_utf8_lossy(&junction.stderr), + ); + fs::remove_dir(&target).unwrap(); + } else { + fs::remove_file(&target).unwrap(); + } + + assert!( + fs::symlink_metadata(entry).is_ok(), + "dangling reparse entry was not created" + ); + assert!(!entry.exists(), "reparse target must be absent"); + + let encoded = units(entry.as_os_str()); + // SAFETY: `encoded` has an explicit trailing NUL; `encoded.as_ptr()` points + // to its readable allocation, which stays live and unmodified during the call. + let attributes = unsafe { GetFileAttributesW(PCWSTR(encoded.as_ptr())) }; + assert_ne!( + attributes, INVALID_FILE_ATTRIBUTES, + "GetFileAttributesW could not query the dangling reparse entry" + ); + } + #[test] fn preserves_explicit_executable_paths_and_wtf16() { let current = env::current_exe().unwrap(); @@ -247,6 +312,37 @@ mod tests { assert_eq!(resolved.as_units(), units(executable.as_os_str())); } + #[test] + fn stops_path_search_at_a_dangling_executable_entry() { + let directory = tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + fs::create_dir(&first).unwrap(); + fs::create_dir(&second).unwrap(); + + let dangling = first.join("tool.exe"); + create_dangling_executable_entry(&dangling); + fs::copy(env::current_exe().unwrap(), second.join("tool.exe")).unwrap(); + + let path = env::join_paths([first.as_os_str(), second.as_os_str()]).unwrap(); + let changes = [(OsString::from("PATH"), Some(path.clone()))]; + let resolved = resolve_program("tool", &changes).unwrap(); + assert_eq!(resolved.as_units(), units(dangling.as_os_str())); + + let spawn = Command::new("tool") + .arg("--help") + .env("PATH", path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut child) = spawn { + let _ = child.kill(); + let _ = child.wait(); + panic!("native Command continued to the second PATH entry"); + } + } + #[test] fn appends_exe_to_an_existing_direct_path() { let directory = tempdir().unwrap(); @@ -258,6 +354,17 @@ mod tests { assert_eq!(resolved.as_units(), units(executable.as_os_str())); } + #[test] + fn appends_exe_to_a_dangling_direct_path_entry() { + let directory = tempdir().unwrap(); + let requested = directory.path().join("direct"); + let dangling = directory.path().join("direct.exe"); + create_dangling_executable_entry(&dangling); + + let resolved = resolve_program(requested.as_os_str(), &[]).unwrap(); + assert_eq!(resolved.as_units(), units(dangling.as_os_str())); + } + #[test] fn rejects_batch_files_and_missing_bare_programs() { for program in ["script.bat", "SCRIPT.CMD"] { From 7e1c1df111312d565e3eae26acdc6b4bc4f857ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 20:00:07 +1200 Subject: [PATCH 034/114] fix(pty): detach pseudoconsole close workers Co-Authored-By: Claude Code --- src/tokio/pty/windows/console.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/tokio/pty/windows/console.rs b/src/tokio/pty/windows/console.rs index 2920dc2..98abb29 100644 --- a/src/tokio/pty/windows/console.rs +++ b/src/tokio/pty/windows/console.rs @@ -2,8 +2,7 @@ use std::{ io, - sync::{Arc, Mutex, OnceLock}, - thread::JoinHandle, + sync::{Arc, Mutex}, }; #[cfg(feature = "tracing")] @@ -163,19 +162,6 @@ pub(super) fn coordinate(size: PtySize) -> io::Result { } fn schedule_close(close: api::ClosePseudoConsole, handle: HPCON) { - static CLOSE_THREADS: OnceLock>>> = OnceLock::new(); - let threads = CLOSE_THREADS.get_or_init(|| Mutex::new(Vec::new())); - let mut threads = threads.lock().unwrap_or_else(|poison| poison.into_inner()); - let mut index = 0; - while index < threads.len() { - if threads[index].is_finished() { - let finished = threads.swap_remove(index); - let _ = finished.join(); - } else { - index += 1; - } - } - let close_thread = std::thread::Builder::new() .name("process-wrap-conpty-close".into()) .spawn(move || { @@ -183,7 +169,11 @@ fn schedule_close(close: api::ClosePseudoConsole, handle: HPCON) { unsafe { close(handle) }; }); match close_thread { - Ok(close_thread) => threads.push(close_thread), + Ok(close_thread) => { + // Dropping a JoinHandle detaches its thread. The worker exclusively owns the raw HPCON + // transferred into its closure and will close it without blocking this caller. + drop(close_thread); + } Err(error) => { // Calling ClosePseudoConsole here could indefinitely block a reactor or arbitrary dropping // thread on older Windows versions. Leaking is the only safe fallback after thread creation From 455268ca9ea7e7b232b33a6b4b2be396e5c9c369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 20:26:46 +1200 Subject: [PATCH 035/114] test(pty): skip unavailable ConPTY spawns Co-Authored-By: Claude Code --- tests/tokio_pty_windows.rs | 71 ++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index c22e719..7c48cc6 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -51,6 +51,19 @@ mod windows_thread; const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; const TIMEOUT: Duration = Duration::from_secs(10); +macro_rules! require_conpty { + () => { + match Pty::check_supported() { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::Unsupported => { + eprintln!("skipping ConPTY-dependent test: {error}"); + return Ok(()); + } + Err(error) => return Err(error), + } + }; +} + fn helper(mode: &str) -> io::Result { let mut command = Command::new(env::current_exe()?); command @@ -355,6 +368,7 @@ fn print_size(output: HANDLE) -> io::Result<()> { #[tokio::test] async fn spawns_with_three_console_standard_handles() -> io::Result<()> { + require_conpty!(); let mut command = helper("terminal")?; let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; let (input, mut output, _resize) = controller.into_parts(); @@ -372,6 +386,7 @@ async fn spawns_with_three_console_standard_handles() -> io::Result<()> { #[tokio::test] async fn exposes_terminal_handles_and_preserves_environment_cwd_and_merged_output() -> io::Result<()> { + require_conpty!(); let directory = tempfile::tempdir()?; let system_root = env::var_os("SystemRoot").expect("Windows must define SystemRoot"); let mut command = helper("terminal")?; @@ -417,6 +432,7 @@ async fn exposes_terminal_handles_and_preserves_environment_cwd_and_merged_outpu #[tokio::test] async fn passes_bidirectional_terminal_bytes() -> io::Result<()> { + require_conpty!(); let mut command = helper("io")?; let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; let (mut input, mut output, _resize) = controller.into_parts(); @@ -443,6 +459,7 @@ async fn passes_bidirectional_terminal_bytes() -> io::Result<()> { #[tokio::test] async fn reports_initial_size_and_resize() -> io::Result<()> { + require_conpty!(); let mut command = helper("size")?; let initial = PtySize::new(31, 97)?; let (mut child, controller) = spawn_with_terminal(&mut command, initial)?; @@ -457,6 +474,7 @@ async fn reports_initial_size_and_resize() -> io::Result<()> { #[tokio::test] async fn canceled_wait_kill_and_repeated_waits_remain_valid() -> io::Result<()> { + require_conpty!(); let mut command = helper("wait")?; let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; let (input, mut output, _resize) = controller.into_parts(); @@ -478,6 +496,7 @@ async fn canceled_wait_kill_and_repeated_waits_remain_valid() -> io::Result<()> #[tokio::test] async fn shutting_down_input_releases_its_owner_without_early_hangup() -> io::Result<()> { + require_conpty!(); let mut command = helper("wait")?; let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; let (mut input, mut output, resize) = controller.into_parts(); @@ -501,6 +520,7 @@ async fn shutting_down_input_releases_its_owner_without_early_hangup() -> io::Re #[tokio::test] async fn dropping_output_keeps_the_terminal_live_while_input_exists() -> io::Result<()> { + require_conpty!(); let mut command = helper("wait")?; let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; let (input, mut output, resize) = controller.into_parts(); @@ -519,6 +539,7 @@ async fn dropping_output_keeps_the_terminal_live_while_input_exists() -> io::Res #[tokio::test] async fn direct_child_wait_is_independent_from_descendant_output_eof() -> io::Result<()> { + require_conpty!(); let directory = tempfile::tempdir()?; let release = directory.path().join("release-descendant"); let _release_on_drop = ReleaseOnDrop(release.clone()); @@ -543,6 +564,7 @@ async fn direct_child_wait_is_independent_from_descendant_output_eof() -> io::Re #[tokio::test] async fn failed_spawn_leaves_the_tracked_command_reusable() -> io::Result<()> { + require_conpty!(); let directory = tempfile::tempdir()?; let executable = directory.path().join("retry-helper.exe"); let mut command = Command::new(&executable); @@ -577,6 +599,7 @@ async fn failed_spawn_leaves_the_tracked_command_reusable() -> io::Result<()> { #[tokio::test] async fn accepts_ordered_raw_argument_fragments() -> io::Result<()> { + require_conpty!(); let mut command = Command::new(env::current_exe()?); command .arg("--exact") @@ -603,6 +626,7 @@ async fn accepts_ordered_raw_argument_fragments() -> io::Result<()> { #[cfg(feature = "creation-flags")] #[tokio::test] async fn creation_flags_compose_without_a_job_object() -> io::Result<()> { + require_conpty!(); use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; let mut command = helper("terminal")?; @@ -627,6 +651,7 @@ async fn creation_flags_compose_without_a_job_object() -> io::Result<()> { #[cfg(feature = "job-object")] #[tokio::test] async fn job_object_resumes_its_temporarily_suspended_primary_thread() -> io::Result<()> { + require_conpty!(); let mut command = helper("terminal")?; command.wrap(JobObject); let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; @@ -649,6 +674,7 @@ async fn job_object_resumes_its_temporarily_suspended_primary_thread() -> io::Re #[cfg(all(feature = "creation-flags", feature = "job-object"))] #[tokio::test] async fn job_object_composes_with_creation_flags_in_both_orders() -> io::Result<()> { + require_conpty!(); use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; for reverse in [false, true] { @@ -692,6 +718,7 @@ async fn job_object_composes_with_creation_flags_in_both_orders() -> io::Result< #[cfg(all(feature = "creation-flags", feature = "job-object"))] #[tokio::test] async fn job_object_preserves_explicit_suspension() -> io::Result<()> { + require_conpty!(); use windows::Win32::System::Threading::CREATE_SUSPENDED; let mut command = helper("terminal")?; @@ -750,6 +777,7 @@ async fn assert_killed_on_drop(mut command: Command) -> io::Result<()> { #[cfg(feature = "kill-on-drop")] #[tokio::test] async fn kill_on_drop_terminates_a_conpty_child() -> io::Result<()> { + require_conpty!(); let mut command = helper("wait")?; command.wrap(KillOnDrop); assert_killed_on_drop(command).await @@ -758,6 +786,7 @@ async fn kill_on_drop_terminates_a_conpty_child() -> io::Result<()> { #[cfg(all(feature = "job-object", feature = "kill-on-drop"))] #[tokio::test] async fn job_object_composes_with_kill_on_drop_in_both_orders() -> io::Result<()> { + require_conpty!(); for reverse in [false, true] { for pty_first in [false, true] { let directory = tempfile::tempdir()?; @@ -923,6 +952,7 @@ impl CommandWrapper for FailLifecycleOnce { #[tokio::test] async fn failed_windows_lifecycle_reaps_the_child_and_remains_reusable() -> io::Result<()> { + require_conpty!(); for stage in [ LifecycleStage::PostSpawn, LifecycleStage::WrapChild, @@ -1013,25 +1043,40 @@ impl CommandWrapper for MakeAttemptNative { } #[test] -fn rejects_opaque_base_and_attempt_state() { +fn rejects_opaque_base_and_attempt_state() -> io::Result<()> { + let supported = match Pty::check_supported() { + Ok(()) => true, + Err(error) if error.kind() == io::ErrorKind::Unsupported => false, + Err(error) => return Err(error), + }; + let native = tokio::process::Command::new("ignored"); let mut command = Command::from(native); command.wrap(Pty::default()); let error = command.spawn().unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert_eq!( - error.to_string(), - "a spawn provider cannot use a native-only command" - ); + if supported { + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only command" + ); + } else { + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } let mut command = Command::new("ignored"); command.wrap(Pty::default()).wrap(MakeAttemptNative); let error = command.spawn().unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert_eq!( - error.to_string(), - "a spawn provider cannot use a native-only spawn attempt" - ); + if supported { + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only spawn attempt" + ); + } else { + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + Ok(()) } #[test] @@ -1051,6 +1096,7 @@ fn explicit_spawners_cannot_bypass_pty() { #[tokio::test] async fn duplicate_pty_registration_uses_the_later_size() -> io::Result<()> { + require_conpty!(); let mut command = helper("size")?; command .wrap(Pty::new(PtySize::new(31, 97)?)) @@ -1067,6 +1113,7 @@ async fn duplicate_pty_registration_uses_the_later_size() -> io::Result<()> { #[tokio::test] async fn provider_and_controller_are_reusable() -> io::Result<()> { + require_conpty!(); let mut command = helper("terminal")?; command.wrap(Pty::default()); @@ -1149,6 +1196,7 @@ impl CommandWrapper for ObserveControllerLifecycle { #[tokio::test] async fn controller_commits_after_hooks_and_traverses_outer_wrappers() -> io::Result<()> { + require_conpty!(); let post_spawn = Arc::new(AtomicBool::new(false)); let wrap_child = Arc::new(AtomicBool::new(false)); let mut command = helper("terminal")?; @@ -1186,6 +1234,7 @@ impl CommandWrapper for PortableWrapper { #[tokio::test] async fn portable_custom_wrappers_compose_with_conpty() -> io::Result<()> { + require_conpty!(); let called = Arc::new(AtomicBool::new(false)); let mut command = helper("terminal")?; command From a3bd63662b65ecaf5c8c098866ff18c24af3fcac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 20:55:59 +1200 Subject: [PATCH 036/114] test(pty): bound Windows process cleanup Co-Authored-By: Claude Code --- tests/tokio_pty_windows.rs | 70 ++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 7c48cc6..861b9da 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -3,14 +3,19 @@ use std::{ env, io::{self, Read, Write}, - os::windows::process::CommandExt, + os::windows::{ + io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + process::CommandExt, + }, panic::{AssertUnwindSafe, catch_unwind}, path::{Path, PathBuf}, process::{ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, + mpsc, }, + thread, time::{Duration, Instant}, }; @@ -37,11 +42,12 @@ use windows::Win32::{ GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, }, Threading::{ - DETACHED_PROCESS, INFINITE, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, - TerminateProcess, WaitForSingleObject, + CreateEventW, DETACHED_PROCESS, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, + SetEvent, TerminateProcess, WaitForSingleObject, }, }, }; +use windows::core::PCWSTR; #[cfg(all(feature = "creation-flags", feature = "job-object"))] #[allow(dead_code)] @@ -50,6 +56,7 @@ mod windows_thread; const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; const TIMEOUT: Duration = Duration::from_secs(10); +const DROP_CLEANUP_TIMEOUT_MS: u32 = 1_000; macro_rules! require_conpty { () => { @@ -146,8 +153,10 @@ impl Drop for ReleaseOnDrop { #[derive(Debug)] struct ProcessExitGuard(Option); -// SAFETY: the guard uniquely owns a process-wide Windows handle. Moving it to another thread -// transfers that ownership without exposing Rust memory or retaining an alias which could close it. +// SAFETY: the normal constructor installs a uniquely owned process handle with no thread affinity. +// Moving the guard transfers sole close authority without exposing Rust memory. The drop regression +// constructs its event-backed guard only after the independently owned duplicate reaches its worker, +// so that controlled event handle does not rely on this Send implementation. unsafe impl Send for ProcessExitGuard {} impl ProcessExitGuard { @@ -186,17 +195,58 @@ impl ProcessExitGuard { impl Drop for ProcessExitGuard { fn drop(&mut self) { if let Some(handle) = self.0.take() { - // SAFETY: taking the handle removes the guard's only owner. It remains live until the final - // CloseHandle below, and all three operations use it only as a process handle. + // SAFETY: handle remains live through the close below. In normal use it is the uniquely owned + // process handle from OpenProcess. The controlled regression instead supplies a live event + // handle, which TerminateProcess rejects without consuming or closing it. unsafe { TerminateProcess(handle, 1) }.ok(); - // SAFETY: the uniquely owned process handle remains live until the following close. - unsafe { WaitForSingleObject(handle, INFINITE) }; - // SAFETY: this is the sole remaining owner and no subsequent operation uses the handle. + // SAFETY: process handles and the regression's event handle are waitable. This guard has sole + // close authority for this handle value, which remains live throughout the bounded wait. + unsafe { WaitForSingleObject(handle, DROP_CLEANUP_TIMEOUT_MS) }; + // SAFETY: taking the handle removed the guard's only close owner. The wait has returned, and no + // subsequent operation uses this handle value, so this closes it exactly once. unsafe { CloseHandle(handle) }.ok(); } } } +#[test] +fn process_exit_guard_drop_is_bounded_when_termination_fails() { + // SAFETY: null security attributes and name request a new, unnamed manual-reset event. The + // successful call returns one owned handle, transferred exactly once into controller. + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.unwrap(); + // SAFETY: CreateEventW returned a live, uniquely owned handle, whose ownership is transferred + // exactly once here. + let controller = unsafe { OwnedHandle::from_raw_handle(event.0) }; + // try_clone creates a separately owned handle to the same waitable event object. The controller + // remains independent so the old infinite drop can always be released before the worker joins. + let wait_handle = controller.try_clone().unwrap(); + let (started, observe_start) = mpsc::channel(); + let (completed, observe_completion) = mpsc::channel(); + let worker = thread::spawn(move || { + // This controlled regression deliberately transfers an event handle, not a process handle, + // into the guard. The event is waitable, TerminateProcess rejects it, and into_raw_handle + // transfers the duplicate's sole close authority to the guard. + let guard = ProcessExitGuard(Some(HANDLE(wait_handle.into_raw_handle()))); + started.send(()).unwrap(); + drop(guard); + completed.send(()).unwrap(); + }); + observe_start.recv().unwrap(); + + let watchdog = Duration::from_millis(u64::from(DROP_CLEANUP_TIMEOUT_MS) * 5); + let completion = observe_completion.recv_timeout(watchdog); + if completion.is_err() { + // SAFETY: controller still owns the live event handle with event-modification access. Signaling + // changes the event state without transferring or closing the controller handle. + unsafe { SetEvent(HANDLE(controller.as_raw_handle())) }.unwrap(); + } + worker.join().unwrap(); + assert!( + completion.is_ok(), + "ProcessExitGuard::drop exceeded its {watchdog:?} watchdog" + ); +} + async fn wait_for_process_exit(guard: ProcessExitGuard) -> io::Result<()> { let deadline = Instant::now() + TIMEOUT; loop { From f3ba5448d26ebd4f0cb409fc0c0745322be54ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 21:59:58 +1200 Subject: [PATCH 037/114] test(pty): scan terminal markers incrementally Co-Authored-By: Claude Code --- tests/tokio_pty_windows.rs | 126 ++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 861b9da..47cb097 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -263,11 +263,134 @@ async fn wait_for_process_exit(guard: ProcessExitGuard) -> io::Result<()> { } } +#[derive(Debug, PartialEq, Eq)] +struct MarkerSearch { + marker_start: Option, + first_candidate: usize, + candidates_examined: usize, +} + +fn scan_marker(bytes: &[u8], needle: &[u8], new_bytes_start: usize) -> MarkerSearch { + assert!( + !needle.is_empty(), + "read_through requires a non-empty marker" + ); + + let first_candidate = new_bytes_start.saturating_sub(needle.len() - 1); + let mut candidates_examined = 0; + for (offset, window) in bytes[first_candidate..].windows(needle.len()).enumerate() { + candidates_examined += 1; + if window == needle { + return MarkerSearch { + marker_start: Some(first_candidate + offset), + first_candidate, + candidates_examined, + }; + } + } + + MarkerSearch { + marker_start: None, + first_candidate, + candidates_examined, + } +} + +#[test] +fn marker_scan_finds_marker_in_first_chunk() { + assert_eq!( + scan_marker(b"prefix READY suffix", b"READY", 0), + MarkerSearch { + marker_start: Some(7), + first_candidate: 0, + candidates_examined: 8, + } + ); +} + +#[test] +fn marker_scan_detects_marker_across_chunk_boundary() { + let mut bytes = b"prefix RE".to_vec(); + let new_bytes_start = bytes.len(); + bytes.extend_from_slice(b"ADY suffix"); + + assert_eq!( + scan_marker(&bytes, b"READY", new_bytes_start), + MarkerSearch { + marker_start: Some(7), + first_candidate: 5, + candidates_examined: 3, + } + ); +} + +#[test] +fn marker_scan_skips_absent_chunks_before_finding_marker() { + let chunks: &[&[u8]] = &[b"prefix ", b"without ", b"marker ", b"REA", b"DY tail"]; + let mut bytes = Vec::new(); + let scans = chunks + .iter() + .map(|chunk| { + let new_bytes_start = bytes.len(); + bytes.extend_from_slice(chunk); + scan_marker(&bytes, b"READY", new_bytes_start) + }) + .collect::>(); + + assert_eq!(bytes, b"prefix without marker READY tail"); + assert!(scans[..4].iter().all(|scan| scan.marker_start.is_none())); + assert_eq!( + scans + .iter() + .map(|scan| scan.first_candidate) + .collect::>(), + [0, 3, 11, 18, 21] + ); + assert_eq!(scans[4].marker_start, Some(22)); + assert_eq!(scans[4].candidates_examined, 2); +} + +#[test] +fn marker_scan_does_not_rescan_a_large_examined_prefix() { + let needle = b"READY"; + let mut bytes = vec![b'x'; 1024 * 1024]; + let first_scan = scan_marker(&bytes, needle, 0); + let new_bytes_start = bytes.len(); + bytes.extend_from_slice(needle); + let second_scan = scan_marker(&bytes, needle, new_bytes_start); + + assert_eq!(first_scan.marker_start, None); + assert_eq!(first_scan.first_candidate, 0); + assert_eq!( + first_scan.candidates_examined, + new_bytes_start - needle.len() + 1 + ); + assert_eq!(second_scan.marker_start, Some(new_bytes_start)); + assert_eq!( + second_scan.first_candidate, + new_bytes_start - (needle.len() - 1) + ); + assert_eq!(second_scan.candidates_examined, needle.len()); + assert_eq!( + first_scan.candidates_examined + second_scan.candidates_examined, + new_bytes_start + 1 + ); +} + async fn read_through(output: &mut PtyOutput, needle: &[u8]) -> io::Result> { timeout(TIMEOUT, async { let mut bytes = Vec::new(); let mut buffer = [0; 1024]; - while !bytes.windows(needle.len()).any(|window| window == needle) { + let mut new_bytes_start = 0; + loop { + if scan_marker(&bytes, needle, new_bytes_start) + .marker_start + .is_some() + { + return Ok(bytes); + } + + new_bytes_start = bytes.len(); let read = output.read(&mut buffer).await?; if read == 0 { return Err(io::Error::new( @@ -277,7 +400,6 @@ async fn read_through(output: &mut PtyOutput, needle: &[u8]) -> io::Result Date: Sat, 26 Sep 2026 23:38:13 +1200 Subject: [PATCH 038/114] test(provider): cover transaction commit panics Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index d23bbd9..9524b3a 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -841,6 +841,28 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[test] + fn commit_panic_rolls_back_preserves_payload_and_allows_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.fail_once(Point::Commit, Failure::Panic("commit failed")); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure( + &mut command, + Failure::Panic("commit failed"), + "commit failed", + ); + let mut expected = successful_events("provider"); + expected.push(Event::Rollback); + assert_eq!(shared.events(), expected); + + shared.clear_events(); + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + #[test] fn successful_provider_can_be_reused() { let runtime = runtime(); From fd6cdbbf66c3ff8951b1d62f71496959f94b46f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sat, 26 Sep 2026 23:59:22 +1200 Subject: [PATCH 039/114] fix(windows): retain provider rollback through finalization Co-Authored-By: Claude Code --- src/command.rs | 19 +- src/generic_wrap.rs | 27 +- src/std/core.rs | 51 ++- src/tokio/core.rs | 51 ++- src/tokio/pty/windows/spawn.rs | 176 +++++++++- src/windows.rs | 11 +- tests/spawn_provider.rs | 300 ++++++++++++++++++ .../std_windows/creation_flags_job_object.rs | 148 +++++++++ 8 files changed, 739 insertions(+), 44 deletions(-) diff --git a/src/command.rs b/src/command.rs index 46fcc72..09e4525 100644 --- a/src/command.rs +++ b/src/command.rs @@ -536,12 +536,19 @@ impl fmt::Debug for CommandState { /// transaction must own its cleanup resources independently of the child wrapper chain, because a /// failing child wrapper may already have consumed or dropped that chain. /// -/// Process-wrap calls [`commit`](SpawnTransaction::commit) only after every public post-spawn and -/// child-wrapping hook succeeds. `commit` must disarm rollback resources on success. If it returns an -/// error or panics, the transaction must remain rollbackable; process-wrap then makes one best-effort -/// [`rollback`](SpawnTransaction::rollback) call. A rollback error or panic is suppressed so the -/// original error or panic is preserved. After a successful commit, process-wrap drops the transaction -/// and does not roll it back if a later internal child-finalization phase fails. +/// On Windows, process-wrap calls [`commit`](SpawnTransaction::commit) only after every public +/// post-spawn and child-wrapping hook and the complete pre-commit child phase succeed. That phase +/// runs ordinary child finalizers, disarms ordinary child cleanup, rejects multiple JobObject cleanup +/// owners, and disarms every non-owner while leaving the sole owner armed. `commit` must disarm +/// provider rollback resources on success. If pre-commit work or `commit` returns an error or unwinds, +/// the transaction must remain rollbackable; process-wrap then makes one best-effort +/// [`rollback`](SpawnTransaction::rollback) call. A rollback error or unwinding panic is suppressed so +/// the original failure is preserved. After successful commit, process-wrap drops the transaction and +/// runs only the sole JobObject owner hook, whose contract requires failure-atomic disarming. +/// +/// On non-Windows targets there is no hidden child-finalization phase, so commit follows the public +/// hooks directly. These panic guarantees require unwinding; `panic=abort` terminates the process +/// before rollback or payload preservation can run. /// /// Until a provider returns its `ProviderProduct`, cleanup for errors or panics in its own `spawn` /// implementation remains the provider's responsibility. diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index e0d798f..da0602e 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -60,7 +60,9 @@ macro_rules! Wrap { /// The child must implement the complete contract for this frontend's `ChildWrapper`, including /// any platform capabilities required by registered wrappers. The transaction must be fresh, /// armed, independently owned from the child chain, and able to undo this specific spawn until - /// process-wrap commits it. + /// process-wrap commits it. On Windows, that includes every ordinary child-finalization and + /// cleanup-disarm hook plus JobObject owner validation and non-owner disarming. Only the sole + /// JobObject owner hook, if present, runs after commit. #[derive(Debug)] pub struct ProviderProduct { child: Box, @@ -72,7 +74,8 @@ macro_rules! Wrap { /// /// Construct this only after the child has been created successfully. The transaction must own /// everything needed to terminate and reap that child and release provider resources if a later - /// hook, child wrapper, or transaction commit fails or panics. + /// hook, pre-commit child-finalization step, or transaction commit fails or unwinds. These panic + /// guarantees do not apply to `panic=abort`. pub fn new( child: Box, transaction: Box, @@ -97,9 +100,10 @@ macro_rules! Wrap { /// Process-wrap invokes provider callbacks in this order: `check_available`, native-only base /// rejection, `validate_command`, every `pre_spawn` hook in registration order, native-only /// attempt rejection, `validate_attempt`, and `spawn`. After `spawn` returns a product, every - /// `post_spawn` and child-wrapping hook runs in registration order before process-wrap commits the - /// product's transaction. `spawn_with` and `spawn_with_child` reject a registered provider instead - /// of bypassing it. + /// `post_spawn` and child-wrapping hook runs in registration order. On Windows, process-wrap then + /// completes the pre-commit child phase while provider rollback remains armed, commits and drops + /// the transaction, and finally disarms the sole JobObject cleanup owner. `spawn_with` and + /// `spawn_with_child` reject a registered provider instead of bypassing it. pub trait SpawnProvider: ::std::fmt::Debug + Send + Sync + 'static { /// Check whether this provider is available on the current platform and runtime. /// @@ -425,7 +429,8 @@ macro_rules! Wrap { })(); #[cfg(windows)] let result = result.and_then(|mut child| { - child.finalize_spawn()?; + let final_owner = child.finalize_spawn_before_commit()?; + child.finalize_spawn_final_owner(final_owner)?; if let Some(cleanup) = cleanup.as_mut() { cleanup.disarm(); } @@ -453,9 +458,11 @@ macro_rules! Wrap { let prepared = self.run_prepare_child(attempt, child.as_mut())?; self.run_post_spawn(attempt, child.as_mut())?; #[cfg(windows)] - let child = self.run_wrap_child(child, prepared)?; + let mut child = self.run_wrap_child(child, prepared)?; #[cfg(not(windows))] let child = self.run_wrap_child(child)?; + #[cfg(windows)] + let final_owner = child.finalize_spawn_before_commit()?; transaction .as_mut() .expect("the provider transaction remains armed until commit") @@ -466,11 +473,7 @@ macro_rules! Wrap { .expect("a committed provider transaction is still present"), ); #[cfg(windows)] - let child = { - let mut child = child; - child.finalize_spawn()?; - child - }; + child.finalize_spawn_final_owner(final_owner)?; Ok(child) })); diff --git a/src/std/core.rs b/src/std/core.rs index 6b1d11d..193e7f1 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -136,7 +136,9 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Report whether this layer owns the JobObject rollback guard. /// - /// Process-wrap defers the sole owning layer until every other fallible child-layer hook succeeds. + /// Process-wrap counts owners and disarms every non-owner before a provider transaction commits. + /// More than one owner fails the lifecycle before commit. The sole owner stays armed until commit + /// succeeds and is then disarmed as the only post-commit child-layer operation. #[doc(hidden)] #[cfg(windows)] fn owns_job_object_cleanup_layer(&self) -> bool { @@ -145,8 +147,10 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Disarm JobObject-phase cleanup state owned by this child layer. /// - /// Non-owning hooks run first. The sole layer identified by - /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs last. + /// Non-owning hooks run before provider commit. The sole layer identified by + /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs after commit and must be failure-atomic: + /// returning an error or unwinding must leave kill-on-close protection armed. If disarming uses a + /// native state transition, no caller-controlled callback may run after that transition succeeds. #[doc(hidden)] #[cfg(windows)] fn disarm_job_object_layer(&mut self) -> Result<()> { @@ -342,6 +346,20 @@ fn same_child(left: &dyn ChildWrapper, right: &dyn ChildWrapper) -> bool { std::ptr::addr_eq(left, right) && left.type_id() == right.type_id() } +#[cfg(windows)] +#[derive(Debug)] +pub(crate) struct FinalJobOwner { + identity: Option<(std::any::TypeId, *mut ())>, +} + +#[cfg(windows)] +fn spawn_layer_identity(layer: &mut dyn ChildWrapper) -> (std::any::TypeId, *mut ()) { + ( + (&*layer as &dyn Any).type_id(), + std::ptr::from_mut(layer).cast::<()>(), + ) +} + impl dyn ChildWrapper + '_ { fn downcast_ref(&self) -> Option<&T> { (self as &dyn Any).downcast_ref() @@ -441,14 +459,16 @@ impl dyn ChildWrapper + '_ { } #[cfg(windows)] - pub(crate) fn finalize_spawn(&mut self) -> Result<()> { + pub(crate) fn finalize_spawn_before_commit(&mut self) -> Result { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; self.visit_spawn_layers(|inner| inner.disarm_spawn_cleanup_layer())?; let mut owners = 0; + let mut identity = None; self.visit_spawn_layers(|inner| { if inner.owns_job_object_cleanup_layer() { owners += 1; + identity.get_or_insert_with(|| spawn_layer_identity(inner)); Ok(()) } else { inner.disarm_job_object_layer() @@ -459,18 +479,31 @@ impl dyn ChildWrapper + '_ { "multiple child layers own JobObject cleanup", )); } - if owners == 0 { - return Ok(()); - } + Ok(FinalJobOwner { identity }) + } + #[cfg(windows)] + pub(crate) fn finalize_spawn_final_owner(&mut self, owner: FinalJobOwner) -> Result<()> { + let Some(identity) = owner.identity else { + return Ok(()); + }; + let mut found = false; self.visit_spawn_layers_until(|inner| { - if inner.owns_job_object_cleanup_layer() { + if spawn_layer_identity(inner) == identity { inner.disarm_job_object_layer()?; + found = true; Ok(true) } else { Ok(false) } - }) + })?; + if found { + Ok(()) + } else { + Err(std::io::Error::other( + "the captured JobObject cleanup owner left the child chain", + )) + } } /// Try to obtain a reference to the underlying native [`Child`]. diff --git a/src/tokio/core.rs b/src/tokio/core.rs index a9717e1..cad5c7a 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -159,7 +159,9 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Report whether this layer owns the JobObject rollback guard. /// - /// Process-wrap defers the sole owning layer until every other fallible child-layer hook succeeds. + /// Process-wrap counts owners and disarms every non-owner before a provider transaction commits. + /// More than one owner fails the lifecycle before commit. The sole owner stays armed until commit + /// succeeds and is then disarmed as the only post-commit child-layer operation. #[doc(hidden)] #[cfg(windows)] fn owns_job_object_cleanup_layer(&self) -> bool { @@ -168,8 +170,10 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Disarm JobObject-phase cleanup state owned by this child layer. /// - /// Non-owning hooks run first. The sole layer identified by - /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs last. + /// Non-owning hooks run before provider commit. The sole layer identified by + /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs after commit and must be failure-atomic: + /// returning an error or unwinding must leave kill-on-close protection armed. If disarming uses a + /// native state transition, no caller-controlled callback may run after that transition succeeds. #[doc(hidden)] #[cfg(windows)] fn disarm_job_object_layer(&mut self) -> Result<()> { @@ -363,6 +367,20 @@ fn same_child(left: &dyn ChildWrapper, right: &dyn ChildWrapper) -> bool { std::ptr::addr_eq(left, right) && left.type_id() == right.type_id() } +#[cfg(windows)] +#[derive(Debug)] +pub(crate) struct FinalJobOwner { + identity: Option<(std::any::TypeId, *mut ())>, +} + +#[cfg(windows)] +fn spawn_layer_identity(layer: &mut dyn ChildWrapper) -> (std::any::TypeId, *mut ()) { + ( + (&*layer as &dyn Any).type_id(), + std::ptr::from_mut(layer).cast::<()>(), + ) +} + impl dyn ChildWrapper + '_ { fn downcast_ref(&self) -> Option<&T> { (self as &dyn Any).downcast_ref() @@ -507,14 +525,16 @@ impl dyn ChildWrapper + '_ { } #[cfg(windows)] - pub(crate) fn finalize_spawn(&mut self) -> Result<()> { + pub(crate) fn finalize_spawn_before_commit(&mut self) -> Result { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; self.visit_spawn_layers(|inner| inner.disarm_spawn_cleanup_layer())?; let mut owners = 0; + let mut identity = None; self.visit_spawn_layers(|inner| { if inner.owns_job_object_cleanup_layer() { owners += 1; + identity.get_or_insert_with(|| spawn_layer_identity(inner)); Ok(()) } else { inner.disarm_job_object_layer() @@ -525,18 +545,31 @@ impl dyn ChildWrapper + '_ { "multiple child layers own JobObject cleanup", )); } - if owners == 0 { - return Ok(()); - } + Ok(FinalJobOwner { identity }) + } + #[cfg(windows)] + pub(crate) fn finalize_spawn_final_owner(&mut self, owner: FinalJobOwner) -> Result<()> { + let Some(identity) = owner.identity else { + return Ok(()); + }; + let mut found = false; self.visit_spawn_layers_until(|inner| { - if inner.owns_job_object_cleanup_layer() { + if spawn_layer_identity(inner) == identity { inner.disarm_job_object_layer()?; + found = true; Ok(true) } else { Ok(false) } - }) + })?; + if found { + Ok(()) + } else { + Err(std::io::Error::other( + "the captured JobObject cleanup owner left the child chain", + )) + } } /// Try to obtain a reference to the underlying native [`Child`]. diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index b017ea8..8ea0532 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -102,7 +102,7 @@ pub(super) struct SpawnedChild { #[derive(Debug)] pub(super) struct SpawnCleanup { - process: Option, + process: Option, } impl SpawnCleanup { @@ -111,7 +111,16 @@ impl SpawnCleanup { } pub(super) fn rollback(&mut self) -> io::Result<()> { - self.process.take().map_or(Ok(()), terminate_and_reap) + self.process.take().map_or(Ok(()), |process| { + terminate_and_reap_with(process, WindowsCleanupOperations) + }) + } + + #[cfg(test)] + fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { + Self { + process: Some(ReapHandle::tracked(process, released)), + } } } @@ -153,7 +162,7 @@ impl SpawnedProcess { .as_ref() .expect("a spawned process guard must own its process handle"); Ok(SpawnCleanup { - process: Some(duplicate(process)?), + process: Some(ReapHandle::new(duplicate(process)?)), }) } @@ -432,6 +441,15 @@ struct ReapHandle { released: Option>, } +impl std::fmt::Debug for ReapHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ReapHandle") + .field("process", &self.process) + .finish_non_exhaustive() + } +} + impl ReapHandle { fn new(process: OwnedHandle) -> Self { Self { @@ -761,7 +779,9 @@ fn win32_io_error(error: windows::core::Error) -> io::Error { mod tests { use std::{ collections::VecDeque, - os::windows::io::FromRawHandle, + io, + os::windows::io::{AsRawHandle, FromRawHandle}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, sync::{ Arc, Condvar, Mutex, atomic::{AtomicUsize, Ordering}, @@ -779,13 +799,159 @@ mod tests { use windows::{ Win32::System::{ Console::HPCON, - Threading::{CreateEventW, STARTF_USESTDHANDLES}, + Threading::{CreateEventW, STARTF_USESTDHANDLES, SetEvent}, }, core::PCWSTR, }; + use crate::{ + SpawnTransaction, + tokio::{ + ChildWrapper, Command, CommandWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, + }, + }; + use super::*; + #[derive(Clone, Copy, Debug)] + enum LifecycleFailure { + Error, + Panic, + } + + #[derive(Debug)] + struct FinalizationFailureChild(LifecycleFailure); + + impl ChildWrapper for FinalizationFailureChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + match self.0 { + LifecycleFailure::Error => Err(io::Error::other("finalization failed")), + LifecycleFailure::Panic => panic_any("finalization failed"), + } + } + } + + #[derive(Debug)] + struct TrackedCleanupTransaction(SpawnCleanup); + + impl SpawnTransaction for TrackedCleanupTransaction { + fn commit(&mut self) -> io::Result<()> { + self.0.disarm(); + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + self.0.rollback() + } + } + + struct CleanupControl { + signal: OwnedHandle, + released: mpsc::Receiver, + expected_handle: usize, + } + + #[derive(Debug)] + struct TrackedCleanupProvider { + failure: LifecycleFailure, + control: mpsc::Sender, + } + + impl SpawnProvider for TrackedCleanupProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> io::Result { + // SAFETY: default security, manual reset, initially unsignaled, and no name request an event + // handle which is transferred immediately to `OwnedHandle`. + let event = unsafe { CreateEventW(None, true, false, None) }?; + // SAFETY: the successful CreateEventW result is uniquely owned here. + let process = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let signal = duplicate(&process)?; + let expected_handle = process.as_raw_handle() as usize; + let (released, released_rx) = mpsc::channel(); + let cleanup = SpawnCleanup::tracked(process, released); + self.control + .send(CleanupControl { + signal, + released: released_rx, + expected_handle, + }) + .expect("the lifecycle test still receives cleanup ownership"); + Ok(ProviderProduct::new( + Box::new(FinalizationFailureChild(self.failure)), + Box::new(TrackedCleanupTransaction(cleanup)), + )) + } + } + + #[derive(Debug)] + struct TrackedCleanupWrapper(TrackedCleanupProvider); + + impl CommandWrapper for TrackedCleanupWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[test] + fn finalization_failures_transfer_the_exact_transaction_handle_to_the_reaper() -> io::Result<()> + { + for failure in [LifecycleFailure::Error, LifecycleFailure::Panic] { + let (control, control_rx) = mpsc::channel(); + let mut command = Command::new("provider-owned-program"); + command.wrap(TrackedCleanupWrapper(TrackedCleanupProvider { + failure, + control, + })); + + match failure { + LifecycleFailure::Error => { + let error = command.spawn().expect_err("finalization must fail"); + assert_eq!(error.to_string(), "finalization failed"); + } + LifecycleFailure::Panic => { + let payload = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("finalization must panic"); + assert_eq!( + *payload + .downcast::<&'static str>() + .expect("the original panic payload is preserved"), + "finalization failed" + ); + } + } + + let control = control_rx + .recv_timeout(Duration::from_secs(2)) + .expect("the provider reports its exact cleanup handle"); + assert_eq!(control.released.try_recv(), Err(mpsc::TryRecvError::Empty)); + // SAFETY: `signal` owns a live duplicate of the manual-reset event waited on by the reaper. + unsafe { SetEvent(HANDLE(control.signal.as_raw_handle())) }?; + assert_eq!( + control + .released + .recv_timeout(Duration::from_secs(2)) + .expect("the reaper releases the exact handle after completion"), + control.expected_handle + ); + } + Ok(()) + } + #[cfg(feature = "tracing")] #[derive(Clone, Copy, Debug)] enum SubscriberBehavior { diff --git a/src/windows.rs b/src/windows.rs index ce283cd..0b5d9cd 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -121,13 +121,20 @@ impl Drop for JobPort { } /// Set whether closing a job's final handle terminates every process in the job. -#[cfg_attr(feature = "tracing", instrument(level = "debug"))] pub(crate) fn set_job_kill_on_drop(job: JobHandle, kill_on_drop: bool) -> Result<()> { let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); if kill_on_drop { info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; } + #[cfg(feature = "tracing")] + debug!( + kill_on_drop, + ?info, + "setting SetInformationJobObject(limit)" + ); + // No tracing or other caller-controlled callback may run after the native transition: the sole + // final owner must either remain kill-on-close armed or complete disarming without unwinding. // SAFETY: `job` is live, and initialized `info` has the reported size and outlives the call. unsafe { SetInformationJobObject( @@ -139,8 +146,6 @@ pub(crate) fn set_job_kill_on_drop(job: JobHandle, kill_on_drop: bool) -> Result .expect("cannot safely cast to DWORD"), ) }?; - #[cfg(feature = "tracing")] - debug!(?info, "done SetInformationJobObject(limit)"); Ok(()) } diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 9524b3a..6c6addb 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -48,6 +48,14 @@ macro_rules! spawn_provider_tests { PeerWrap, Commit, Rollback, + #[cfg(windows)] + FinalizeSpawn, + #[cfg(windows)] + DisarmSpawnCleanup, + #[cfg(windows)] + DisarmNonOwner, + #[cfg(windows)] + DisarmOwner, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -68,6 +76,23 @@ macro_rules! spawn_provider_tests { Wrap(&'static str), Commit, Rollback, + #[cfg(windows)] + FinalizeSpawn(&'static str), + #[cfg(windows)] + DisarmSpawnCleanup(&'static str), + #[cfg(windows)] + DisarmNonOwner(&'static str), + #[cfg(windows)] + DisarmOwner(&'static str), + #[cfg(windows)] + OwnerDropped(&'static str, bool), + } + + #[cfg(windows)] + #[derive(Clone, Copy, Debug)] + struct FinalizationLayerSpec { + name: &'static str, + owns_job_cleanup: bool, } #[derive(Debug, Default)] @@ -75,6 +100,8 @@ macro_rules! spawn_provider_tests { events: Mutex>, failures: Mutex>, make_attempt_native_only: AtomicBool, + #[cfg(windows)] + finalization_layers: Mutex>, } impl Shared { @@ -90,6 +117,16 @@ macro_rules! spawn_provider_tests { self.events.lock().unwrap().clear(); } + #[cfg(windows)] + fn set_finalization_layers(&self, layers: Vec) { + *self.finalization_layers.lock().unwrap() = layers; + } + + #[cfg(windows)] + fn finalization_layers(&self) -> Vec { + self.finalization_layers.lock().unwrap().clone() + } + fn fail_once(&self, point: Point, failure: Failure) { self.failures.lock().unwrap().push((point, failure)); } @@ -128,6 +165,90 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[derive(Debug)] + struct FinalizationChild { + inner: Option>, + shared: Arc, + name: &'static str, + owns_job_cleanup: bool, + armed: bool, + } + + #[cfg(windows)] + impl ChildWrapper for FinalizationChild { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_deref().expect("the child layer still owns its inner child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("the child layer still owns its inner child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("the child layer still owns its inner child") + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + self.shared.event(Event::FinalizeSpawn(self.name)); + self.shared.fail(Point::FinalizeSpawn) + } + + fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + self.shared.event(Event::DisarmSpawnCleanup(self.name)); + self.shared.fail(Point::DisarmSpawnCleanup) + } + + fn owns_job_object_cleanup_layer(&self) -> bool { + self.owns_job_cleanup + } + + fn disarm_job_object_layer(&mut self) -> io::Result<()> { + if self.owns_job_cleanup { + self.shared.event(Event::DisarmOwner(self.name)); + self.shared.fail(Point::DisarmOwner)?; + self.armed = false; + } else { + self.shared.event(Event::DisarmNonOwner(self.name)); + self.shared.fail(Point::DisarmNonOwner)?; + } + Ok(()) + } + } + + #[cfg(windows)] + impl Drop for FinalizationChild { + fn drop(&mut self) { + if self.owns_job_cleanup { + self.shared.event(Event::OwnerDropped(self.name, self.armed)); + } + } + } + + #[cfg(windows)] + fn add_finalization_layers( + shared: Arc, + child: Box, + ) -> Box { + shared + .finalization_layers() + .into_iter() + .rev() + .fold(child, |inner, layer| { + Box::new(FinalizationChild { + inner: Some(inner), + shared: Arc::clone(&shared), + name: layer.name, + owns_job_cleanup: layer.owns_job_cleanup, + armed: layer.owns_job_cleanup, + }) + }) + } + #[derive(Debug)] struct Transaction(Arc); @@ -379,6 +500,8 @@ macro_rules! spawn_provider_tests { } self.shared.event(Event::Wrap("peer")); self.shared.fail(Point::PeerWrap)?; + #[cfg(windows)] + let child = add_finalization_layers(Arc::clone(&self.shared), child); Ok(child) } } @@ -448,6 +571,49 @@ macro_rules! spawn_provider_tests { ] } + #[cfg(windows)] + fn finalization_layer( + name: &'static str, + owns_job_cleanup: bool, + ) -> FinalizationLayerSpec { + FinalizationLayerSpec { + name, + owns_job_cleanup, + } + } + + #[cfg(windows)] + fn lifecycle_before_commit(name: &'static str) -> Vec { + let mut events = successful_events(name); + assert_eq!(events.pop(), Some(Event::Commit)); + events + } + + #[cfg(windows)] + fn successful_finalization_events( + name: &'static str, + layers: &[FinalizationLayerSpec], + ) -> Vec { + let mut events = lifecycle_before_commit(name); + events.extend(layers.iter().map(|layer| Event::FinalizeSpawn(layer.name))); + events.extend( + layers + .iter() + .map(|layer| Event::DisarmSpawnCleanup(layer.name)), + ); + events.extend( + layers + .iter() + .filter(|layer| !layer.owns_job_cleanup) + .map(|layer| Event::DisarmNonOwner(layer.name)), + ); + events.push(Event::Commit); + if let Some(owner) = layers.iter().find(|layer| layer.owns_job_cleanup) { + events.push(Event::DisarmOwner(owner.name)); + } + events + } + fn expected_before(point: Point, name: &'static str) -> Vec { let mut events = vec![Event::Available(name)]; if point == Point::Available { @@ -841,6 +1007,140 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[cfg(windows)] + #[test] + fn precommit_child_hook_failures_roll_back_preserve_failure_and_allow_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for (point, failure_event) in [ + (Point::FinalizeSpawn, Event::FinalizeSpawn("layer")), + ( + Point::DisarmSpawnCleanup, + Event::DisarmSpawnCleanup("layer"), + ), + (Point::DisarmNonOwner, Event::DisarmNonOwner("layer")), + ] { + for failure in [ + Failure::Error(io::ErrorKind::Other, "finalization failed"), + Failure::Panic("finalization failed"), + ] { + let shared = Arc::new(Shared::default()); + let layers = vec![finalization_layer("layer", false)]; + shared.set_finalization_layers(layers.clone()); + shared.fail_once(point, failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "finalization failed"); + let events = shared.events(); + assert_eq!(events.last(), Some(&Event::Rollback)); + assert!(events.contains(&failure_event)); + assert!(!events.contains(&Event::Commit)); + assert!( + events + .windows(2) + .any(|events| events == [failure_event, Event::Rollback]), + "the failing hook must be followed by provider rollback: {events:?}" + ); + + shared.clear_events(); + let child = command.spawn().unwrap(); + drop(child); + assert_eq!( + shared.events(), + successful_finalization_events("provider", &layers) + ); + } + } + } + + #[cfg(windows)] + #[test] + fn provider_commit_separates_nonowners_from_the_sole_final_owner() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for layers in [ + vec![ + finalization_layer("outer", false), + finalization_layer("inner", false), + ], + vec![ + finalization_layer("outer", false), + finalization_layer("owner", true), + finalization_layer("inner", false), + ], + ] { + let shared = Arc::new(Shared::default()); + shared.set_finalization_layers(layers.clone()); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let child = command.spawn().unwrap(); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + if let Some(owner) = layers.iter().find(|layer| layer.owns_job_cleanup) { + expected.push(Event::OwnerDropped(owner.name, false)); + } + assert_eq!(shared.events(), expected); + } + } + + #[cfg(windows)] + #[test] + fn multiple_final_owners_fail_and_roll_back_before_commit() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let layers = vec![ + finalization_layer("outer-owner", true), + finalization_layer("nonowner", false), + finalization_layer("inner-owner", true), + ]; + shared.set_finalization_layers(layers); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command.spawn().expect_err("multiple owners must fail"); + assert_eq!( + error.to_string(), + "multiple child layers own JobObject cleanup" + ); + let events = shared.events(); + assert!(!events.contains(&Event::Commit)); + assert_eq!(events.last(), Some(&Event::Rollback)); + assert!(events.contains(&Event::DisarmNonOwner("nonowner"))); + assert!(events.contains(&Event::OwnerDropped("outer-owner", true))); + assert!(events.contains(&Event::OwnerDropped("inner-owner", true))); + } + + #[cfg(windows)] + #[test] + fn final_owner_failures_leave_the_owner_armed_and_preserve_the_failure() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for failure in [ + Failure::Error(io::ErrorKind::Other, "owner disarm failed"), + Failure::Panic("owner disarm failed"), + ] { + let shared = Arc::new(Shared::default()); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + shared.fail_once(Point::DisarmOwner, failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "owner disarm failed"); + let events = shared.events(); + assert!(events.contains(&Event::Commit)); + assert!(events.contains(&Event::DisarmOwner("owner"))); + assert_eq!(events.last(), Some(&Event::OwnerDropped("owner", true))); + assert!(!events.contains(&Event::Rollback)); + + shared.clear_events(); + let child = command.spawn().unwrap(); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + expected.push(Event::OwnerDropped("owner", false)); + assert_eq!(shared.events(), expected); + } + } + #[test] fn commit_panic_rolls_back_preserves_payload_and_allows_reuse() { let runtime = runtime(); diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index 404d87e..a95da9a 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -12,6 +12,13 @@ use std::{ time::Instant, }; +#[cfg(feature = "tracing")] +use tracing::{ + Event, Metadata, Subscriber, + field::{Field, Visit}, + span::{Attributes, Id, Record}, +}; + use windows::Win32::System::Threading::{ CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, PROCESS_CREATION_FLAGS, @@ -24,6 +31,8 @@ use super::{ const EXIT_TIMEOUT: Duration = Duration::from_secs(5); const DESCENDANT_PID_FILE: &str = "PROCESS_WRAP_DESCENDANT_PID_FILE"; +#[cfg(feature = "tracing")] +const FINAL_OWNER_TRACE_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_HELPER"; static PID_FILE_SEQUENCE: AtomicU32 = AtomicU32::new(0); #[derive(Clone, Copy)] @@ -278,6 +287,96 @@ impl CommandWrapper for FailAfterDescendant { } } +#[cfg(feature = "tracing")] +#[derive(Debug)] +struct ObserveDescendant { + pid_file: PathBuf, + guard: Arc>>, +} + +#[cfg(feature = "tracing")] +impl CommandWrapper for ObserveDescendant { + fn wrap_child( + &mut self, + child: Box, + _core: &CommandWrap, + ) -> Result> { + resume_process_threads(child.id())?; + let deadline = Instant::now() + EXIT_TIMEOUT; + let pid = loop { + if let Ok(pid) = fs::read_to_string(&self.pid_file) + .and_then(|pid| pid.trim().parse().map_err(Error::other)) + { + break pid; + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + std::thread::sleep(Duration::from_millis(10)); + }; + *self.guard.lock().unwrap() = Some(ProcessGuard::open(pid)?); + Ok(child) + } +} + +#[cfg(feature = "tracing")] +#[derive(Default)] +struct JobLimitMessageVisitor { + matches: bool, + kill_on_drop: Option, +} + +#[cfg(feature = "tracing")] +impl Visit for JobLimitMessageVisitor { + fn record_bool(&mut self, field: &Field, value: bool) { + if field.name() == "kill_on_drop" { + self.kill_on_drop = Some(value); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" + && format!("{value:?}").contains("setting SetInformationJobObject(limit)") + { + self.matches = true; + } + } +} + +#[cfg(feature = "tracing")] +#[derive(Debug, Default)] +struct PanicOnJobDisarmEvent; + +#[cfg(feature = "tracing")] +impl Subscriber for PanicOnJobDisarmEvent { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + let mut visitor = JobLimitMessageVisitor::default(); + event.record(&mut visitor); + if visitor.matches && visitor.kill_on_drop == Some(false) { + panic!("injected final-owner tracing panic"); + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} +} + fn descendant_pid_file() -> Result { let path = std::env::temp_dir().join(format!( "process-wrap-{}-{}.pid", @@ -492,6 +591,55 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { Ok(()) } +#[cfg(feature = "tracing")] +#[test] +fn final_owner_tracing_panic_leaves_job_cleanup_armed() -> Result<()> { + if std::env::var_os(FINAL_OWNER_TRACE_HELPER).is_none() { + let status = StdCommand::new(std::env::current_exe()?) + .args([ + "std_windows::creation_flags_job_object::final_owner_tracing_panic_leaves_job_cleanup_armed", + "--exact", + "--nocapture", + ]) + .env(FINAL_OWNER_TRACE_HELPER, "1") + .status()?; + assert!(status.success(), "the isolated tracing regression failed"); + return Ok(()); + } + + let pid_file = descendant_pid_file()?; + let guard = Arc::new(Mutex::new(None)); + let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args(["lifecycle_descendant_parent", "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_FILE, &pid_file); + }); + command + .wrap(ObserveDescendant { + pid_file: pid_file.clone(), + guard: Arc::clone(&guard), + }) + .wrap(JobObject); + + let panic = tracing::subscriber::with_default(PanicOnJobDisarmEvent, || { + catch_unwind(AssertUnwindSafe(|| command.spawn())) + }) + .expect_err("the final-owner tracing callback must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "injected final-owner tracing panic" + ); + + let process = guard + .lock() + .unwrap() + .take() + .expect("the observing hook opened the descendant process"); + wait_for_process_exit(process)?; + fs::remove_file(pid_file)?; + Ok(()) +} + #[test] fn suspended_native_children_are_killed_after_later_failures() -> Result<()> { for failure in [Failure::Error, Failure::Panic] { From 3acbb72d78ea36a8cc5326f55bdb2e4bf147a7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:14:04 +1200 Subject: [PATCH 040/114] fix(windows): own completion ports across blocking waits Co-Authored-By: Claude Code --- src/std/job_object.rs | 14 +- src/tokio/job_object.rs | 300 ++++++++++++++++++++++++++++++++++++++-- src/windows.rs | 32 ++--- 3 files changed, 310 insertions(+), 36 deletions(-) diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 58c5a5b..b68677a 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -1,7 +1,7 @@ use std::{ any::Any, io::{Error, ErrorKind, Result}, - os::windows::io::{AsRawHandle, BorrowedHandle}, + os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}, process::ExitStatus, time::Duration, }; @@ -208,7 +208,7 @@ impl ChildWrapper for JobObjectChild { // manually drop the completion port let its = std::mem::ManuallyDrop::new(job_port); // SAFETY: `its` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(its.completion_port.0) }.ok(); + unsafe { CloseHandle(HANDLE(its.completion_port.as_raw_handle())) }.ok(); // we leave the job handle unclosed, otherwise the Child is useless // (as closing it may terminate the job) } @@ -246,16 +246,16 @@ impl ChildWrapper for JobObjectChild { self.exit_status = ChildExitStatus::Exited(status); // nevertheless, now wait and make sure we reap all children. - let JobPort { - completion_port, .. - } = self.job_port; - let _ = wait_on_job(completion_port, None)?; + let _ = wait_on_job(self.job_port.completion_port.as_handle(), None)?; Ok(status) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - let _ = wait_on_job(self.job_port.completion_port, Some(Duration::ZERO))?; + let _ = wait_on_job( + self.job_port.completion_port.as_handle(), + Some(Duration::ZERO), + )?; self.inner.try_wait() } } diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 55b18e5..a6c251e 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -2,7 +2,7 @@ use std::{ any::Any, future::Future, io::{Error, ErrorKind, Result}, - os::windows::io::{AsRawHandle, BorrowedHandle}, + os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}, pin::Pin, process::ExitStatus, time::Duration, @@ -24,6 +24,47 @@ use crate::{ }, }; +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BlockingWaitEvent { + Begin { handle: usize, owned: bool }, + End { handle: usize }, + Dropped { handle: usize }, +} + +#[cfg(test)] +fn blocking_wait_observer() +-> &'static std::sync::Mutex>> { + static OBSERVER: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + OBSERVER.get_or_init(|| std::sync::Mutex::new(None)) +} + +#[cfg(test)] +fn observe_blocking_wait(event: BlockingWaitEvent) { + if let Some(observer) = blocking_wait_observer().lock().unwrap().as_ref() { + let _ = observer.send(event); + } +} + +fn blocking_wait_on_job(completion_port: OwnedHandle) -> Result> { + #[cfg(test)] + let handle = completion_port.as_raw_handle() as usize; + #[cfg(test)] + observe_blocking_wait(BlockingWaitEvent::Begin { + handle, + owned: true, + }); + let result = wait_on_job(completion_port.as_handle(), None); + #[cfg(test)] + observe_blocking_wait(BlockingWaitEvent::End { handle }); + drop(completion_port); + #[cfg(test)] + observe_blocking_wait(BlockingWaitEvent::Dropped { handle }); + result +} + #[cfg(feature = "creation-flags")] use super::CreationFlags; #[cfg(feature = "kill-on-drop")] @@ -221,7 +262,7 @@ impl ChildWrapper for JobObjectChild { // manually drop the completion port let its = std::mem::ManuallyDrop::new(job_port); // SAFETY: `its` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(its.completion_port.0) }.ok(); + unsafe { CloseHandle(HANDLE(its.completion_port.as_raw_handle())) }.ok(); // we leave the job handle unclosed, otherwise the Child is useless // (as closing it may terminate the job) } @@ -263,24 +304,265 @@ impl ChildWrapper for JobObjectChild { // nevertheless, now try reaping all children a few times... for _ in 1..MAX_RETRY_ATTEMPT { - if wait_on_job(self.job_port.completion_port, Some(Duration::ZERO))?.is_break() { + if wait_on_job( + self.job_port.completion_port.as_handle(), + Some(Duration::ZERO), + )? + .is_break() + { return Ok(status); } } // ...finally, if there are some that are still alive, - // block in the background to reap them fully. - let JobPort { - completion_port, .. - } = self.job_port; - let _ = spawn_blocking(move || wait_on_job(completion_port, None)).await??; + // block in the background to reap them fully. The detached closure owns only a duplicate + // completion-port handle so canceling this future cannot make its native wait stale or keep + // the JobObject itself alive. + let completion_port = self.job_port.completion_port.try_clone()?; + let _ = spawn_blocking(move || blocking_wait_on_job(completion_port)).await??; Ok(status) }) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - let _ = wait_on_job(self.job_port.completion_port, Some(Duration::ZERO))?; + let _ = wait_on_job( + self.job_port.completion_port.as_handle(), + Some(Duration::ZERO), + )?; self.inner.try_wait() } } + +#[cfg(test)] +mod tests { + use std::{ + future::Future, + io, + os::windows::{ + io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + process::ExitStatusExt, + }, + pin::Pin, + process::ExitStatus, + sync::mpsc, + time::Duration, + }; + + use windows::Win32::{ + Foundation::{ + DUPLICATE_SAME_ACCESS, DuplicateHandle, GetHandleInformation, HANDLE, + INVALID_HANDLE_VALUE, + }, + System::{ + IO::{CreateIoCompletionPort, PostQueuedCompletionStatus}, + JobObjects::CreateJobObjectW, + Threading::{CreateEventW, GetCurrentProcess}, + }, + }; + + use super::*; + use crate::windows::{JobHandle, JobPort}; + + #[derive(Debug)] + struct ImmediateChild; + + impl ChildWrapper for ImmediateChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn id(&self) -> Option { + Some(1) + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + Box::pin(async { Ok(ExitStatus::from_raw(0)) }) + } + } + + struct ObserverGuard; + + impl ObserverGuard { + fn install() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(); + let mut observer = blocking_wait_observer().lock().unwrap(); + assert!( + observer.is_none(), + "blocking wait observer is already installed" + ); + *observer = Some(sender); + (Self, receiver) + } + } + + impl Drop for ObserverGuard { + fn drop(&mut self) { + *blocking_wait_observer().lock().unwrap() = None; + } + } + + fn owned(handle: HANDLE) -> OwnedHandle { + // SAFETY: each caller transfers one newly created or duplicated owned handle. + unsafe { OwnedHandle::from_raw_handle(handle.0) } + } + + fn job_port() -> io::Result { + // SAFETY: default attributes and no name create a uniquely owned job handle. + let job = owned(unsafe { CreateJobObjectW(None, None) }.map_err(io::Error::other)?); + // SAFETY: these arguments create a new completion port which is uniquely owned. + let port = owned(unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }?); + Ok(JobPort { + job: JobHandle(HANDLE(job.into_raw_handle())), + completion_port: port, + }) + } + + fn duplicate(handle: HANDLE) -> io::Result { + // SAFETY: this returns the current process pseudo-handle, which stays valid for this process. + let current = unsafe { GetCurrentProcess() }; + let mut duplicate = HANDLE::default(); + // SAFETY: `handle` is live, `duplicate` is writable, and success transfers a distinct owned + // handle in the current process. + unsafe { + DuplicateHandle( + current, + handle, + current, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + }?; + Ok(owned(duplicate)) + } + + fn handle_is_live(raw: usize) -> bool { + let mut flags = 0; + // SAFETY: the call only queries the scalar handle value and writes `flags` on success. + unsafe { GetHandleInformation(HANDLE(raw as *mut _), &mut flags) }.is_ok() + } + + fn occupy_reused_handle(raw: usize) -> OwnedHandle { + let mut retained = Vec::new(); + for _ in 0..4096 { + // SAFETY: default security, manual reset, initially unsignaled, and no name create one event. + let replacement = owned(unsafe { CreateEventW(None, true, false, None) }.unwrap()); + if replacement.as_raw_handle() as usize == raw { + return replacement; + } + retained.push(replacement); + } + panic!("Windows did not reuse the closed completion-port slot under stress"); + } + + const CANCELED_WAIT_HELPER: &str = "PROCESS_WRAP_CANCELED_JOB_WAIT_HELPER"; + + #[test] + fn canceled_blocking_wait_owns_only_a_duplicate_completion_port() { + if std::env::var_os(CANCELED_WAIT_HELPER).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "tokio::job_object::tests::canceled_blocking_wait_owns_only_a_duplicate_completion_port", + "--exact", + "--nocapture", + ]) + .env(CANCELED_WAIT_HELPER, "1") + .status() + .unwrap(); + assert!( + status.success(), + "the isolated handle-reuse regression failed" + ); + return; + } + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .max_blocking_threads(1) + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + let (blocker_started, blocker_started_rx) = mpsc::channel(); + let (release_blocker, release_blocker_rx) = mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + blocker_started.send(()).unwrap(); + release_blocker_rx.recv().unwrap(); + }); + blocker_started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("the sole blocking worker starts"); + + let job_port = job_port().unwrap(); + let original_job = job_port.job.0.0 as usize; + let original = job_port.completion_port.as_raw_handle() as usize; + let controller = duplicate(HANDLE(job_port.completion_port.as_raw_handle())).unwrap(); + let mut child = JobObjectChild::new(Box::new(ImmediateChild), job_port, false); + let (_observer, observations) = ObserverGuard::install(); + + let timed_out = tokio::time::timeout(Duration::from_millis(100), child.wait()).await; + assert!( + timed_out.is_err(), + "the queued blocking wait must not start yet" + ); + drop(child); + assert!( + !handle_is_live(original_job), + "the delayed wait must not retain the JobObject" + ); + assert!( + !handle_is_live(original), + "dropping the child closes the original port" + ); + let replacement = occupy_reused_handle(original); + + release_blocker.send(()).unwrap(); + let begin = observations + .recv_timeout(Duration::from_secs(2)) + .expect("the delayed completion-port wait begins"); + let BlockingWaitEvent::Begin { handle, owned } = begin else { + panic!("the first observation must begin the wait: {begin:?}"); + }; + assert!(owned, "the delayed wait must retain an owned duplicate"); + assert_ne!( + handle, original, + "the delayed wait must not reuse the original slot" + ); + assert!( + handle_is_live(handle), + "the delayed wait's duplicate is live" + ); + // SAFETY: `controller` owns a live duplicate of the same completion port. + unsafe { PostQueuedCompletionStatus(HANDLE(controller.as_raw_handle()), 0, 0, None) } + .unwrap(); + assert_eq!( + observations.recv_timeout(Duration::from_secs(2)).unwrap(), + BlockingWaitEvent::End { handle } + ); + assert_eq!( + observations.recv_timeout(Duration::from_secs(2)).unwrap(), + BlockingWaitEvent::Dropped { handle } + ); + assert!( + !handle_is_live(handle), + "the duplicate closes after the wait returns" + ); + assert!( + handle_is_live(original), + "the replacement object still owns the reused slot" + ); + drop(replacement); + blocker.await.unwrap(); + }); + } +} diff --git a/src/windows.rs b/src/windows.rs index 0b5d9cd..688398d 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -3,6 +3,7 @@ use std::{ io::{Error, Result}, ops::ControlFlow, + os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle as StdOwnedHandle}, time::Duration, }; @@ -94,29 +95,19 @@ unsafe impl Send for JobHandle {} // SAFETY: shared access exposes only the handle value, not mutable Rust memory. unsafe impl Sync for JobHandle {} -#[derive(Clone, Copy, Debug)] -pub struct PortHandle(pub HANDLE); - -// SAFETY: this non-owning wrapper contains only a process-wide kernel handle value. -unsafe impl Send for PortHandle {} -// SAFETY: shared access exposes only the handle value, not mutable Rust memory. -unsafe impl Sync for PortHandle {} - /// A JobObject and its associated completion port. /// /// This struct closes the handles when dropped. #[derive(Debug)] pub(crate) struct JobPort { pub job: JobHandle, - pub completion_port: PortHandle, + pub completion_port: StdOwnedHandle, } impl Drop for JobPort { fn drop(&mut self) { // SAFETY: `JobPort` solely owns this job handle. unsafe { CloseHandle(self.job.0) }.ok(); - // SAFETY: `JobPort` solely owns this distinct completion-port handle. - unsafe { CloseHandle(self.completion_port.0) }.ok(); } } @@ -161,14 +152,15 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res debug!(?job, "done CreateJobObjectW"); // SAFETY: these arguments create a new port; the successful handle is immediately owned. - let completion_port = - OwnedHandle(unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }?); + let completion_port = unsafe { + StdOwnedHandle::from_raw_handle(CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1)?.0) + }; #[cfg(feature = "tracing")] debug!(?completion_port, "done CreateIoCompletionPort"); let associate_completion = JOBOBJECT_ASSOCIATE_COMPLETION_PORT { CompletionKey: job.0.0 as _, - CompletionPort: completion_port.0, + CompletionPort: HANDLE(completion_port.as_raw_handle()), }; // SAFETY: the handles stay live, and initialized `associate_completion` has the reported size. @@ -198,7 +190,7 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res Ok(JobPort { job: JobHandle(job.into_raw()), - completion_port: PortHandle(completion_port.into_raw()), + completion_port, }) } @@ -275,7 +267,7 @@ pub(crate) fn terminate_job(job: JobHandle, exit_code: u32) -> Result<()> { /// Wait for a job to complete. #[cfg_attr(feature = "tracing", instrument(level = "debug"))] pub(crate) fn wait_on_job( - completion_port: PortHandle, + completion_port: BorrowedHandle<'_>, timeout: Option, ) -> Result> { let mut code: u32 = 0; @@ -283,12 +275,12 @@ pub(crate) fn wait_on_job( let mut overlapped = OVERLAPPED::default(); let mut lp_overlapped = &mut overlapped as *mut OVERLAPPED; - // SAFETY: `completion_port` is borrowed from a live `JobPort`, and every output pointer refers - // to live stack storage. On failure we inspect `code` and `key` only when a packet was dequeued, - // as indicated by a non-null `lp_overlapped`. + // SAFETY: `completion_port` is lifetime-bound to a live owned handle, and every output pointer + // refers to live stack storage. On failure we inspect `code` and `key` only when a packet was + // dequeued, as indicated by a non-null `lp_overlapped`. let result = unsafe { GetQueuedCompletionStatus( - completion_port.0, + HANDLE(completion_port.as_raw_handle()), &mut code, &mut key, &mut lp_overlapped as *mut _, From 22f1deb4fd482afe37e12ab0deefffe1978e0292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:20:05 +1200 Subject: [PATCH 041/114] fix(pty): suppress inline close failure diagnostics Co-Authored-By: Claude Code --- src/tokio/pty/windows/console.rs | 269 ++++++++++++++++++++++++++++--- 1 file changed, 248 insertions(+), 21 deletions(-) diff --git a/src/tokio/pty/windows/console.rs b/src/tokio/pty/windows/console.rs index 98abb29..a4a53e8 100644 --- a/src/tokio/pty/windows/console.rs +++ b/src/tokio/pty/windows/console.rs @@ -5,8 +5,6 @@ use std::{ sync::{Arc, Mutex}, }; -#[cfg(feature = "tracing")] -use tracing::warn; use windows::Win32::{ Foundation::HANDLE, System::Console::{COORD, HPCON}, @@ -162,30 +160,31 @@ pub(super) fn coordinate(size: PtySize) -> io::Result { } fn schedule_close(close: api::ClosePseudoConsole, handle: HPCON) { + schedule_close_with(close, handle, launch_close_worker); +} + +fn launch_close_worker(close: api::ClosePseudoConsole, handle: HPCON) -> io::Result<()> { let close_thread = std::thread::Builder::new() .name("process-wrap-conpty-close".into()) .spawn(move || { // SAFETY: ownership of this live raw HPCON was transferred to exactly this close thread. unsafe { close(handle) }; - }); - match close_thread { - Ok(close_thread) => { - // Dropping a JoinHandle detaches its thread. The worker exclusively owns the raw HPCON - // transferred into its closure and will close it without blocking this caller. - drop(close_thread); - } - Err(error) => { - // Calling ClosePseudoConsole here could indefinitely block a reactor or arbitrary dropping - // thread on older Windows versions. Leaking is the only safe fallback after thread creation - // itself fails. - #[cfg(feature = "tracing")] - warn!( - ?error, - "failed to start ConPTY close thread; leaking the pseudo-console handle" - ); - #[cfg(not(feature = "tracing"))] - let _ = error; - } + })?; + // Dropping a JoinHandle detaches its thread. The worker exclusively owns the raw HPCON + // transferred into its closure and will close it without blocking this caller. + drop(close_thread); + Ok(()) +} + +fn schedule_close_with( + close: api::ClosePseudoConsole, + handle: HPCON, + launch: impl FnOnce(api::ClosePseudoConsole, HPCON) -> io::Result<()>, +) { + if launch(close, handle).is_err() { + // Calling ClosePseudoConsole here could indefinitely block a reactor or arbitrary dropping + // thread on older Windows versions. Leaking without diagnostics is the only fallback that + // neither blocks nor invokes application-controlled tracing on the dropping thread. } } @@ -196,6 +195,18 @@ mod tests { time::{Duration, Instant}, }; + #[cfg(feature = "tracing")] + use std::{ + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Condvar, mpsc}, + thread::{self, ThreadId}, + }; + #[cfg(feature = "tracing")] + use tracing::{ + Event, Metadata, Subscriber, + span::{Attributes, Id, Record}, + }; + use windows::core::HRESULT; use super::*; @@ -208,6 +219,141 @@ mod tests { static BLOCK_CLOSE: AtomicBool = AtomicBool::new(false); static CLOSE_STARTED: AtomicBool = AtomicBool::new(false); static CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0); + #[cfg(feature = "tracing")] + static CLOSE_LAUNCH_COUNT: AtomicUsize = AtomicUsize::new(0); + + #[cfg(feature = "tracing")] + #[derive(Clone, Copy, Debug)] + enum DiagnosticBehavior { + Record, + Panic, + Reenter, + Block, + } + + #[cfg(feature = "tracing")] + #[derive(Debug, Default)] + struct DiagnosticGate { + events: usize, + thread: Option, + released: bool, + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct DiagnosticState { + behavior: DiagnosticBehavior, + gate: Mutex, + changed: Condvar, + reentered: AtomicBool, + } + + #[cfg(feature = "tracing")] + impl DiagnosticState { + fn new(behavior: DiagnosticBehavior) -> Arc { + Arc::new(Self { + behavior, + gate: Mutex::new(DiagnosticGate::default()), + changed: Condvar::new(), + reentered: AtomicBool::new(false), + }) + } + + fn event_count(&self) -> usize { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .events + } + + fn event_thread(&self) -> Option { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .thread + } + + fn release(&self) { + let mut gate = self + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.released = true; + self.changed.notify_all(); + } + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct AdversarialSubscriber { + state: Arc, + } + + #[cfg(feature = "tracing")] + impl Subscriber for AdversarialSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) { + let mut gate = self + .state + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.events += 1; + gate.thread = Some(thread::current().id()); + self.state.changed.notify_all(); + match self.state.behavior { + DiagnosticBehavior::Record => {} + DiagnosticBehavior::Panic => { + drop(gate); + panic!("injected close diagnostic panic"); + } + DiagnosticBehavior::Reenter => { + drop(gate); + self.state.reentered.store(true, Ordering::SeqCst); + tracing::warn!("re-entered tracing from the close diagnostic subscriber"); + } + DiagnosticBehavior::Block => { + while !gate.released { + gate = self.state.changed.wait(gate).unwrap(); + } + } + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} + } + + #[cfg(feature = "tracing")] + fn diagnostic_dispatch(state: Arc) -> tracing::Dispatch { + tracing::Dispatch::new(AdversarialSubscriber { state }) + } + + #[cfg(feature = "tracing")] + fn fail_close_worker_start() { + schedule_close_with(close, HPCON(42), |_, _| { + CLOSE_LAUNCH_COUNT.fetch_add(1, Ordering::SeqCst); + Err(io::Error::other("injected close-worker startup failure")) + }); + } + + #[cfg(feature = "tracing")] + fn assert_failed_launch_without_close() { + assert_eq!(CLOSE_LAUNCH_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 0); + } unsafe extern "system" fn create( size: COORD, @@ -270,6 +416,8 @@ mod tests { BLOCK_CLOSE.store(false, Ordering::SeqCst); CLOSE_STARTED.store(false, Ordering::SeqCst); CLOSE_COUNT.store(0, Ordering::SeqCst); + #[cfg(feature = "tracing")] + CLOSE_LAUNCH_COUNT.store(0, Ordering::SeqCst); } #[test] @@ -367,4 +515,83 @@ mod tests { std::thread::sleep(Duration::from_millis(10)); assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 1); } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_invoke_a_subscriber_on_the_caller() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let caller = thread::current().id(); + let state = DiagnosticState::new(DiagnosticBehavior::Record); + let dispatch = diagnostic_dispatch(Arc::clone(&state)); + + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + + assert_ne!( + state.event_thread(), + Some(caller), + "worker-start diagnostics must not call the subscriber on the dropping thread" + ); + assert_eq!(state.event_count(), 0); + assert_failed_launch_without_close(); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_invoke_a_panicking_subscriber() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let state = DiagnosticState::new(DiagnosticBehavior::Panic); + let dispatch = diagnostic_dispatch(state); + + let result = catch_unwind(AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + })); + + assert!( + result.is_ok(), + "worker-start failure must not invoke a panicking subscriber" + ); + assert_failed_launch_without_close(); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_reenter_tracing() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let state = DiagnosticState::new(DiagnosticBehavior::Reenter); + let dispatch = diagnostic_dispatch(Arc::clone(&state)); + + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + + assert!(!state.reentered.load(Ordering::SeqCst)); + assert_eq!(state.event_count(), 0); + assert_failed_launch_without_close(); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_block_in_a_subscriber() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let state = DiagnosticState::new(DiagnosticBehavior::Block); + let dispatch = diagnostic_dispatch(Arc::clone(&state)); + let (finished, finished_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + finished.send(()).unwrap(); + }); + + let returned_before_watchdog = finished_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + state.release(); + worker.join().unwrap(); + + assert!( + returned_before_watchdog, + "worker-start failure blocked in an inline subscriber callback" + ); + assert_eq!(state.event_count(), 0); + assert_failed_launch_without_close(); + } } From b3b16f9033c6c856a629baf7fa245bab513ba954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:25:33 +1200 Subject: [PATCH 042/114] fix(pty): validate Windows PTY size before hooks Co-Authored-By: Claude Code --- src/tokio/pty.rs | 8 +++-- src/tokio/pty/unix.rs | 4 +++ src/tokio/pty/unsupported.rs | 4 +++ src/tokio/pty/windows/backend.rs | 4 +++ src/tokio/pty/windows/mod.rs | 2 +- tests/tokio_pty_windows.rs | 62 ++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index 7ad8547..8901a00 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -189,7 +189,7 @@ impl SpawnProvider for Pty { } fn validate_command(&self, _command: &Command) -> io::Result<()> { - self.size.validate() + imp::validate_size(self.size) } fn validate_attempt(&self, attempt: &SpawnAttempt, _command: &Command) -> io::Result<()> { @@ -223,8 +223,10 @@ impl SpawnProvider for Pty { /// The character and pixel dimensions of a pseudo-terminal. /// -/// Character dimensions must both be nonzero. Pixel dimensions may be zero when they are unknown or -/// not meaningful to the caller. Sizes are validated before spawning and by [`PtyResize::resize`]. +/// Character dimensions must both be nonzero. The Windows backend additionally requires each +/// character dimension to fit in a signed 16-bit console coordinate (`1..=32767`). Pixel dimensions +/// may be zero when they are unknown or not meaningful to the caller. Sizes are validated before +/// spawning and by [`PtyResize::resize`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PtySize { /// Character rows. diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 39d1323..1e35a82 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -162,6 +162,10 @@ pub(super) fn check_available() -> io::Result<()> { Ok(()) } +pub(super) fn validate_size(size: PtySize) -> io::Result<()> { + size.validate() +} + #[cfg(target_os = "netbsd")] fn check_netbsd_version() -> io::Result<()> { let mut name = std::mem::MaybeUninit::::uninit(); diff --git a/src/tokio/pty/unsupported.rs b/src/tokio/pty/unsupported.rs index eb53c01..19bf591 100644 --- a/src/tokio/pty/unsupported.rs +++ b/src/tokio/pty/unsupported.rs @@ -55,6 +55,10 @@ pub(super) fn check_available() -> io::Result<()> { Err(unsupported()) } +pub(super) fn validate_size(size: PtySize) -> io::Result<()> { + size.validate() +} + pub(super) fn spawn(_attempt: &mut SpawnAttempt, _size: PtySize) -> io::Result { Err(unsupported()) } diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs index 53d9250..7efbbe2 100644 --- a/src/tokio/pty/windows/backend.rs +++ b/src/tokio/pty/windows/backend.rs @@ -22,6 +22,10 @@ pub(in crate::tokio::pty) fn check_available() -> io::Result<()> { api::get().map(|_| ()) } +pub(in crate::tokio::pty) fn validate_size(size: PtySize) -> io::Result<()> { + console::coordinate(size).map(|_| ()) +} + pub(in crate::tokio::pty) fn spawn( attempt: &mut SpawnAttempt, size: PtySize, diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 4c6db42..afc8a79 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -24,7 +24,7 @@ mod pipe; mod program; mod spawn; -pub(super) use backend::{check_available, spawn}; +pub(super) use backend::{check_available, spawn, validate_size}; pub(super) use controller::{Input, Output, Resize}; #[derive(Debug, Eq, PartialEq)] diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 47cb097..cfef1a2 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -109,6 +109,68 @@ fn capability_queries_ignore_invalid_configuration_on_windows() { assert_eq!(invalid.check_available().is_ok(), checked); } +#[derive(Debug)] +struct ObservePreSpawn(Arc); + +impl CommandWrapper for ObservePreSpawn { + fn pre_spawn(&mut self, _attempt: &mut SpawnAttempt, _command: &Command) -> io::Result<()> { + self.0.store(true, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn oversized_windows_size_is_rejected_before_hooks_without_changing_capability() -> io::Result<()> { + let oversized = Pty::new(PtySize { + rows: 1, + columns: 32_768, + pixel_width: 0, + pixel_height: 0, + }); + let validation_error = oversized + .validate_command(&Command::new("must-not-run")) + .expect_err("Windows COORD overflow must be rejected during immutable validation"); + assert_eq!(validation_error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + validation_error.to_string(), + "PTY columns must not exceed 32767 on Windows" + ); + + let supported = Pty::check_supported(); + let available = oversized.check_available(); + match (&supported, &available) { + (Ok(()), Ok(())) => {} + (Err(supported), Err(available)) => { + assert_eq!(available.kind(), supported.kind()); + assert_eq!(available.to_string(), supported.to_string()); + } + _ => panic!("capability queries disagree: {supported:?} != {available:?}"), + } + + let called = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("must-not-run"); + command + .wrap(oversized) + .wrap(ObservePreSpawn(Arc::clone(&called))); + let spawn_error = match command.spawn() { + Ok(_) => panic!("an oversized Windows PTY spawn must fail"), + Err(error) => error, + }; + let expected_kind = match supported { + Ok(()) => io::ErrorKind::InvalidInput, + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + io::ErrorKind::Unsupported + } + }; + assert_eq!(spawn_error.kind(), expected_kind); + assert!( + !called.load(Ordering::SeqCst), + "capability and immutable validation must both precede pre_spawn hooks" + ); + Ok(()) +} + fn spawn_with_terminal( command: &mut Command, size: PtySize, From 97a9946a09f9c5e8404c149a304cb1f40be03994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:33:30 +1200 Subject: [PATCH 043/114] test(pty): bound synchronous lifecycle polling Co-Authored-By: Claude Code --- .github/workflows/test.yml | 2 + tests/tokio_pty_provider.rs | 150 ++++++++++++++++++++++++++++++++---- tests/tokio_pty_unix.rs | 64 +++++++++++++-- 3 files changed, 197 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0b21f8..72a1d72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,7 @@ jobs: name: "Test on ${{ matrix.platform }} with Rust ${{ matrix.toolchain }} (feat: ${{ matrix.features }})" runs-on: "${{ matrix.platform }}-latest" + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -101,6 +102,7 @@ jobs: name: "Test all features on ${{ matrix.platform }}" runs-on: "${{ matrix.platform }}-latest" + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/tests/tokio_pty_provider.rs b/tests/tokio_pty_provider.rs index fd0c3ef..2c808dc 100644 --- a/tests/tokio_pty_provider.rs +++ b/tests/tokio_pty_provider.rs @@ -18,7 +18,7 @@ use std::mem::MaybeUninit; use std::{ io, panic::{AssertUnwindSafe, catch_unwind, panic_any}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, mpsc}, time::{Duration, Instant}, }; @@ -281,6 +281,131 @@ async fn controller_is_committed_after_child_wrapping_hooks() -> io::Result<()> #[derive(Debug)] struct SpawnPostAttempt; +const SYNCHRONOUS_POLL_TIMEOUT: Duration = Duration::from_secs(5); +const SYNCHRONOUS_CLEANUP_TIMEOUT: Duration = Duration::from_secs(1); +const SYNCHRONOUS_POLL_INTERVAL: Duration = Duration::from_millis(10); + +fn poll_until_exit( + timeout: Duration, + mut try_wait: impl FnMut() -> io::Result>, +) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + if try_wait()?.is_some() { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + std::thread::sleep(SYNCHRONOUS_POLL_INTERVAL.min(deadline - now)); + } +} + +fn polling_timeout(stage: &str, pid: Option, cleanup: &str) -> io::Error { + let process = pid.map_or_else( + || "child with unknown PID".to_owned(), + |pid| format!("PID {pid}"), + ); + io::Error::new( + io::ErrorKind::TimedOut, + format!("{stage} timed out waiting for {process}; {cleanup}"), + ) +} + +fn wait_for_native_probe_exit( + probe: &mut tokio::process::Child, + timeout: Duration, + stage: &str, +) -> io::Result<()> { + if poll_until_exit(timeout, || probe.try_wait())? { + return Ok(()); + } + + let pid = probe.id(); + let kill_error = probe.start_kill().err(); + let reaped = poll_until_exit(SYNCHRONOUS_CLEANUP_TIMEOUT, || probe.try_wait())?; + let cleanup = if reaped { + "the directly owned child was reaped after a kill request".to_owned() + } else if let Some(error) = kill_error { + format!("kill failed ({error}) and the directly owned child was not reaped") + } else { + "the directly owned child did not become reapable after a kill request".to_owned() + }; + Err(polling_timeout(stage, pid, &cleanup)) +} + +fn wait_for_provider_child_exit( + child: &mut dyn ChildWrapper, + timeout: Duration, + stage: &str, +) -> io::Result<()> { + if poll_until_exit(timeout, || child.try_wait())? { + return Ok(()); + } + + let pid = child.id(); + let kill_error = child.start_kill().err(); + let reaped = poll_until_exit(SYNCHRONOUS_CLEANUP_TIMEOUT, || child.try_wait())?; + let cleanup = if reaped { + "the provider child exited after a kill request; transaction rollback remains armed" + .to_owned() + } else if let Some(error) = kill_error { + format!("best-effort kill failed ({error}); transaction rollback remains armed") + } else { + "bounded cleanup did not observe exit; transaction rollback remains armed".to_owned() + }; + Err(polling_timeout(stage, pid, &cleanup)) +} + +#[tokio::test] +async fn synchronous_native_probe_polling_times_out_and_reaps() -> io::Result<()> { + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "while :; do sleep 1; done"]); + let child = command.spawn()?; + let pid = child.id().expect("the long-lived probe has a PID"); + let (finished, finished_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let mut child = child; + let result = wait_for_native_probe_exit( + &mut child, + Duration::from_millis(50), + "controlled native probe", + ); + let reaped = child.try_wait(); + finished.send((result, reaped)).unwrap(); + }); + + let report = match finished_rx.recv_timeout(Duration::from_secs(2)) { + Ok(report) => report, + Err(mpsc::RecvTimeoutError::Timeout) => { + // SAFETY: `pid` identifies the still-live controlled child; this external release keeps the + // unbounded mutation from leaking its process or worker thread. + unsafe { libc::kill(i32::try_from(pid).unwrap(), libc::SIGKILL) }; + finished_rx + .recv_timeout(Duration::from_secs(2)) + .expect("the externally released polling worker returns") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the polling worker disconnected without a result") + } + }; + worker.join().unwrap(); + + let error = report + .0 + .expect_err("the local polling deadline must expire for a long-lived child"); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + let message = error.to_string(); + assert!(message.contains("controlled native probe")); + assert!(message.contains(&format!("PID {pid}"))); + assert!( + report.1?.is_some(), + "the directly owned probe must be reaped before timeout returns" + ); + Ok(()) +} + impl CommandWrapper for SpawnPostAttempt { fn post_spawn( &mut self, @@ -289,12 +414,11 @@ impl CommandWrapper for SpawnPostAttempt { _command: &Command, ) -> io::Result<()> { let mut probe = attempt.native_mut().spawn()?; - loop { - if probe.try_wait()?.is_some() { - return Ok(()); - } - std::thread::yield_now(); - } + wait_for_native_probe_exit( + &mut probe, + SYNCHRONOUS_POLL_TIMEOUT, + "post_spawn native probe", + ) } } @@ -494,12 +618,12 @@ impl CommandWrapper for ReapThenFail { .pid .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = child.id(); - loop { - if child.try_wait()?.is_some() { - return Err(io::Error::other("fail after reaping PTY child")); - } - std::thread::yield_now(); - } + wait_for_provider_child_exit( + child, + SYNCHRONOUS_POLL_TIMEOUT, + "post_spawn PTY child reaping", + )?; + Err(io::Error::other("fail after reaping PTY child")) } } diff --git a/tests/tokio_pty_unix.rs b/tests/tokio_pty_unix.rs index 06be9a1..d45d637 100644 --- a/tests/tokio_pty_unix.rs +++ b/tests/tokio_pty_unix.rs @@ -17,6 +17,8 @@ use std::any::TypeId; #[cfg(feature = "reset-sigmask")] use std::os::unix::process::ExitStatusExt; +#[cfg(any(feature = "process-group", feature = "process-session"))] +use std::time::Instant; use std::{io, process::ExitStatus, time::Duration}; #[cfg(all(feature = "kill-on-drop", feature = "process-session"))] @@ -399,6 +401,58 @@ async fn cancelling_wait_preserves_child_ownership() -> io::Result<()> { #[derive(Debug)] struct ReapBeforeChildWrapping; +#[cfg(any(feature = "process-group", feature = "process-session"))] +const SYNCHRONOUS_POLL_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(any(feature = "process-group", feature = "process-session"))] +const SYNCHRONOUS_CLEANUP_TIMEOUT: Duration = Duration::from_secs(1); +#[cfg(any(feature = "process-group", feature = "process-session"))] +const SYNCHRONOUS_POLL_INTERVAL: Duration = Duration::from_millis(10); + +#[cfg(any(feature = "process-group", feature = "process-session"))] +fn poll_until_exit( + timeout: Duration, + mut try_wait: impl FnMut() -> io::Result>, +) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + if try_wait()?.is_some() { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + std::thread::sleep(SYNCHRONOUS_POLL_INTERVAL.min(deadline - now)); + } +} + +#[cfg(any(feature = "process-group", feature = "process-session"))] +fn wait_for_provider_child_exit(child: &mut dyn ChildWrapper, stage: &str) -> io::Result<()> { + if poll_until_exit(SYNCHRONOUS_POLL_TIMEOUT, || child.try_wait())? { + return Ok(()); + } + + let pid = child.id(); + let kill_error = child.start_kill().err(); + let reaped = poll_until_exit(SYNCHRONOUS_CLEANUP_TIMEOUT, || child.try_wait())?; + let process = pid.map_or_else( + || "child with unknown PID".to_owned(), + |pid| format!("PID {pid}"), + ); + let cleanup = if reaped { + "the provider child exited after a kill request; transaction rollback remains armed" + .to_owned() + } else if let Some(error) = kill_error { + format!("best-effort kill failed ({error}); transaction rollback remains armed") + } else { + "bounded cleanup did not observe exit; transaction rollback remains armed".to_owned() + }; + Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("{stage} timed out waiting for {process}; {cleanup}"), + )) +} + #[cfg(any(feature = "process-group", feature = "process-session"))] impl CommandWrapper for ReapBeforeChildWrapping { fn post_spawn( @@ -407,12 +461,10 @@ impl CommandWrapper for ReapBeforeChildWrapping { child: &mut dyn ChildWrapper, _command: &Command, ) -> io::Result<()> { - loop { - if child.try_wait()?.is_some() { - return Ok(()); - } - std::thread::yield_now(); - } + wait_for_provider_child_exit( + child, + "post_spawn PTY child reaping before wrapper installation", + ) } } From 502890a7f9f915d6b54bef3a98b05d22a16e2125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:38:29 +1200 Subject: [PATCH 044/114] test(pty): require ConPTY on supported Windows Co-Authored-By: Claude Code --- .github/workflows/test.yml | 14 +++++++ tests/tokio_pty_windows.rs | 83 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72a1d72..5b58d64 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,8 @@ jobs: name: "Test on ${{ matrix.platform }} with Rust ${{ matrix.toolchain }} (feat: ${{ matrix.features }})" runs-on: "${{ matrix.platform }}-latest" timeout-minutes: 30 + env: + PROCESS_WRAP_EXPECT_CONPTY: "${{ matrix.platform == 'windows' && '1' || '0' }}" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -42,6 +44,11 @@ jobs: rustup default ${{ matrix.toolchain }} rustup component add clippy + - name: Report expected ConPTY mode + if: matrix.platform == 'windows' + shell: pwsh + run: Write-Host "PROCESS_WRAP_EXPECT_CONPTY=$env:PROCESS_WRAP_EXPECT_CONPTY" + - run: tests/multiproc_helper.rs 1 1 - name: Test defaults plus frontend @@ -103,6 +110,8 @@ jobs: name: "Test all features on ${{ matrix.platform }}" runs-on: "${{ matrix.platform }}-latest" timeout-minutes: 30 + env: + PROCESS_WRAP_EXPECT_CONPTY: "${{ matrix.platform == 'windows' && '1' || '0' }}" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -116,6 +125,11 @@ jobs: rustup default stable rustup component add clippy + - name: Report expected ConPTY mode + if: matrix.platform == 'windows' + shell: pwsh + run: Write-Host "PROCESS_WRAP_EXPECT_CONPTY=$env:PROCESS_WRAP_EXPECT_CONPTY" + - run: tests/multiproc_helper.rs 1 1 - run: cargo test --locked --all-features --all-targets - run: cargo clippy --locked --all-features --all-targets diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index cfef1a2..0556611 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -55,14 +55,41 @@ use windows::core::PCWSTR; mod windows_thread; const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; +const EXPECT_CONPTY: &str = "PROCESS_WRAP_EXPECT_CONPTY"; const TIMEOUT: Duration = Duration::from_secs(10); const DROP_CLEANUP_TIMEOUT_MS: u32 = 1_000; +fn conpty_is_required() -> bool { + matches!(env::var(EXPECT_CONPTY), Ok(value) if value == "1") +} + +#[derive(Debug)] +enum ConPtyDecision { + Run, + Skip(io::Error), +} + +fn require_conpty_result(capability: io::Result<()>, required: bool) -> io::Result { + match capability { + Ok(()) => Ok(ConPtyDecision::Run), + Err(error) if error.kind() == io::ErrorKind::Unsupported && required => { + Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "ConPTY is required because {EXPECT_CONPTY}=1, but it is unavailable: {error}" + ), + )) + } + Err(error) if error.kind() == io::ErrorKind::Unsupported => Ok(ConPtyDecision::Skip(error)), + Err(error) => Err(error), + } +} + macro_rules! require_conpty { () => { - match Pty::check_supported() { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::Unsupported => { + match require_conpty_result(Pty::check_supported(), conpty_is_required()) { + Ok(ConPtyDecision::Run) => {} + Ok(ConPtyDecision::Skip(error)) => { eprintln!("skipping ConPTY-dependent test: {error}"); return Ok(()); } @@ -96,6 +123,26 @@ fn capability_queries_agree_with_windows_backend() { assert_eq!(Pty::is_supported(), checked); } +#[test] +fn expected_support_rejects_injected_unsupported_capability() { + let unsupported = || { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "injected missing ConPTY capability", + )) + }; + let error = require_conpty_result(unsupported(), true) + .expect_err("expected-support mode must reject an unavailable ConPTY backend"); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert!(error.to_string().contains(EXPECT_CONPTY)); + + let decision = require_conpty_result(unsupported(), false).unwrap(); + let ConPtyDecision::Skip(error) = decision else { + panic!("optional ConPTY mode must retain the unavailable-runtime skip"); + }; + assert_eq!(error.kind(), io::ErrorKind::Unsupported); +} + #[test] fn capability_queries_ignore_invalid_configuration_on_windows() { let invalid = Pty::new(PtySize { @@ -600,6 +647,36 @@ fn print_size(output: HANDLE) -> io::Result<()> { Ok(()) } +#[tokio::test] +async fn expected_supported_conpty_preflight_performs_a_real_spawn() -> io::Result<()> { + if !conpty_is_required() { + eprintln!("expected-supported ConPTY preflight is disabled"); + return Ok(()); + } + match require_conpty_result(Pty::check_supported(), true)? { + ConPtyDecision::Run => {} + ConPtyDecision::Skip(_) => unreachable!("required ConPTY cannot resolve to a skip"), + } + + let mut command = helper("terminal")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(b"PW-TERMINALS:111".len()) + .any(|window| window == b"PW-TERMINALS:111") + ); + eprintln!("expected-supported ConPTY preflight executed a real PTY spawn"); + Ok(()) +} + #[tokio::test] async fn spawns_with_three_console_standard_handles() -> io::Result<()> { require_conpty!(); From ad4bf9a35e30bed4c56fc7012382b958278dcf1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:40:13 +1200 Subject: [PATCH 045/114] ci(pty): compile unsupported-target integration tests Co-Authored-By: Claude Code --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b58d64..656bc52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -200,6 +200,10 @@ jobs: cargo clippy --locked --target "${{ matrix.target }}" --no-default-features --features pty --lib fi + - name: Compile unsupported PTY integration test + if: matrix.target == 'aarch64-apple-ios' + run: cargo check --locked --target aarch64-apple-ios --all-features --test tokio_pty_unsupported + rustdoc: name: Rustdoc runs-on: ubuntu-latest From 55cd76ee4c11a56c4b76bbb86a60a6efcff4fd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 00:47:51 +1200 Subject: [PATCH 046/114] docs(provider): scope panic recovery to unwinding Co-Authored-By: Claude Code --- README.md | 9 ++++++--- src/command.rs | 4 ++-- src/generic_wrap.rs | 24 ++++++++++++++---------- src/lib.rs | 11 +++++++---- src/std/core.rs | 2 +- src/tokio/core.rs | 2 +- src/tokio/pty/windows/spawn.rs | 4 ++-- 7 files changed, 33 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index d8559f2..2cd765c 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ The trait provides extension or hook points into the lifecycle of a `Command`: only one registered wrapper may expose one. Pre-spawn, post-spawn, and child-wrapping hooks all run in registration order and stop at the first -error or panic. The active wrapper remains registered but is temporarily unavailable through +error or unwinding panic. The active wrapper remains registered but is temporarily unavailable through `get_wrap`; peer wrappers remain visible. ### Spawn providers @@ -372,8 +372,11 @@ Callbacks run in this order: Validation must reject unsupported portable policy before allocating operating-system resources. `spawn` returns a child satisfying the frontend's complete `ChildWrapper` contract together with a fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. A -later hook, wrapper, or commit error/panic causes best-effort rollback while preserving the original -failure. Until `spawn` returns the product, cleanup remains the provider's responsibility. +later hook, wrapper, or commit error or unwinding panic causes best-effort rollback while preserving +the original failure. Rollback, wrapper restoration, original panic-payload preservation, and +cleanup-diagnostic panic containment apply only to unwinding panics. With `panic=abort`, the process +terminates before those guarantees can run. Until `spawn` returns the product, cleanup remains the +provider's responsibility. A command may register only one provider; conflicts are rejected before any provider callback or operating-system allocation. Both providers and wrapper state are reused across repeated spawns. diff --git a/src/command.rs b/src/command.rs index 09e4525..61197b0 100644 --- a/src/command.rs +++ b/src/command.rs @@ -550,8 +550,8 @@ impl fmt::Debug for CommandState { /// hooks directly. These panic guarantees require unwinding; `panic=abort` terminates the process /// before rollback or payload preservation can run. /// -/// Until a provider returns its `ProviderProduct`, cleanup for errors or panics in its own `spawn` -/// implementation remains the provider's responsibility. +/// Until a provider returns its `ProviderProduct`, cleanup for errors or unwinding panics in its own +/// `spawn` implementation remains the provider's responsibility. pub trait SpawnTransaction: fmt::Debug + Send + 'static { /// Finalize the successful spawn and disarm rollback resources. fn commit(&mut self) -> std::io::Result<()>; diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index da0602e..766d937 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -104,6 +104,10 @@ macro_rules! Wrap { /// completes the pre-commit child phase while provider rollback remains armed, commits and drops /// the transaction, and finally disarms the sole JobObject cleanup owner. `spawn_with` and /// `spawn_with_child` reject a registered provider instead of bypassing it. + /// + /// Rollback, wrapper restoration, and original panic-payload preservation apply only to + /// unwinding panics. With `panic=abort`, the process terminates before lifecycle recovery can + /// run. pub trait SpawnProvider: ::std::fmt::Debug + Send + Sync + 'static { /// Check whether this provider is available on the current platform and runtime. /// @@ -139,7 +143,7 @@ macro_rules! Wrap { /// /// The provider must honor every portable setting accepted by `validate_attempt`. Until this /// method returns a `ProviderProduct`, it remains responsible for cleaning up resources and any - /// child it creates if it returns an error or panics. + /// child it creates if it returns an error or an unwinding panic. fn spawn( &self, attempt: &mut SpawnAttempt, @@ -675,9 +679,9 @@ macro_rules! Wrap { /// Called before the command is spawned, to mutate this attempt as needed. /// - /// Hooks run in registration order and stop at the first error or panic. Mutations to an attempt - /// copied from a tracked command apply to that spawn only. A native-only base instead retains - /// native mutations when process-wrap restores it after the lifecycle. + /// Hooks run in registration order and stop at the first error or unwinding panic. Mutations to + /// an attempt copied from a tracked command apply to that spawn only. A native-only base instead + /// retains native mutations when process-wrap restores it after the lifecycle. /// /// Calling `SpawnAttempt::native_mut`, directly or through `stdin`, `stdout`, or `stderr`, makes a /// tracked attempt opaque. A registered portable provider rejects it after all pre-spawn hooks @@ -717,12 +721,12 @@ macro_rules! Wrap { /// Called after any transport spawns a child, but before the child is wrapped. /// - /// Hooks run in registration order and stop at the first error or panic. The child is exposed - /// through the frontend's object-safe capability trait, so it may be a terminal custom or + /// Hooks run in registration order and stop at the first error or unwinding panic. The child is + /// exposed through the frontend's object-safe capability trait, so it may be a terminal custom or /// provider child with no native child value. The transport has already created it: changing /// command settings on `attempt` here cannot configure that child. /// - /// On the provider path, an error or panic triggers best-effort transaction rollback. Native + /// On the provider path, an error or unwinding panic triggers best-effort transaction rollback. Native /// transports do not promise equivalent child cleanup on every platform. /// /// Default implementation: no-op. @@ -739,9 +743,9 @@ macro_rules! Wrap { /// /// If the wrapper needs to override methods on the child, it should create an instance of its /// own type implementing `ChildWrapper` and return it here. Wrappers run in registration order - /// and stop at the first error or panic, so `.wrap(Foo).wrap(Bar)` produces an outer - /// `Bar(Foo(child))` layer. On the provider path, an error or panic triggers best-effort - /// transaction rollback. + /// and stop at the first error or unwinding panic, so `.wrap(Foo).wrap(Bar)` produces an outer + /// `Bar(Foo(child))` layer. On the provider path, an error or unwinding panic triggers + /// best-effort transaction rollback. /// /// Default implementation: no-op (returns the child unchanged). fn wrap_child( diff --git a/src/lib.rs b/src/lib.rs index 6c49007..827711f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -246,8 +246,8 @@ //! lifecycle, and only one registered wrapper may expose one. By default returns `None`. //! //! Pre-spawn, post-spawn, and child-wrapping hooks all run in registration order and stop at the first -//! error or panic. The active wrapper remains registered but is temporarily unavailable through -//! `get_wrap`; peer wrappers remain visible. +//! error or unwinding panic. The active wrapper remains registered but is temporarily unavailable +//! through `get_wrap`; peer wrappers remain visible. //! //! ## Spawn providers //! @@ -268,8 +268,11 @@ //! Validation rejects unsupported portable policy before operating-system allocation. `spawn` //! returns a child satisfying the frontend's complete `ChildWrapper` contract and a fresh, armed //! `SpawnTransaction` which owns cleanup independently of the child chain. A later public hook, -//! wrapper, or commit error/panic causes best-effort rollback while preserving the original failure. -//! Cleanup before `spawn` returns that product remains the provider's responsibility. +//! wrapper, or commit error or unwinding panic causes best-effort rollback while preserving the +//! original failure. Rollback, wrapper restoration, original panic-payload preservation, and +//! cleanup-diagnostic panic containment apply only to unwinding panics. With `panic=abort`, the +//! process terminates before those guarantees can run. Cleanup before `spawn` returns that product +//! remains the provider's responsibility. //! //! A command may register only one provider; conflicts are rejected before callbacks or allocation. //! Providers and wrapper state are reusable across repeated spawns. `spawn_with` and diff --git a/src/std/core.rs b/src/std/core.rs index 193e7f1..13b2edc 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -127,7 +127,7 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Disarm Windows cleanup state owned by this child layer. /// /// Process-wrap invokes this internal hook only after every ordinary spawn finalizer succeeds, so - /// cleanup remains armed if any earlier finalizer errors or panics. + /// cleanup remains armed if any earlier finalizer errors or hits an unwinding panic. #[doc(hidden)] #[cfg(windows)] fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index cad5c7a..bc795a7 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -150,7 +150,7 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Disarm Windows cleanup state owned by this child layer. /// /// Process-wrap invokes this internal hook only after every ordinary spawn finalizer succeeds, so - /// cleanup remains armed if any earlier finalizer errors or panics. + /// cleanup remains armed if any earlier finalizer errors or hits an unwinding panic. #[doc(hidden)] #[cfg(windows)] fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index 8ea0532..e1e2794 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -362,8 +362,8 @@ impl PendingDiagnostics { } } - /// Emit one snapshot. Returns true after atomically reserving a later retry for entries that - /// panicked or arrived while this emitter was active. + /// Emit one snapshot. Returns true after atomically reserving a later retry for entries whose + /// diagnostic emission hit an unwinding panic or that arrived while this emitter was active. fn emit(&self) -> bool { #[cfg(feature = "tracing")] { From 07596899bfcaf4cfd4921712fba86bd5d94ef924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 01:59:43 +1200 Subject: [PATCH 047/114] docs(pty): record ConPTY FFI premises Co-Authored-By: Claude Code --- src/tokio/pty/windows/attributes.rs | 15 +++++++++++++-- src/tokio/pty/windows/spawn.rs | 17 ++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/tokio/pty/windows/attributes.rs b/src/tokio/pty/windows/attributes.rs index 6e2c85f..c804665 100644 --- a/src/tokio/pty/windows/attributes.rs +++ b/src/tokio/pty/windows/attributes.rs @@ -1,4 +1,13 @@ //! Aligned ownership for a pseudo-console process-thread attribute list. +//! +//! # External premise +//! +//! This module accepts **TCB premise G1**: on every supported Windows target ABI, storage aligned +//! to `align_of::()` satisfies the alignment requirement of +//! `InitializeProcThreadAttributeList` and every later access through +//! `LPPROC_THREAD_ATTRIBUTE_LIST`. Microsoft documents the byte-size query but publishes no +//! independent alignment bound in the +//! [`InitializeProcThreadAttributeList` contract](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-initializeprocthreadattributelist). use std::{ffi::c_void, io, mem::size_of}; @@ -29,8 +38,10 @@ impl AttributeList { let words = bytes.div_ceil(size_of::()); let mut storage = vec![0usize; words].into_boxed_slice(); let list = LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast()); - // SAFETY: storage is pointer-aligned, has at least the queried byte size, and remains owned by - // the returned guard until after DeleteProcThreadAttributeList runs. + // SAFETY: storage starts at an address aligned to align_of::(), has at least the queried + // byte capacity, and remains owned by the returned guard until after + // DeleteProcThreadAttributeList runs. TCB premise G1 above supplies the opaque Win32 list's + // alignment requirement. unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut bytes) } .map_err(io::Error::other)?; diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index e1e2794..4689e38 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -1,4 +1,15 @@ //! Exact `CreateProcessW` spawning for a prepared ConPTY command. +//! +//! # External premise +//! +//! This module accepts **TCB premise G2**: process creation with +//! `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` is an attachment to a new console for the null-standard- +//! handle replacement rule when `STARTF_USESTDHANDLES` is present. This premise reconciles the +//! [`CreateProcessW` contract](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw), +//! which requires valid standard-handle slots, with the +//! [`GetStdHandle` contract](https://learn.microsoft.com/en-us/windows/console/getstdhandle), which +//! describes replacement of null slots when attaching to a new console. The native standard-handle +//! regression checks deployed behavior but does not establish this platform contract. use std::{ ffi::c_void, @@ -54,9 +65,9 @@ pub(super) fn spawn( // SAFETY: every pointer references live, NUL-terminated or explicitly bounded storage for the // duration of the call. The command line is uniquely mutable, the startup attribute list owns its - // aligned backing storage, no native handles are inherited, and STARTF_USESTDHANDLES with three null - // slots asks ConPTY to install its console handles. The process-information value is writable output - // storage. + // aligned backing storage, and no native handles are inherited. Under TCB premise G2 above, + // STARTF_USESTDHANDLES with three null slots causes ConPTY to install its console handles. The + // process-information value is writable output storage. let created = unsafe { CreateProcessW( PCWSTR(command.application_name.as_ptr()), From 9fc3ee8d7501997529427e63231a3e6a14765b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 03:02:53 +1300 Subject: [PATCH 048/114] docs(pty): document the portable migration flow Co-Authored-By: Claude Code --- README.md | 20 +++++++++++++++++--- src/lib.rs | 12 ++++++++++++ src/tokio/pty.rs | 5 +++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2cd765c..0693394 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,18 @@ process-wrap = { version = "10.0.0", features = ["pty"] } ``` ```rust -use process_wrap::tokio::*; +use process_wrap::tokio::{Command, Pty}; use tokio::io::AsyncReadExt; -let mut command = Command::new("ls"); -command.wrap(ProcessSession).wrap(Pty::default()); +#[cfg(unix)] +let mut command = Command::with_new("sh", |command| { + command.args(["-c", "printf terminal"]); +}); +#[cfg(windows)] +let mut command = Command::with_new("cmd.exe", |command| { + command.args(["/d", "/s", "/c", "echo terminal"]); +}); +command.wrap(Pty::default()); let mut child = command.spawn()?; let controller = child .take_pty_controller() @@ -129,6 +136,13 @@ conditionally, use `Pty::check_supported()` for a capability result or `Pty::is_ boolean. These report platform and runtime capability only; they do not suppress later configuration, compatibility, or spawn errors. +#### Migrating from the former PTY prototype + +Move command and terminal configuration to the shared Tokio `Command` and `Pty` values. Register +that `Pty` as the spawn provider with `.wrap(Pty::default())` (or `.wrap(configured_pty)`), then use +ordinary `.spawn()` and its ordinary boxed-child result. Call `take_pty_controller()` once on that +returned child to obtain terminal I/O and resize control. + 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; diff --git a/src/lib.rs b/src/lib.rs index 827711f..2b3cf49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,9 +122,14 @@ //! # fn run() -> std::io::Result<()> { //! use process_wrap::tokio::{Command, Pty}; //! +//! #[cfg(unix)] //! let mut command = Command::with_new("sh", |command| { //! command.args(["-c", "printf terminal"]); //! }); +//! #[cfg(windows)] +//! let mut command = Command::with_new("cmd.exe", |command| { +//! command.args(["/d", "/s", "/c", "echo terminal"]); +//! }); //! command.wrap(Pty::default()); //! let mut child = command.spawn()?; //! let controller = child @@ -143,6 +148,13 @@ //! report platform and runtime capability only; they do not suppress later configuration, //! compatibility, or spawn errors. //! +//! ## Migrating from the former PTY prototype +//! +//! Move command and terminal configuration to the shared Tokio `Command` and `Pty` values. Register +//! that `Pty` as the spawn provider with `.wrap(Pty::default())` (or `.wrap(configured_pty)`), then +//! use ordinary `.spawn()` and its ordinary boxed-child result. Call `take_pty_controller()` once on +//! that returned child to obtain terminal I/O and resize control. +//! //! 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 diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index 8901a00..9f988f4 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -126,9 +126,14 @@ use windows as imp; /// # use std::io; /// use process_wrap::tokio::{Command, Pty}; /// # fn run() -> io::Result<()> { +/// #[cfg(unix)] /// let mut command = Command::with_new("sh", |command| { /// command.args(["-c", "printf terminal"]); /// }); +/// #[cfg(windows)] +/// let mut command = Command::with_new("cmd.exe", |command| { +/// command.args(["/d", "/s", "/c", "echo terminal"]); +/// }); /// command.wrap(Pty::default()); /// let mut child = command.spawn()?; /// let controller = child From 14c3ae5cab196aedb9c2cc78ebc3c24f5bf869a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 05:10:31 +1300 Subject: [PATCH 049/114] fix(windows): wait for complete JobObject drain Co-Authored-By: Claude Code --- src/std/job_object.rs | 64 +++-- src/tokio/job_object.rs | 341 ++++---------------------- src/windows.rs | 127 ++++++++-- tests/std_windows/mod.rs | 3 +- tests/std_windows/process_handle.rs | 166 ++++++++++++- tests/tokio_pty_windows.rs | 69 +++++- tests/tokio_windows/mod.rs | 3 +- tests/tokio_windows/process_handle.rs | 202 ++++++++++++++- 8 files changed, 627 insertions(+), 348 deletions(-) diff --git a/src/std/job_object.rs b/src/std/job_object.rs index b68677a..1c01c23 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -16,8 +16,8 @@ use windows::Win32::{ use crate::{ ChildExitStatus, windows::{ - JobPort, job_creation_flags, make_job_object, resume_threads, set_job_kill_on_drop, - terminate_job, wait_on_job, + JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, + resume_threads, set_job_kill_on_drop, terminate_job, }, }; @@ -167,6 +167,7 @@ impl CommandWrapper for JobObject { pub struct JobObjectChild { inner: Box, exit_status: ChildExitStatus, + job_drained: bool, job_port: JobPort, final_kill_on_drop: bool, spawn_finalized: bool, @@ -182,6 +183,7 @@ impl JobObjectChild { Self { inner, exit_status: ChildExitStatus::Running, + job_drained: false, job_port, final_kill_on_drop, spawn_finalized: false, @@ -236,26 +238,54 @@ impl ChildWrapper for JobObjectChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Result { - if let ChildExitStatus::Exited(status) = &self.exit_status { - return Ok(*status); - } - - // always wait for parent to exit first, as by the time it does, - // it's likely that all its children have already exited. - let status = self.inner.wait()?; - self.exit_status = ChildExitStatus::Exited(status); + let status = match self.exit_status { + ChildExitStatus::Running => { + // Direct-child exit and whole-job drain are separate states. Cache the direct status so + // repeated calls do not lose it while the job still contains descendants. + let status = self.inner.wait()?; + self.exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; - // nevertheless, now wait and make sure we reap all children. - let _ = wait_on_job(self.job_port.completion_port.as_handle(), None)?; + while !self.job_drained { + if poll_job_drain( + self.job_port.job, + self.job_port.completion_port.as_handle(), + JOB_POLL_INTERVAL, + )? + .is_break() + { + self.job_drained = true; + } + } Ok(status) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - let _ = wait_on_job( - self.job_port.completion_port.as_handle(), - Some(Duration::ZERO), - )?; - self.inner.try_wait() + if matches!(self.exit_status, ChildExitStatus::Running) { + let Some(status) = self.inner.try_wait()? else { + return Ok(None); + }; + self.exit_status = ChildExitStatus::Exited(status); + } + + if !self.job_drained + && poll_job_drain( + self.job_port.job, + self.job_port.completion_port.as_handle(), + Duration::ZERO, + )? + .is_break() + { + self.job_drained = true; + } + + match (self.exit_status, self.job_drained) { + (ChildExitStatus::Exited(status), true) => Ok(Some(status)), + _ => Ok(None), + } } } diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index a6c251e..1250ba7 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -2,13 +2,12 @@ use std::{ any::Any, future::Future, io::{Error, ErrorKind, Result}, - os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}, + os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}, pin::Pin, process::ExitStatus, time::Duration, }; -use tokio::task::spawn_blocking; #[cfg(feature = "tracing")] use tracing::{debug, instrument}; use windows::Win32::{ @@ -19,52 +18,11 @@ use windows::Win32::{ use crate::{ ChildExitStatus, windows::{ - JobPort, job_creation_flags, make_job_object, resume_threads, set_job_kill_on_drop, - terminate_job, wait_on_job, + JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, + resume_threads, set_job_kill_on_drop, terminate_job, }, }; -#[cfg(test)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum BlockingWaitEvent { - Begin { handle: usize, owned: bool }, - End { handle: usize }, - Dropped { handle: usize }, -} - -#[cfg(test)] -fn blocking_wait_observer() --> &'static std::sync::Mutex>> { - static OBSERVER: std::sync::OnceLock< - std::sync::Mutex>>, - > = std::sync::OnceLock::new(); - OBSERVER.get_or_init(|| std::sync::Mutex::new(None)) -} - -#[cfg(test)] -fn observe_blocking_wait(event: BlockingWaitEvent) { - if let Some(observer) = blocking_wait_observer().lock().unwrap().as_ref() { - let _ = observer.send(event); - } -} - -fn blocking_wait_on_job(completion_port: OwnedHandle) -> Result> { - #[cfg(test)] - let handle = completion_port.as_raw_handle() as usize; - #[cfg(test)] - observe_blocking_wait(BlockingWaitEvent::Begin { - handle, - owned: true, - }); - let result = wait_on_job(completion_port.as_handle(), None); - #[cfg(test)] - observe_blocking_wait(BlockingWaitEvent::End { handle }); - drop(completion_port); - #[cfg(test)] - observe_blocking_wait(BlockingWaitEvent::Dropped { handle }); - result -} - #[cfg(feature = "creation-flags")] use super::CreationFlags; #[cfg(feature = "kill-on-drop")] @@ -221,6 +179,7 @@ impl CommandWrapper for JobObject { pub struct JobObjectChild { inner: Box, exit_status: ChildExitStatus, + job_drained: bool, job_port: JobPort, final_kill_on_drop: bool, spawn_finalized: bool, @@ -236,6 +195,7 @@ impl JobObjectChild { Self { inner, exit_status: ChildExitStatus::Running, + job_drained: false, job_port, final_kill_on_drop, spawn_finalized: false, @@ -291,278 +251,59 @@ impl ChildWrapper for JobObjectChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Pin> + Send + '_>> { Box::pin(async { - if let ChildExitStatus::Exited(status) = &self.exit_status { - return Ok(*status); - } - - const MAX_RETRY_ATTEMPT: usize = 10; - - // always wait for parent to exit first, as by the time it does, - // it's likely that all its children have already exited. - let status = self.inner.wait().await?; - self.exit_status = ChildExitStatus::Exited(status); + let status = match self.exit_status { + ChildExitStatus::Running => { + // Cache direct-child exit independently from whole-job drain. There is no await + // between observing the status and retaining it, so cancellation cannot lose it. + let status = self.inner.wait().await?; + self.exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; - // nevertheless, now try reaping all children a few times... - for _ in 1..MAX_RETRY_ATTEMPT { - if wait_on_job( + while !self.job_drained { + if poll_job_drain( + self.job_port.job, self.job_port.completion_port.as_handle(), - Some(Duration::ZERO), + Duration::ZERO, )? .is_break() { - return Ok(status); + // No await occurs between the authoritative accounting result and this durable + // transition, so a canceled future cannot consume the drain state. + self.job_drained = true; + break; } + tokio::time::sleep(JOB_POLL_INTERVAL).await; } - - // ...finally, if there are some that are still alive, - // block in the background to reap them fully. The detached closure owns only a duplicate - // completion-port handle so canceling this future cannot make its native wait stale or keep - // the JobObject itself alive. - let completion_port = self.job_port.completion_port.try_clone()?; - let _ = spawn_blocking(move || blocking_wait_on_job(completion_port)).await??; Ok(status) }) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - let _ = wait_on_job( - self.job_port.completion_port.as_handle(), - Some(Duration::ZERO), - )?; - self.inner.try_wait() - } -} - -#[cfg(test)] -mod tests { - use std::{ - future::Future, - io, - os::windows::{ - io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, - process::ExitStatusExt, - }, - pin::Pin, - process::ExitStatus, - sync::mpsc, - time::Duration, - }; - - use windows::Win32::{ - Foundation::{ - DUPLICATE_SAME_ACCESS, DuplicateHandle, GetHandleInformation, HANDLE, - INVALID_HANDLE_VALUE, - }, - System::{ - IO::{CreateIoCompletionPort, PostQueuedCompletionStatus}, - JobObjects::CreateJobObjectW, - Threading::{CreateEventW, GetCurrentProcess}, - }, - }; - - use super::*; - use crate::windows::{JobHandle, JobPort}; - - #[derive(Debug)] - struct ImmediateChild; - - impl ChildWrapper for ImmediateChild { - fn inner(&self) -> &dyn ChildWrapper { - self - } - - fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self - } - - fn into_inner(self: Box) -> Box { - self - } - - fn id(&self) -> Option { - Some(1) - } - - fn wait(&mut self) -> Pin> + Send + '_>> { - Box::pin(async { Ok(ExitStatus::from_raw(0)) }) + if matches!(self.exit_status, ChildExitStatus::Running) { + let Some(status) = self.inner.try_wait()? else { + return Ok(None); + }; + self.exit_status = ChildExitStatus::Exited(status); } - } - - struct ObserverGuard; - impl ObserverGuard { - fn install() -> (Self, mpsc::Receiver) { - let (sender, receiver) = mpsc::channel(); - let mut observer = blocking_wait_observer().lock().unwrap(); - assert!( - observer.is_none(), - "blocking wait observer is already installed" - ); - *observer = Some(sender); - (Self, receiver) + if !self.job_drained + && poll_job_drain( + self.job_port.job, + self.job_port.completion_port.as_handle(), + Duration::ZERO, + )? + .is_break() + { + self.job_drained = true; } - } - impl Drop for ObserverGuard { - fn drop(&mut self) { - *blocking_wait_observer().lock().unwrap() = None; + match (self.exit_status, self.job_drained) { + (ChildExitStatus::Exited(status), true) => Ok(Some(status)), + _ => Ok(None), } } - - fn owned(handle: HANDLE) -> OwnedHandle { - // SAFETY: each caller transfers one newly created or duplicated owned handle. - unsafe { OwnedHandle::from_raw_handle(handle.0) } - } - - fn job_port() -> io::Result { - // SAFETY: default attributes and no name create a uniquely owned job handle. - let job = owned(unsafe { CreateJobObjectW(None, None) }.map_err(io::Error::other)?); - // SAFETY: these arguments create a new completion port which is uniquely owned. - let port = owned(unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }?); - Ok(JobPort { - job: JobHandle(HANDLE(job.into_raw_handle())), - completion_port: port, - }) - } - - fn duplicate(handle: HANDLE) -> io::Result { - // SAFETY: this returns the current process pseudo-handle, which stays valid for this process. - let current = unsafe { GetCurrentProcess() }; - let mut duplicate = HANDLE::default(); - // SAFETY: `handle` is live, `duplicate` is writable, and success transfers a distinct owned - // handle in the current process. - unsafe { - DuplicateHandle( - current, - handle, - current, - &mut duplicate, - 0, - false, - DUPLICATE_SAME_ACCESS, - ) - }?; - Ok(owned(duplicate)) - } - - fn handle_is_live(raw: usize) -> bool { - let mut flags = 0; - // SAFETY: the call only queries the scalar handle value and writes `flags` on success. - unsafe { GetHandleInformation(HANDLE(raw as *mut _), &mut flags) }.is_ok() - } - - fn occupy_reused_handle(raw: usize) -> OwnedHandle { - let mut retained = Vec::new(); - for _ in 0..4096 { - // SAFETY: default security, manual reset, initially unsignaled, and no name create one event. - let replacement = owned(unsafe { CreateEventW(None, true, false, None) }.unwrap()); - if replacement.as_raw_handle() as usize == raw { - return replacement; - } - retained.push(replacement); - } - panic!("Windows did not reuse the closed completion-port slot under stress"); - } - - const CANCELED_WAIT_HELPER: &str = "PROCESS_WRAP_CANCELED_JOB_WAIT_HELPER"; - - #[test] - fn canceled_blocking_wait_owns_only_a_duplicate_completion_port() { - if std::env::var_os(CANCELED_WAIT_HELPER).is_none() { - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "tokio::job_object::tests::canceled_blocking_wait_owns_only_a_duplicate_completion_port", - "--exact", - "--nocapture", - ]) - .env(CANCELED_WAIT_HELPER, "1") - .status() - .unwrap(); - assert!( - status.success(), - "the isolated handle-reuse regression failed" - ); - return; - } - - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .max_blocking_threads(1) - .enable_time() - .build() - .unwrap(); - runtime.block_on(async { - let (blocker_started, blocker_started_rx) = mpsc::channel(); - let (release_blocker, release_blocker_rx) = mpsc::channel(); - let blocker = tokio::task::spawn_blocking(move || { - blocker_started.send(()).unwrap(); - release_blocker_rx.recv().unwrap(); - }); - blocker_started_rx - .recv_timeout(Duration::from_secs(2)) - .expect("the sole blocking worker starts"); - - let job_port = job_port().unwrap(); - let original_job = job_port.job.0.0 as usize; - let original = job_port.completion_port.as_raw_handle() as usize; - let controller = duplicate(HANDLE(job_port.completion_port.as_raw_handle())).unwrap(); - let mut child = JobObjectChild::new(Box::new(ImmediateChild), job_port, false); - let (_observer, observations) = ObserverGuard::install(); - - let timed_out = tokio::time::timeout(Duration::from_millis(100), child.wait()).await; - assert!( - timed_out.is_err(), - "the queued blocking wait must not start yet" - ); - drop(child); - assert!( - !handle_is_live(original_job), - "the delayed wait must not retain the JobObject" - ); - assert!( - !handle_is_live(original), - "dropping the child closes the original port" - ); - let replacement = occupy_reused_handle(original); - - release_blocker.send(()).unwrap(); - let begin = observations - .recv_timeout(Duration::from_secs(2)) - .expect("the delayed completion-port wait begins"); - let BlockingWaitEvent::Begin { handle, owned } = begin else { - panic!("the first observation must begin the wait: {begin:?}"); - }; - assert!(owned, "the delayed wait must retain an owned duplicate"); - assert_ne!( - handle, original, - "the delayed wait must not reuse the original slot" - ); - assert!( - handle_is_live(handle), - "the delayed wait's duplicate is live" - ); - // SAFETY: `controller` owns a live duplicate of the same completion port. - unsafe { PostQueuedCompletionStatus(HANDLE(controller.as_raw_handle()), 0, 0, None) } - .unwrap(); - assert_eq!( - observations.recv_timeout(Duration::from_secs(2)).unwrap(), - BlockingWaitEvent::End { handle } - ); - assert_eq!( - observations.recv_timeout(Duration::from_secs(2)).unwrap(), - BlockingWaitEvent::Dropped { handle } - ); - assert!( - !handle_is_live(handle), - "the duplicate closes after the wait returns" - ); - assert!( - handle_is_live(original), - "the replacement object still owns the reused slot" - ); - drop(replacement); - blocker.await.unwrap(); - }); - } } diff --git a/src/windows.rs b/src/windows.rs index 688398d..56b697c 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -11,7 +11,9 @@ use std::{ use tracing::{debug, instrument}; use windows::{ Win32::{ - Foundation::{CloseHandle, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE}, + Foundation::{ + CloseHandle, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + }, System::{ Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, @@ -20,13 +22,14 @@ use windows::{ IO::{CreateIoCompletionPort, GetQueuedCompletionStatus, OVERLAPPED}, JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - JOBOBJECT_ASSOCIATE_COMPLETION_PORT, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JobObjectAssociateCompletionPortInformation, JobObjectExtendedLimitInformation, - SetInformationJobObject, TerminateJobObject, + JOBOBJECT_ASSOCIATE_COMPLETION_PORT, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectAssociateCompletionPortInformation, + JobObjectBasicAccountingInformation, JobObjectExtendedLimitInformation, + QueryInformationJobObject, SetInformationJobObject, TerminateJobObject, }, Threading::{ - CREATE_SUSPENDED, GetProcessId, INFINITE, OpenThread, PROCESS_CREATION_FLAGS, - ResumeThread, THREAD_SUSPEND_RESUME, + CREATE_SUSPENDED, GetProcessId, OpenThread, PROCESS_CREATION_FLAGS, ResumeThread, + THREAD_SUSPEND_RESUME, }, }, }, @@ -87,6 +90,41 @@ mod creation_flag_tests { } } +#[cfg(test)] +mod job_wait_tests { + use std::os::windows::io::AsHandle; + + use windows::Win32::System::IO::{CreateIoCompletionPort, PostQueuedCompletionStatus}; + + use super::*; + + const JOB_OBJECT_MSG_NEW_PROCESS: u32 = 6; + + #[test] + fn nonterminal_completion_packet_does_not_report_job_drain() { + // SAFETY: these arguments create a new completion port which is immediately owned below. + let raw = unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }.unwrap(); + // SAFETY: `raw` is a newly created, uniquely owned completion-port handle. + let port = unsafe { StdOwnedHandle::from_raw_handle(raw.0) }; + // SAFETY: `port` owns a live completion port; the scalar payload does not borrow memory. + unsafe { + PostQueuedCompletionStatus( + HANDLE(port.as_raw_handle()), + JOB_OBJECT_MSG_NEW_PROCESS, + 0, + None, + ) + } + .unwrap(); + + assert_eq!( + poll_job_drain_with(port.as_handle(), Duration::ZERO, || Ok(false)).unwrap(), + ControlFlow::Continue(()), + "a new-process notification is only a wake hint, not proof that the job drained" + ); + } +} + #[derive(Clone, Copy, Debug)] pub struct JobHandle(pub HANDLE); @@ -264,35 +302,76 @@ pub(crate) fn terminate_job(job: JobHandle, exit_code: u32) -> Result<()> { unsafe { TerminateJobObject(job.0, exit_code) }.map_err(Error::other) } -/// Wait for a job to complete. -#[cfg_attr(feature = "tracing", instrument(level = "debug"))] -pub(crate) fn wait_on_job( +/// Maximum interval between authoritative JobObject accounting checks. +pub(crate) const JOB_POLL_INTERVAL: Duration = Duration::from_millis(10); + +fn job_is_drained(job: JobHandle) -> Result { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: `job` is borrowed from a live `JobPort`; the information class requires exactly the + // writable accounting structure and byte count supplied here. The call returns before that stack + // storage is read or dropped. + unsafe { + QueryInformationJobObject( + Some(job.0), + JobObjectBasicAccountingInformation, + &mut accounting as *mut _ as _, + std::mem::size_of_val(&accounting) + .try_into() + .expect("job accounting information cannot exceed a DWORD"), + None, + ) + } + .map_err(Error::other)?; + Ok(accounting.ActiveProcesses == 0) +} + +fn poll_job_drain_with( completion_port: BorrowedHandle<'_>, - timeout: Option, + timeout: Duration, + mut is_drained: impl FnMut() -> Result, ) -> Result> { - let mut code: u32 = 0; - let mut key: usize = 0; - let mut overlapped = OVERLAPPED::default(); - let mut lp_overlapped = &mut overlapped as *mut OVERLAPPED; + if is_drained()? { + return Ok(ControlFlow::Break(())); + } + + let mut code = 0; + let mut key = 0; + let mut overlapped: *mut OVERLAPPED = std::ptr::null_mut(); + let timeout_ms = timeout.as_millis().try_into().unwrap_or(u32::MAX - 1); // SAFETY: `completion_port` is lifetime-bound to a live owned handle, and every output pointer - // refers to live stack storage. On failure we inspect `code` and `key` only when a packet was - // dequeued, as indicated by a non-null `lp_overlapped`. - let result = unsafe { + // refers to initialized writable stack storage for the duration of the call. The finite timeout + // cannot equal `INFINITE`. A dequeued packet is only a wake hint; none of its scalar fields is + // treated as proof that the job drained. + let wake = unsafe { GetQueuedCompletionStatus( HANDLE(completion_port.as_raw_handle()), &mut code, &mut key, - &mut lp_overlapped as *mut _, - timeout.map_or(INFINITE, |d| d.as_millis().try_into().unwrap_or(INFINITE)), + &mut overlapped, + timeout_ms, ) }; + if let Err(error) = wake + && overlapped.is_null() + && error.code() != HRESULT::from_win32(WAIT_TIMEOUT.0) + { + return Err(Error::other(error)); + } - // ignore timing out errors unless the timeout was specified to INFINITE - // https://docs.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatus - if timeout.is_some() && result.is_err() && lp_overlapped.is_null() { - return Ok(ControlFlow::Continue(())); + if is_drained()? { + Ok(ControlFlow::Break(())) + } else { + Ok(ControlFlow::Continue(())) } +} - Ok(ControlFlow::Break(())) +/// Poll whether a job has no active processes, waiting at most `timeout` for a wake hint. +#[cfg_attr(feature = "tracing", instrument(level = "debug"))] +pub(crate) fn poll_job_drain( + job: JobHandle, + completion_port: BorrowedHandle<'_>, + timeout: Duration, +) -> Result> { + poll_job_drain_with(completion_port, timeout, || job_is_drained(job)) } diff --git a/tests/std_windows/mod.rs b/tests/std_windows/mod.rs index 857b0dd..f406a8c 100644 --- a/tests/std_windows/mod.rs +++ b/tests/std_windows/mod.rs @@ -23,6 +23,7 @@ mod try_wait_after_die; mod wait_after_die; mod wait_twice; mod wait_with_output; -#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[cfg(feature = "job-object")] +#[cfg_attr(not(feature = "creation-flags"), allow(dead_code))] #[path = "../support/windows_thread.rs"] mod windows_thread; diff --git a/tests/std_windows/process_handle.rs b/tests/std_windows/process_handle.rs index 096c347..414a6ea 100644 --- a/tests/std_windows/process_handle.rs +++ b/tests/std_windows/process_handle.rs @@ -1,17 +1,25 @@ use std::{ any::TypeId, + fs, os::windows::{ io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}, process::ExitStatusExt, }, - process::{Command, ExitStatus}, + path::{Path, PathBuf}, + process::{Command, Command as StdCommand, ExitStatus}, sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, }, + time::Instant, }; -use super::prelude::*; +use super::{prelude::*, windows_thread::ProcessGuard}; + +const JOB_WAIT_DESCENDANT_PID: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_PID"; +const JOB_WAIT_DESCENDANT_RELEASE: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_RELEASE"; +const JOB_WAIT_TIMEOUT: Duration = Duration::from_secs(5); #[link(name = "kernel32")] unsafe extern "system" { @@ -439,3 +447,157 @@ fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Result<( assert!(consumed_has_handle); Ok(()) } + +#[derive(Debug)] +struct ReleaseOnDrop(PathBuf); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = fs::File::create(&self.0); + } +} + +fn wait_for_pid(path: &Path) -> Result { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if let Ok(pid) = fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(std::io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + sleep(Duration::from_millis(10)); + } +} + +fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "process did not exit before the test deadline", + )); + } + sleep(Duration::from_millis(10)); + } +} + +fn descendant_job_command(pid_file: &Path, release_file: &Path) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args([ + "--exact", + "std_windows::process_handle::job_wait_direct_child_helper", + "--ignored", + "--nocapture", + ]) + .env(JOB_WAIT_DESCENDANT_PID, pid_file) + .env(JOB_WAIT_DESCENDANT_RELEASE, release_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_descendant_helper() -> Result<()> { + let release = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ); + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + while !release.exists() { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant release was not signaled", + )); + } + sleep(Duration::from_millis(10)); + } + Ok(()) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_direct_child_helper() -> Result<()> { + let pid_file = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_PID) + .ok_or_else(|| std::io::Error::other("descendant PID path is missing"))?, + ); + let descendant = StdCommand::new(std::env::current_exe()?) + .args([ + "--exact", + "std_windows::process_handle::job_wait_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env( + JOB_WAIT_DESCENDANT_RELEASE, + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + fs::write(pid_file, descendant.id().to_string())?; + drop(descendant); + Ok(()) +} + +#[test] +fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release_file = directory.path().join("release"); + let _release_on_drop = ReleaseOnDrop(release_file.clone()); + let mut command = descendant_job_command(&pid_file, &release_file)?; + command.wrap(JobObject); + let mut child = command.spawn()?; + let direct_process = ProcessGuard::open(child.id())?; + let descendant_process = ProcessGuard::open(wait_for_pid(&pid_file)?)?; + wait_for_process_exit(direct_process)?; + + assert_eq!( + child.try_wait()?, + None, + "direct-child exit must remain pending while the job has a live descendant" + ); + + let (completed, completion) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let result = child.wait(); + let _ = completed.send((result, child)); + }); + assert!( + completion.recv_timeout(Duration::from_millis(100)).is_err(), + "blocking JobObject wait returned while the descendant remained alive" + ); + fs::File::create(&release_file)?; + let (status, mut child) = completion.recv_timeout(JOB_WAIT_TIMEOUT).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("JobObject wait did not finish after descendant release: {error}"), + ) + })?; + waiter + .join() + .map_err(|_| std::io::Error::other("JobObject wait thread panicked"))?; + let status = status?; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + assert_eq!(child.wait()?, status); + wait_for_process_exit(descendant_process)?; + Ok(()) +} diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index 0556611..adb369f 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -372,6 +372,24 @@ async fn wait_for_process_exit(guard: ProcessExitGuard) -> io::Result<()> { } } +async fn wait_for_pid_file(path: &Path) -> io::Result { + let deadline = Instant::now() + TIMEOUT; + loop { + if let Ok(pid) = std::fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "ConPTY descendant did not report its process ID", + )); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + #[derive(Debug, PartialEq, Eq)] struct MarkerSearch { marker_start: Option, @@ -597,7 +615,11 @@ fn conpty_child_helper() -> io::Result<()> { let release = env::var_os("PW_RELEASE").ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") })?; - drop(spawn_descendant(Path::new(&release))?); + let descendant = spawn_descendant(Path::new(&release))?; + if let Some(pid_file) = env::var_os("PW_DESCENDANT_PID") { + std::fs::write(pid_file, descendant.id().to_string())?; + } + drop(descendant); } "descendant" => { let release = env::var_os("PW_RELEASE").ok_or_else(|| { @@ -873,6 +895,51 @@ async fn direct_child_wait_is_independent_from_descendant_output_eof() -> io::Re Ok(()) } +#[cfg(feature = "job-object")] +#[tokio::test] +async fn job_object_waits_for_conpty_descendant_before_terminal_eof() -> io::Result<()> { + require_conpty!(); + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let pid_file = directory.path().join("descendant.pid"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("descendant-parent")?; + command + .env("PW_RELEASE", &release) + .env("PW_DESCENDANT_PID", &pid_file) + .wrap(JobObject); + + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let direct_process = ProcessExitGuard::open( + child + .id() + .expect("a newly spawned ConPTY child exposes its process ID"), + )?; + let (input, mut output, _resize) = controller.into_parts(); + let mut bytes = read_through(&mut output, b"PW-DESCENDANT-READY").await?; + let descendant_process = ProcessExitGuard::open(wait_for_pid_file(&pid_file).await?)?; + wait_for_process_exit(direct_process).await?; + assert_eq!( + child.try_wait()?, + None, + "ConPTY direct-child exit must remain pending while its job has a descendant" + ); + assert!( + timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "JobObject wait returned before the live ConPTY descendant or terminal output completed" + ); + + std::fs::File::create(&release)?; + drop(input); + let status = wait_and_drain(child.as_mut(), &mut output, &mut bytes).await?; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + wait_for_process_exit(descendant_process).await?; + Ok(()) +} + #[tokio::test] async fn failed_spawn_leaves_the_tracked_command_reusable() -> io::Result<()> { require_conpty!(); diff --git a/tests/tokio_windows/mod.rs b/tests/tokio_windows/mod.rs index adb69bd..8a33a17 100644 --- a/tests/tokio_windows/mod.rs +++ b/tests/tokio_windows/mod.rs @@ -24,6 +24,7 @@ mod try_wait_after_die; mod wait_after_die; mod wait_twice; mod wait_with_output; -#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[cfg(feature = "job-object")] +#[cfg_attr(not(feature = "creation-flags"), allow(dead_code))] #[path = "../support/windows_thread.rs"] mod windows_thread; diff --git a/tests/tokio_windows/process_handle.rs b/tests/tokio_windows/process_handle.rs index b733f9d..5514388 100644 --- a/tests/tokio_windows/process_handle.rs +++ b/tests/tokio_windows/process_handle.rs @@ -1,16 +1,23 @@ use std::{ any::TypeId, + fs, future::Future, os::windows::io::{AsRawHandle, BorrowedHandle, OwnedHandle}, + path::{Path, PathBuf}, pin::Pin, - process::ExitStatus, + process::{Command as StdCommand, ExitStatus}, sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, }, + time::Instant, }; -use super::prelude::*; +use super::{prelude::*, windows_thread::ProcessGuard}; + +const JOB_WAIT_DESCENDANT_PID: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_PID"; +const JOB_WAIT_DESCENDANT_RELEASE: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_RELEASE"; +const JOB_WAIT_TIMEOUT: Duration = Duration::from_secs(5); #[link(name = "kernel32")] unsafe extern "system" { @@ -429,3 +436,194 @@ async fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Re assert!(consumed_has_handle); Ok(()) } + +#[derive(Debug)] +struct ReleaseOnDrop(PathBuf); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = fs::File::create(&self.0); + } +} + +async fn wait_for_pid(path: &Path) -> Result { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if let Ok(pid) = fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(std::io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + sleep(Duration::from_millis(10)).await; + } +} + +async fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "process did not exit before the test deadline", + )); + } + sleep(Duration::from_millis(10)).await; + } +} + +fn descendant_job_command(pid_file: &Path, release_file: &Path) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args([ + "--exact", + "tokio_windows::process_handle::job_wait_direct_child_helper", + "--ignored", + "--nocapture", + ]) + .env(JOB_WAIT_DESCENDANT_PID, pid_file) + .env(JOB_WAIT_DESCENDANT_RELEASE, release_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_descendant_helper() -> Result<()> { + let release = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ); + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + while !release.exists() { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant release was not signaled", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(()) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_direct_child_helper() -> Result<()> { + let pid_file = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_PID) + .ok_or_else(|| std::io::Error::other("descendant PID path is missing"))?, + ); + let descendant = StdCommand::new(std::env::current_exe()?) + .args([ + "--exact", + "tokio_windows::process_handle::job_wait_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env( + JOB_WAIT_DESCENDANT_RELEASE, + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + fs::write(pid_file, descendant.id().to_string())?; + drop(descendant); + Ok(()) +} + +async fn spawn_descendant_job() -> Result<( + Box, + PathBuf, + ReleaseOnDrop, + ProcessGuard, + tempfile::TempDir, +)> { + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release_file = directory.path().join("release"); + let release_on_drop = ReleaseOnDrop(release_file.clone()); + let mut command = descendant_job_command(&pid_file, &release_file)?; + command.wrap(JobObject); + let child = command.spawn()?; + let direct_process = ProcessGuard::open( + child + .id() + .expect("a newly spawned child exposes its process ID"), + )?; + let descendant_process = ProcessGuard::open(wait_for_pid(&pid_file).await?)?; + wait_for_process_exit(direct_process).await?; + Ok(( + child, + release_file, + release_on_drop, + descendant_process, + directory, + )) +} + +#[tokio::test] +async fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { + let (mut child, release_file, _release_on_drop, descendant_process, _directory) = + spawn_descendant_job().await?; + assert_eq!( + child.try_wait()?, + None, + "direct-child exit must remain pending while the job has a live descendant" + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "JobObject wait returned while the descendant remained alive" + ); + fs::File::create(&release_file)?; + let status = tokio::time::timeout(JOB_WAIT_TIMEOUT, child.wait()) + .await + .map_err(std::io::Error::other)??; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + assert_eq!(child.wait().await?, status); + wait_for_process_exit(descendant_process).await?; + Ok(()) +} + +#[tokio::test] +async fn canceled_job_wait_preserves_pending_drain_state() -> Result<()> { + let (mut child, release_file, _release_on_drop, descendant_process, _directory) = + spawn_descendant_job().await?; + assert!( + tokio::time::timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "the first wait must remain pending before cancellation" + ); + assert_eq!(child.try_wait()?, None); + assert!( + tokio::time::timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "a repeated wait after cancellation must resume the pending job drain" + ); + fs::File::create(&release_file)?; + let status = tokio::time::timeout(JOB_WAIT_TIMEOUT, child.wait()) + .await + .map_err(std::io::Error::other)??; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + wait_for_process_exit(descendant_process).await?; + Ok(()) +} From a62ddfd2c60393195879ed5d922f2afb5e1a5781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 05:12:21 +1300 Subject: [PATCH 050/114] docs(windows): state ConPTY platform assumptions Document the supported-Windows attribute-list alignment and null-standard-handle behavior directly beside the FFI that relies on them. Co-Authored-By: Claude Code --- src/tokio/pty/windows/attributes.rs | 13 ++++++------- src/tokio/pty/windows/spawn.rs | 14 +++++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/tokio/pty/windows/attributes.rs b/src/tokio/pty/windows/attributes.rs index c804665..58835c0 100644 --- a/src/tokio/pty/windows/attributes.rs +++ b/src/tokio/pty/windows/attributes.rs @@ -1,11 +1,10 @@ //! Aligned ownership for a pseudo-console process-thread attribute list. //! -//! # External premise +//! # Supported-Windows ABI assumption //! -//! This module accepts **TCB premise G1**: on every supported Windows target ABI, storage aligned -//! to `align_of::()` satisfies the alignment requirement of -//! `InitializeProcThreadAttributeList` and every later access through -//! `LPPROC_THREAD_ATTRIBUTE_LIST`. Microsoft documents the byte-size query but publishes no +//! On every supported Windows target ABI, storage aligned to `align_of::()` is assumed to +//! satisfy the alignment requirement of `InitializeProcThreadAttributeList` and every later access +//! through `LPPROC_THREAD_ATTRIBUTE_LIST`. Microsoft documents the byte-size query but publishes no //! independent alignment bound in the //! [`InitializeProcThreadAttributeList` contract](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-initializeprocthreadattributelist). @@ -40,8 +39,8 @@ impl AttributeList { let list = LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast()); // SAFETY: storage starts at an address aligned to align_of::(), has at least the queried // byte capacity, and remains owned by the returned guard until after - // DeleteProcThreadAttributeList runs. TCB premise G1 above supplies the opaque Win32 list's - // alignment requirement. + // DeleteProcThreadAttributeList runs. The supported-Windows ABI assumption above supplies the + // opaque Win32 list's alignment requirement. unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut bytes) } .map_err(io::Error::other)?; diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index 4689e38..98ad74c 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -1,10 +1,10 @@ //! Exact `CreateProcessW` spawning for a prepared ConPTY command. //! -//! # External premise +//! # ConPTY platform assumption //! -//! This module accepts **TCB premise G2**: process creation with -//! `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` is an attachment to a new console for the null-standard- -//! handle replacement rule when `STARTF_USESTDHANDLES` is present. This premise reconciles the +//! Process creation with `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` is assumed to attach the process to +//! a new console for the null-standard-handle replacement rule when `STARTF_USESTDHANDLES` is +//! present. This assumption reconciles the //! [`CreateProcessW` contract](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw), //! which requires valid standard-handle slots, with the //! [`GetStdHandle` contract](https://learn.microsoft.com/en-us/windows/console/getstdhandle), which @@ -65,9 +65,9 @@ pub(super) fn spawn( // SAFETY: every pointer references live, NUL-terminated or explicitly bounded storage for the // duration of the call. The command line is uniquely mutable, the startup attribute list owns its - // aligned backing storage, and no native handles are inherited. Under TCB premise G2 above, - // STARTF_USESTDHANDLES with three null slots causes ConPTY to install its console handles. The - // process-information value is writable output storage. + // aligned backing storage, and no native handles are inherited. Under the ConPTY platform + // assumption above, STARTF_USESTDHANDLES with three null slots causes ConPTY to install its console + // handles. The process-information value is writable output storage. let created = unsafe { CreateProcessW( PCWSTR(command.application_name.as_ptr()), From 5dcbd7194acb96de6d605607495ce1c86bfa50d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 05:56:47 +1300 Subject: [PATCH 051/114] fix(windows): preserve JobObject polling cadence Keep finite std and Tokio drain polls paced under wake traffic and cancellation, without requiring Tokio timers. Cover timeout boundaries, timer-disabled runtimes, and deterministic drain-state synchronization, and gate the production-only feature graph in CI. Co-Authored-By: Claude Code --- .github/workflows/test.yml | 18 +++ src/std/job_object.rs | 219 +++++++++++++++++++++++--- src/tokio/job_object.rs | 41 ++++- src/windows.rs | 24 ++- tests/std_windows/process_handle.rs | 65 ++++++-- tests/tokio_windows/process_handle.rs | 161 +++++++++++++++++-- 6 files changed, 479 insertions(+), 49 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 656bc52..0e31c4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,6 +76,24 @@ jobs: - run: cargo check --locked --no-default-features --lib + windows-production-features: + name: Check Windows production feature closure + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure Rust + run: | + rustup toolchain install --profile minimal --no-self-update stable + rustup default stable + rustup target add x86_64-pc-windows-gnu + + - name: Check Tokio JobObject production library + run: cargo check --locked --target x86_64-pc-windows-gnu --no-default-features --features tokio1,job-object --lib + # Miri deliberately excludes native process creation and FFI. miri-process-free: name: Miri (process-free state and ownership) diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 1c01c23..b0ff356 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -1,9 +1,10 @@ use std::{ any::Any, io::{Error, ErrorKind, Result}, + ops::ControlFlow, os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}, process::ExitStatus, - time::Duration, + time::{Duration, Instant}, }; #[cfg(feature = "tracing")] @@ -191,6 +192,40 @@ impl JobObjectChild { } } +fn wait_for_exit_and_job_drain_with( + exit_status: &mut ChildExitStatus, + job_drained: &mut bool, + mut wait_direct: impl FnMut() -> Result, + mut poll_drain: impl FnMut(Duration) -> Result>, + mut now: impl FnMut() -> Instant, + mut sleep: impl FnMut(Duration), + mut on_pending: impl FnMut(ChildExitStatus), +) -> Result { + let status = match *exit_status { + ChildExitStatus::Running => { + let status = wait_direct()?; + *exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; + + while !*job_drained { + let poll_started = now(); + if poll_drain(JOB_POLL_INTERVAL)?.is_break() { + *job_drained = true; + } else { + let elapsed = now().saturating_duration_since(poll_started); + let remaining = JOB_POLL_INTERVAL.saturating_sub(elapsed); + on_pending(*exit_status); + if !remaining.is_zero() { + sleep(remaining); + } + } + } + Ok(status) +} + impl ChildWrapper for JobObjectChild { fn inner(&self) -> &dyn ChildWrapper { self.inner.as_ref() @@ -238,29 +273,22 @@ impl ChildWrapper for JobObjectChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Result { - let status = match self.exit_status { - ChildExitStatus::Running => { - // Direct-child exit and whole-job drain are separate states. Cache the direct status so - // repeated calls do not lose it while the job still contains descendants. - let status = self.inner.wait()?; - self.exit_status = ChildExitStatus::Exited(status); - status - } - ChildExitStatus::Exited(status) => status, - }; - - while !self.job_drained { - if poll_job_drain( - self.job_port.job, - self.job_port.completion_port.as_handle(), - JOB_POLL_INTERVAL, - )? - .is_break() - { - self.job_drained = true; - } - } - Ok(status) + let Self { + inner, + exit_status, + job_drained, + job_port, + .. + } = self; + wait_for_exit_and_job_drain_with( + exit_status, + job_drained, + || inner.wait(), + |timeout| poll_job_drain(job_port.job, job_port.completion_port.as_handle(), timeout), + Instant::now, + std::thread::sleep, + |_| {}, + ) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -289,3 +317,146 @@ impl ChildWrapper for JobObjectChild { } } } + +#[cfg(test)] +mod tests { + use std::{ + cell::{Cell, RefCell}, + os::windows::process::ExitStatusExt, + sync::mpsc, + thread, + }; + + use super::*; + + #[test] + fn sustained_nonterminal_wakes_preserve_poll_cadence() -> Result<()> { + let status = ExitStatus::from_raw(0); + let mut exit_status = ChildExitStatus::Exited(status); + let mut job_drained = false; + let base = Instant::now(); + let elapsed = Cell::new(Duration::ZERO); + let poll_costs = [ + Duration::from_millis(2), + Duration::from_millis(7), + JOB_POLL_INTERVAL, + Duration::ZERO, + ]; + let mut poll_costs = poll_costs.into_iter(); + let mut outcomes = [ + ControlFlow::Continue(()), + ControlFlow::Continue(()), + ControlFlow::Continue(()), + ControlFlow::Break(()), + ] + .into_iter(); + let poll_starts = RefCell::new(Vec::new()); + let sleeps = RefCell::new(Vec::new()); + + let result = wait_for_exit_and_job_drain_with( + &mut exit_status, + &mut job_drained, + || unreachable!("the direct status was already cached"), + |timeout| { + assert_eq!(timeout, JOB_POLL_INTERVAL); + poll_starts.borrow_mut().push(elapsed.get()); + elapsed.set(elapsed.get() + poll_costs.next().expect("one cost per injected poll")); + Ok(outcomes.next().expect("one outcome per injected poll")) + }, + || base + elapsed.get(), + |duration| { + sleeps.borrow_mut().push(duration); + elapsed.set(elapsed.get() + duration); + }, + |_| {}, + )?; + + assert_eq!(result, status); + assert!(job_drained); + assert_eq!( + poll_starts.into_inner(), + [ + Duration::ZERO, + JOB_POLL_INTERVAL, + JOB_POLL_INTERVAL * 2, + JOB_POLL_INTERVAL * 3, + ] + ); + assert_eq!( + sleeps.into_inner(), + [Duration::from_millis(8), Duration::from_millis(3)] + ); + Ok(()) + } + + #[test] + fn blocking_wait_rendezvous_follows_cached_exit_and_false_drain_poll() -> Result<()> { + let (pending_tx, pending_rx) = mpsc::sync_channel(0); + let (release_tx, release_rx) = mpsc::channel(); + let (completed_tx, completed_rx) = mpsc::channel(); + let waiter = thread::spawn(move || { + let status = ExitStatus::from_raw(0); + let mut exit_status = ChildExitStatus::Running; + let mut job_drained = false; + let mut polls = [ControlFlow::Continue(()), ControlFlow::Break(())].into_iter(); + let result = wait_for_exit_and_job_drain_with( + &mut exit_status, + &mut job_drained, + || Ok(status), + |_| { + Ok(polls + .next() + .expect("the fake job drains on its second poll")) + }, + Instant::now, + |_| {}, + |cached_exit| { + assert!(matches!( + cached_exit, + ChildExitStatus::Exited(cached) if cached == status + )); + pending_tx + .send(()) + .expect("the test receives the pending rendezvous"); + release_rx + .recv() + .expect("the test releases the pending drain"); + }, + ); + let _ = completed_tx.send((result, exit_status, job_drained)); + }); + + if let Err(error) = pending_rx.recv_timeout(Duration::from_secs(5)) { + let _ = release_tx.send(()); + let _ = waiter.join(); + return Err(Error::new( + ErrorKind::TimedOut, + format!("wait did not enter the pending drain state: {error}"), + )); + } + let was_pending = matches!(completed_rx.try_recv(), Err(mpsc::TryRecvError::Empty)); + let _ = release_tx.send(()); + let completed = completed_rx.recv_timeout(Duration::from_secs(5)); + let joined = waiter.join(); + + assert!( + was_pending, + "wait returned before the injected drain release" + ); + joined.map_err(|_| Error::other("blocking wait seam thread panicked"))?; + let (result, exit_status, job_drained) = completed.map_err(|error| { + Error::new( + ErrorKind::TimedOut, + format!("wait did not finish after the injected release: {error}"), + ) + })?; + let status = result?; + assert!(status.success()); + assert!(matches!( + exit_status, + ChildExitStatus::Exited(cached) if cached == status + )); + assert!(job_drained); + Ok(()) + } +} diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 1250ba7..05bfb22 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -5,7 +5,7 @@ use std::{ os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}, pin::Pin, process::ExitStatus, - time::Duration, + time::{Duration, Instant}, }; #[cfg(feature = "tracing")] @@ -180,6 +180,7 @@ pub struct JobObjectChild { inner: Box, exit_status: ChildExitStatus, job_drained: bool, + poll_cadence: Option>, job_port: JobPort, final_kill_on_drop: bool, spawn_finalized: bool, @@ -196,11 +197,41 @@ impl JobObjectChild { inner, exit_status: ChildExitStatus::Running, job_drained: false, + poll_cadence: None, job_port, final_kill_on_drop, spawn_finalized: false, } } + + fn arm_poll_cadence(&mut self, elapsed: Duration) { + debug_assert!(self.poll_cadence.is_none()); + let remaining = JOB_POLL_INTERVAL.saturating_sub(elapsed); + if !remaining.is_zero() { + // The blocking task owns only inert timing data. In particular, it cannot prolong or + // outlive any borrow of the JobObject or completion-port handles. + self.poll_cadence = Some(tokio::task::spawn_blocking(move || { + std::thread::sleep(remaining); + })); + } + } + + async fn wait_for_poll_cadence(&mut self) -> Result<()> { + let Some(cadence) = self.poll_cadence.as_mut() else { + return Ok(()); + }; + let result = cadence.await; + self.poll_cadence = None; + result.map_err(Error::other) + } + + fn clear_poll_cadence(&mut self) { + if let Some(cadence) = self.poll_cadence.take() { + // `abort` can prevent a queued blocking task from starting. If it has started, Tokio + // lets the short sleep finish detached; its closure owns no native resource. + cadence.abort(); + } + } } impl ChildWrapper for JobObjectChild { @@ -263,6 +294,10 @@ impl ChildWrapper for JobObjectChild { }; while !self.job_drained { + // A canceled wait leaves this handle in `self`, so its successor cannot poll again + // until the already-armed cadence completes. + self.wait_for_poll_cadence().await?; + let poll_started = Instant::now(); if poll_job_drain( self.job_port.job, self.job_port.completion_port.as_handle(), @@ -273,9 +308,10 @@ impl ChildWrapper for JobObjectChild { // No await occurs between the authoritative accounting result and this durable // transition, so a canceled future cannot consume the drain state. self.job_drained = true; + self.clear_poll_cadence(); break; } - tokio::time::sleep(JOB_POLL_INTERVAL).await; + self.arm_poll_cadence(poll_started.elapsed()); } Ok(status) }) @@ -299,6 +335,7 @@ impl ChildWrapper for JobObjectChild { .is_break() { self.job_drained = true; + self.clear_poll_cadence(); } match (self.exit_status, self.job_drained) { diff --git a/src/windows.rs b/src/windows.rs index 56b697c..6363caf 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -100,6 +100,20 @@ mod job_wait_tests { const JOB_OBJECT_MSG_NEW_PROCESS: u32 = 6; + #[test] + fn finite_timeout_milliseconds_never_uses_infinite() { + assert_eq!(finite_timeout_millis(Duration::ZERO), 0); + assert_eq!(finite_timeout_millis(Duration::from_millis(17)), 17); + assert_eq!( + finite_timeout_millis(Duration::from_millis(u64::from(u32::MAX))), + u32::MAX - 1 + ); + assert_eq!( + finite_timeout_millis(Duration::from_millis(u64::from(u32::MAX) + 1)), + u32::MAX - 1 + ); + } + #[test] fn nonterminal_completion_packet_does_not_report_job_drain() { // SAFETY: these arguments create a new completion port which is immediately owned below. @@ -325,6 +339,14 @@ fn job_is_drained(job: JobHandle) -> Result { Ok(accounting.ActiveProcesses == 0) } +fn finite_timeout_millis(timeout: Duration) -> u32 { + timeout + .as_millis() + .try_into() + .unwrap_or(u32::MAX - 1) + .min(u32::MAX - 1) +} + fn poll_job_drain_with( completion_port: BorrowedHandle<'_>, timeout: Duration, @@ -337,7 +359,7 @@ fn poll_job_drain_with( let mut code = 0; let mut key = 0; let mut overlapped: *mut OVERLAPPED = std::ptr::null_mut(); - let timeout_ms = timeout.as_millis().try_into().unwrap_or(u32::MAX - 1); + let timeout_ms = finite_timeout_millis(timeout); // SAFETY: `completion_port` is lifetime-bound to a live owned handle, and every output pointer // refers to initialized writable stack storage for the duration of the call. The finite timeout diff --git a/tests/std_windows/process_handle.rs b/tests/std_windows/process_handle.rs index 414a6ea..5898fa5 100644 --- a/tests/std_windows/process_handle.rs +++ b/tests/std_windows/process_handle.rs @@ -457,6 +457,32 @@ impl Drop for ReleaseOnDrop { } } +#[derive(Debug)] +struct WaiterGuard { + release_file: PathBuf, + thread: Option>, +} + +impl WaiterGuard { + fn join(&mut self) -> Result<()> { + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| std::io::Error::other("JobObject wait thread panicked"))?; + } + Ok(()) + } +} + +impl Drop for WaiterGuard { + fn drop(&mut self) { + let _ = fs::File::create(&self.release_file); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + fn wait_for_pid(path: &Path) -> Result { let deadline = Instant::now() + JOB_WAIT_TIMEOUT; loop { @@ -575,15 +601,38 @@ fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { "direct-child exit must remain pending while the job has a live descendant" ); + let (drain_entered, drain_entry) = mpsc::channel(); let (completed, completion) = mpsc::channel(); - let waiter = std::thread::spawn(move || { - let result = child.wait(); + let waiter_release = release_file.clone(); + let thread = std::thread::spawn(move || { + let pending = child.try_wait(); + let entered = match &pending { + Ok(None) => Ok(()), + Ok(Some(_)) => Err(std::io::Error::other( + "blocking wait setup observed a drained job before release", + )), + Err(error) => Err(std::io::Error::new(error.kind(), error.to_string())), + }; + let _ = drain_entered.send(entered); + let result = match pending { + Ok(None) => child.wait(), + Ok(Some(status)) => Ok(status), + Err(error) => Err(error), + }; let _ = completed.send((result, child)); }); - assert!( - completion.recv_timeout(Duration::from_millis(100)).is_err(), - "blocking JobObject wait returned while the descendant remained alive" - ); + let mut waiter = WaiterGuard { + release_file: waiter_release, + thread: Some(thread), + }; + drain_entry + .recv_timeout(JOB_WAIT_TIMEOUT) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("waiter did not enter job drain after a false query: {error}"), + ) + })??; fs::File::create(&release_file)?; let (status, mut child) = completion.recv_timeout(JOB_WAIT_TIMEOUT).map_err(|error| { std::io::Error::new( @@ -591,9 +640,7 @@ fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { format!("JobObject wait did not finish after descendant release: {error}"), ) })?; - waiter - .join() - .map_err(|_| std::io::Error::other("JobObject wait thread panicked"))?; + waiter.join()?; let status = status?; assert!(status.success()); assert_eq!(child.try_wait()?, Some(status)); diff --git a/tests/tokio_windows/process_handle.rs b/tests/tokio_windows/process_handle.rs index 5514388..0c4eca2 100644 --- a/tests/tokio_windows/process_handle.rs +++ b/tests/tokio_windows/process_handle.rs @@ -446,6 +446,40 @@ impl Drop for ReleaseOnDrop { } } +fn wait_for_pid_blocking(path: &Path) -> Result { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if let Ok(pid) = fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(std::io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn wait_for_process_exit_blocking(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "process did not exit before the test deadline", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + async fn wait_for_pid(path: &Path) -> Result { let deadline = Instant::now() + JOB_WAIT_TIMEOUT; loop { @@ -545,13 +579,15 @@ fn job_wait_direct_child_helper() -> Result<()> { Ok(()) } -async fn spawn_descendant_job() -> Result<( - Box, - PathBuf, - ReleaseOnDrop, - ProcessGuard, - tempfile::TempDir, -)> { +struct DescendantJob { + child: Box, + release_file: PathBuf, + release_on_drop: ReleaseOnDrop, + descendant_process: ProcessGuard, + directory: tempfile::TempDir, +} + +async fn spawn_descendant_job() -> Result { let directory = tempfile::tempdir()?; let pid_file = directory.path().join("descendant.pid"); let release_file = directory.path().join("release"); @@ -566,19 +602,113 @@ async fn spawn_descendant_job() -> Result<( )?; let descendant_process = ProcessGuard::open(wait_for_pid(&pid_file).await?)?; wait_for_process_exit(direct_process).await?; - Ok(( + Ok(DescendantJob { + child, + release_file, + release_on_drop, + descendant_process, + directory, + }) +} + +fn spawn_descendant_job_without_timer() -> Result { + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release_file = directory.path().join("release"); + let release_on_drop = ReleaseOnDrop(release_file.clone()); + let mut command = descendant_job_command(&pid_file, &release_file)?; + command.wrap(JobObject); + let child = command.spawn()?; + let direct_process = ProcessGuard::open( + child + .id() + .expect("a newly spawned child exposes its process ID"), + )?; + let descendant_process = ProcessGuard::open(wait_for_pid_blocking(&pid_file)?)?; + wait_for_process_exit_blocking(direct_process)?; + Ok(DescendantJob { child, release_file, release_on_drop, descendant_process, directory, - )) + }) +} + +#[test] +fn descendant_aware_wait_works_without_a_timer_driver() -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .build()?; + runtime.block_on(async { + let DescendantJob { + mut child, + release_file, + release_on_drop: _release_on_drop, + descendant_process, + directory: _directory, + } = spawn_descendant_job_without_timer()?; + assert_eq!( + child.try_wait()?, + None, + "the direct status is cached while the descendant keeps the job active" + ); + + let (blocker_started_tx, blocker_started_rx) = std::sync::mpsc::channel(); + let (blocker_release_tx, blocker_release_rx) = std::sync::mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = blocker_started_tx.send(()); + let _ = blocker_release_rx.recv(); + }); + blocker_started_rx + .recv_timeout(JOB_WAIT_TIMEOUT) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("blocking-pool guard did not start: {error}"), + ) + })?; + + let mut wait = child.wait(); + let first_poll = + std::future::poll_fn(|context| std::task::Poll::Ready(wait.as_mut().poll(context))) + .await; + assert!( + first_poll.is_pending(), + "the live descendant must leave the timer-disabled wait pending" + ); + drop(wait); + + let mut resumed_wait = child.wait(); + let resumed_poll = std::future::poll_fn(|context| { + std::task::Poll::Ready(resumed_wait.as_mut().poll(context)) + }) + .await; + assert!( + resumed_poll.is_pending(), + "a canceled wait must resume its queued cadence without a timer driver" + ); + + fs::File::create(&release_file)?; + let _ = blocker_release_tx.send(()); + let status = resumed_wait.await?; + blocker.await.map_err(std::io::Error::other)?; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + wait_for_process_exit_blocking(descendant_process)?; + Ok(()) + }) } #[tokio::test] async fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { - let (mut child, release_file, _release_on_drop, descendant_process, _directory) = - spawn_descendant_job().await?; + let DescendantJob { + mut child, + release_file, + release_on_drop: _release_on_drop, + descendant_process, + directory: _directory, + } = spawn_descendant_job().await?; assert_eq!( child.try_wait()?, None, @@ -603,8 +733,13 @@ async fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { #[tokio::test] async fn canceled_job_wait_preserves_pending_drain_state() -> Result<()> { - let (mut child, release_file, _release_on_drop, descendant_process, _directory) = - spawn_descendant_job().await?; + let DescendantJob { + mut child, + release_file, + release_on_drop: _release_on_drop, + descendant_process, + directory: _directory, + } = spawn_descendant_job().await?; assert!( tokio::time::timeout(Duration::from_millis(100), child.wait()) .await From cf34f48e3b8fa9dc4829263ac92d38e5be98cec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 07:59:12 +1300 Subject: [PATCH 052/114] test(provider): cover committed transaction ownership transfer Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 1096 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 1089 insertions(+), 7 deletions(-) diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 6c6addb..7911b77 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -19,7 +19,7 @@ macro_rules! spawn_provider_tests { panic::{AssertUnwindSafe, catch_unwind, panic_any}, sync::{ Arc, Mutex, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, thread::sleep, time::{Duration, Instant}, @@ -64,6 +64,34 @@ macro_rules! spawn_provider_tests { Panic(&'static str), } + #[derive(Debug)] + struct CommittedDropPayload(Arc<()>); + + #[derive(Debug)] + struct PanickingDropPayload { + drops: Arc, + } + + impl Drop for PanickingDropPayload { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + panic_any("a secondary panic payload was dropped"); + } + } + + #[derive(Debug)] + enum RollbackBehavior { + Error, + Panic(PanickingDropPayload), + } + + #[derive(Debug)] + enum TransactionDropBehavior { + Record, + PanicCommitted(CommittedDropPayload), + PanicSecondary(PanickingDropPayload), + } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Event { Extend(&'static str, &'static str), @@ -76,6 +104,7 @@ macro_rules! spawn_provider_tests { Wrap(&'static str), Commit, Rollback, + TransactionDrop, #[cfg(windows)] FinalizeSpawn(&'static str), #[cfg(windows)] @@ -99,6 +128,8 @@ macro_rules! spawn_provider_tests { struct Shared { events: Mutex>, failures: Mutex>, + rollback_behavior: Mutex>, + transaction_drop_behavior: Mutex>, make_attempt_native_only: AtomicBool, #[cfg(windows)] finalization_layers: Mutex>, @@ -117,6 +148,22 @@ macro_rules! spawn_provider_tests { self.events.lock().unwrap().clear(); } + fn set_rollback_behavior(&self, behavior: RollbackBehavior) { + *self.rollback_behavior.lock().unwrap() = Some(behavior); + } + + fn take_rollback_behavior(&self) -> Option { + self.rollback_behavior.lock().unwrap().take() + } + + fn set_transaction_drop_behavior(&self, behavior: TransactionDropBehavior) { + *self.transaction_drop_behavior.lock().unwrap() = Some(behavior); + } + + fn take_transaction_drop_behavior(&self) -> Option { + self.transaction_drop_behavior.lock().unwrap().take() + } + #[cfg(windows)] fn set_finalization_layers(&self, layers: Vec) { *self.finalization_layers.lock().unwrap() = layers; @@ -148,6 +195,20 @@ macro_rules! spawn_provider_tests { } } + fn successful_exit_status() -> std::process::ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + } + #[derive(Debug)] struct CustomChild; @@ -163,6 +224,18 @@ macro_rules! spawn_provider_tests { fn into_inner(self: Box) -> Box { self } + + fn try_clone(&self) -> Option> { + Some(Box::new(Self)) + } + + fn start_kill(&mut self) -> io::Result<()> { + Ok(()) + } + + fn try_wait(&mut self) -> io::Result> { + Ok(Some(successful_exit_status())) + } } #[cfg(windows)] @@ -250,17 +323,53 @@ macro_rules! spawn_provider_tests { } #[derive(Debug)] - struct Transaction(Arc); + struct Transaction { + shared: Arc, + committed: bool, + } + + impl Transaction { + fn new(shared: Arc) -> Self { + Self { + shared, + committed: false, + } + } + } impl SpawnTransaction for Transaction { fn commit(&mut self) -> io::Result<()> { - self.0.event(Event::Commit); - self.0.fail(Point::Commit) + self.shared.event(Event::Commit); + self.shared.fail(Point::Commit)?; + self.committed = true; + Ok(()) } fn rollback(&mut self) -> io::Result<()> { - self.0.event(Event::Rollback); - self.0.fail(Point::Rollback) + self.shared.event(Event::Rollback); + self.shared.fail(Point::Rollback)?; + match self.shared.take_rollback_behavior() { + Some(RollbackBehavior::Error) => Err(io::Error::other("rollback failed")), + Some(RollbackBehavior::Panic(payload)) => panic_any(payload), + None => Ok(()), + } + } + } + + impl Drop for Transaction { + fn drop(&mut self) { + let Some(behavior) = self.shared.take_transaction_drop_behavior() else { + return; + }; + self.shared.event(Event::TransactionDrop); + match behavior { + TransactionDropBehavior::Record => {} + TransactionDropBehavior::PanicCommitted(payload) => { + assert!(self.committed, "the transaction must commit before deferred disposal"); + panic_any(payload); + } + TransactionDropBehavior::PanicSecondary(payload) => panic_any(payload), + } } } @@ -360,7 +469,7 @@ macro_rules! spawn_provider_tests { self.shared.fail(Point::Spawn)?; Ok(ProviderProduct::new( Box::new(CustomChild), - Box::new(Transaction(Arc::clone(&self.shared))), + Box::new(Transaction::new(Arc::clone(&self.shared))), )) } } @@ -506,6 +615,52 @@ macro_rules! spawn_provider_tests { } } + #[derive(Debug)] + struct IdentityError(Arc<()>); + + impl std::fmt::Display for IdentityError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("primary error") + } + } + + impl std::error::Error for IdentityError {} + + #[derive(Debug)] + struct PrimaryPanic(Arc<()>); + + #[derive(Debug)] + enum PrimaryFailure { + Error(Arc<()>), + Panic(Arc<()>), + } + + #[derive(Debug)] + struct PrimaryFailureWrapper(Mutex>); + + impl PrimaryFailureWrapper { + fn new(failure: PrimaryFailure) -> Self { + Self(Mutex::new(Some(failure))) + } + } + + impl CommandWrapper for PrimaryFailureWrapper { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result<()> { + match self.0.lock().unwrap().take() { + Some(PrimaryFailure::Error(identity)) => { + Err(io::Error::other(IdentityError(identity))) + } + Some(PrimaryFailure::Panic(identity)) => panic_any(PrimaryPanic(identity)), + None => Ok(()), + } + } + } + #[derive(Debug)] struct OtherProviderWrapper(Provider); @@ -555,6 +710,38 @@ macro_rules! spawn_provider_tests { command } + fn provider_command_with_primary_failure( + shared: Arc, + failure: PrimaryFailure, + ) -> CommandWrap { + let mut command = provider_command(shared, "provider"); + command.wrap(PrimaryFailureWrapper::new(failure)); + command + } + + fn assert_primary_failure_preserved( + outcome: std::thread::Result>>, + identity: &Arc<()>, + was_panic: bool, + ) { + if was_panic { + let payload = outcome.expect_err("the primary panic must be resumed"); + let payload = payload + .downcast::() + .expect("cleanup must not replace the primary panic payload"); + assert!(Arc::ptr_eq(&payload.0, identity)); + } else { + let error = outcome + .expect("cleanup must not replace the primary error with a panic") + .expect_err("the primary error must be returned"); + let payload = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("cleanup must preserve the primary io::Error payload"); + assert!(Arc::ptr_eq(&payload.0, identity)); + } + } + fn successful_events(name: &'static str) -> Vec { vec![ Event::Available(name), @@ -707,6 +894,77 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[test] + fn committed_transaction_residue_moves_with_the_child_and_supported_clones() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicCommitted( + CommittedDropPayload(Arc::clone(&identity)), + )); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let mut child = match outcome { + Ok(Ok(child)) => child, + Ok(Err(error)) => panic!("committed provider spawn failed: {error}"), + Err(payload) => { + let payload = payload + .downcast::() + .expect("the committed transaction supplied the panic payload"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert!(!shared.events().contains(&Event::Rollback)); + panic!("committed transaction residue was destroyed before child transfer"); + } + }; + + assert_eq!(shared.events(), successful_events("provider")); + assert_eq!(child.inner().type_id(), TypeId::of::()); + child.start_kill().expect("terminate returned child"); + let first = child + .try_wait() + .expect("reap returned child") + .expect("returned child is complete"); + assert_eq!(child.try_wait().expect("repeat child status"), Some(first)); + + let clone = child + .try_clone() + .expect("the child layer supports cloning"); + drop(child); + assert!(!shared.events().contains(&Event::TransactionDrop)); + let payload = catch_unwind(AssertUnwindSafe(|| drop(clone))) + .expect_err("the last child owner destroys the transaction residue"); + let payload = payload + .downcast::() + .expect("the exact deferred transaction payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(shared.events().last(), Some(&Event::TransactionDrop)); + assert!(!shared.events().contains(&Event::Rollback)); + + shared.clear_events(); + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill().expect("terminate reused child"); + assert!(child.try_wait().expect("reap reused child").is_some()); + drop(child); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn consuming_the_provider_sidecar_disposes_ordinary_residue() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.set_transaction_drop_behavior(TransactionDropBehavior::Record); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let child = command.spawn().expect("spawn provider child"); + assert!(!shared.events().contains(&Event::TransactionDrop)); + let child = child.into_inner(); + assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(shared.events().last(), Some(&Event::TransactionDrop)); + } + #[test] fn provider_conflicts_precede_callbacks_and_allocation() { let runtime = runtime(); @@ -985,6 +1243,143 @@ macro_rules! spawn_provider_tests { } } + #[test] + fn rollback_errors_preserve_exact_primary_errors_and_panics() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for was_panic in [false, true] { + let shared = Arc::new(Shared::default()); + shared.set_rollback_behavior(RollbackBehavior::Error); + let identity = Arc::new(()); + let failure = if was_panic { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let mut command = + provider_command_with_primary_failure(Arc::clone(&shared), failure); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(shared.events().last(), Some(&Event::Rollback)); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + fn rollback_panic_payloads_are_quarantined_without_replacing_the_primary() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for was_panic in [false, true] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_rollback_behavior(RollbackBehavior::Panic(PanickingDropPayload { + drops: Arc::clone(&secondary_drops), + })); + let identity = Arc::new(()); + let failure = if was_panic { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let mut command = + provider_command_with_primary_failure(Arc::clone(&shared), failure); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert_eq!(shared.events().last(), Some(&Event::Rollback)); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + fn transaction_drop_panic_payloads_are_quarantined_after_rollback() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for was_panic in [false, true] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_transaction_drop_behavior( + TransactionDropBehavior::PanicSecondary(PanickingDropPayload { + drops: Arc::clone(&secondary_drops), + }), + ); + let identity = Arc::new(()); + let failure = if was_panic { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let mut command = + provider_command_with_primary_failure(Arc::clone(&shared), failure); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::TransactionDrop])); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + fn rollback_and_transaction_unwinds_are_separated() { + const CHILD_ENV: &str = "PROCESS_WRAP_SEPARATE_PROVIDER_UNWINDS"; + let child_value = stringify!($module); + if std::env::var_os(CHILD_ENV).as_deref() != Some(OsStr::new(child_value)) { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + concat!(stringify!($module), "::rollback_and_transaction_unwinds_are_separated"), + "--nocapture", + ]) + .env(CHILD_ENV, child_value) + .output() + .expect("start isolated lifecycle regression"); + assert!( + output.status.success(), + "rollback and transaction disposal overlapped:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let rollback_payload_drops = Arc::new(AtomicUsize::new(0)); + let transaction_payload_drops = Arc::new(AtomicUsize::new(0)); + shared.set_rollback_behavior(RollbackBehavior::Panic(PanickingDropPayload { + drops: Arc::clone(&rollback_payload_drops), + })); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( + PanickingDropPayload { + drops: Arc::clone(&transaction_payload_drops), + }, + )); + let identity = Arc::new(()); + let mut command = provider_command_with_primary_failure( + Arc::clone(&shared), + PrimaryFailure::Panic(Arc::clone(&identity)), + ); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, true); + assert_eq!(rollback_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(transaction_payload_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::TransactionDrop])); + } + #[test] fn commit_failure_rolls_back_and_preserves_its_error() { let runtime = runtime(); @@ -1141,6 +1536,82 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[test] + fn final_owner_failures_preserve_the_owner_failure_over_residue_disposal() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for failure in [ + Failure::Error(io::ErrorKind::Other, "owner disarm failed"), + Failure::Panic("owner disarm failed"), + ] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + shared.fail_once(Point::DisarmOwner, failure); + shared.set_transaction_drop_behavior( + TransactionDropBehavior::PanicSecondary(PanickingDropPayload { + drops: Arc::clone(&secondary_drops), + }), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "owner disarm failed"); + let events = shared.events(); + assert!(events.contains(&Event::Commit)); + assert!(events.contains(&Event::DisarmOwner("owner"))); + assert!(events.ends_with(&[ + Event::OwnerDropped("owner", true), + Event::TransactionDrop, + ])); + assert!(!events.contains(&Event::Rollback)); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + + shared.clear_events(); + let child = command.spawn().expect("the command remains reusable"); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + expected.push(Event::OwnerDropped("owner", false)); + assert_eq!(shared.events(), expected); + } + } + + #[cfg(windows)] + #[test] + fn final_owner_disarms_before_committed_residue_transfer() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicCommitted( + CommittedDropPayload(Arc::clone(&identity)), + )); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let child = command.spawn().expect("spawn provider child"); + assert_eq!( + shared.events(), + successful_finalization_events("provider", &layers) + ); + let payload = catch_unwind(AssertUnwindSafe(|| drop(child))) + .expect_err("child disposal destroys the committed residue"); + let payload = payload + .downcast::() + .expect("the exact deferred transaction payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!( + shared.events().as_slice(), + &[ + successful_finalization_events("provider", &layers).as_slice(), + &[Event::OwnerDropped("owner", false), Event::TransactionDrop], + ] + .concat() + ); + } + #[test] fn commit_panic_rolls_back_preserves_payload_and_allows_reuse() { let runtime = runtime(); @@ -1183,6 +1654,582 @@ macro_rules! spawn_provider_tests { }; } +macro_rules! std_shared_process_methods { + () => { + fn id(&self) -> u32 { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .id() + } + + fn wait(&mut self) -> std::io::Result { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .wait() + } + }; +} + +macro_rules! tokio_shared_process_methods { + () => { + fn id(&self) -> Option { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .id() + } + + fn wait( + &mut self, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + let child = std::sync::Arc::clone(&self.child); + Box::pin(std::future::poll_fn(move |context| { + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wait = child.wait(); + std::future::Future::poll(wait.as_mut(), context) + })) + } + }; +} + +macro_rules! std_wait_for_child { + ($runtime:expr, $child:expr) => {{ + let _ = &$runtime; + $child.wait() + }}; +} + +macro_rules! tokio_wait_for_child { + ($runtime:expr, $child:expr) => { + $runtime + .as_ref() + .expect("the Tokio frontend has a runtime") + .block_on($child.wait()) + }; +} + +macro_rules! real_provider_tests { + ( + $module:ident, + $command_wrap:path, + $spawn_attempt:path, + $command_wrapper:path, + $child_wrapper:path, + $provider_product:path, + $spawn_provider:path, + $native_command:path, + $shared_process_methods:ident, + $wait_for_child:ident, + $runtime:expr + ) => { + mod $module { + use std::{ + fs, io, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use process_wrap::SpawnTransaction; + use $child_wrapper as ChildWrapper; + use $command_wrap as CommandWrap; + use $command_wrapper as CommandWrapper; + use $provider_product as ProviderProduct; + use $spawn_attempt as SpawnAttempt; + use $spawn_provider as SpawnProvider; + + const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + const MARKER_ENV: &str = "PROCESS_WRAP_PROVIDER_MARKER_DIR"; + + #[derive(Clone, Debug)] + struct MarkerPaths { + directory: PathBuf, + ready: PathBuf, + go: PathBuf, + marker: PathBuf, + } + + impl MarkerPaths { + fn new(directory: &Path) -> Self { + Self { + directory: directory.to_owned(), + ready: directory.join("ready"), + go: directory.join("go"), + marker: directory.join("marker"), + } + } + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Event { + Commit, + Rollback, + TransactionDrop, + } + + #[derive(Debug)] + struct DeferredPayload(Arc<()>); + + #[derive(Debug)] + struct PanickingDropPayload(Arc); + + impl Drop for PanickingDropPayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a secondary process-transaction payload was dropped"); + } + } + + #[derive(Debug)] + enum DropBehavior { + PanicCommitted(DeferredPayload), + PanicSecondary(PanickingDropPayload), + } + + #[derive(Debug, Default)] + struct Shared { + events: Mutex>, + paths: Mutex>, + last_child: Mutex>>>>, + drop_behavior: Mutex>, + } + + impl Shared { + fn event(&self, event: Event) { + self.events.lock().unwrap().push(event); + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + fn set_paths(&self, paths: MarkerPaths) { + *self.paths.lock().unwrap() = Some(paths); + } + + fn paths(&self) -> MarkerPaths { + self.paths + .lock() + .unwrap() + .clone() + .expect("the process fixture has marker paths") + } + + fn set_drop_behavior(&self, behavior: DropBehavior) { + *self.drop_behavior.lock().unwrap() = Some(behavior); + } + + fn take_drop_behavior(&self) -> Option { + self.drop_behavior.lock().unwrap().take() + } + + fn child(&self) -> Arc>> { + Arc::clone( + self.last_child + .lock() + .unwrap() + .as_ref() + .expect("the provider published its process child"), + ) + } + + fn clear_child(&self) { + self.last_child.lock().unwrap().take(); + } + } + + #[derive(Debug)] + struct SharedProcessChild { + child: Arc>>, + } + + impl ChildWrapper for SharedProcessChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn start_kill(&mut self) -> io::Result<()> { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .start_kill() + } + + fn try_wait(&mut self) -> io::Result> { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + } + + $shared_process_methods!(); + } + + #[derive(Debug)] + struct Transaction { + cleanup: Option>>>, + shared: Arc, + committed: bool, + drop_behavior: Option, + } + + impl Transaction { + fn terminate_and_reap(&mut self) -> io::Result<()> { + let Some(child) = self.cleanup.take() else { + return Ok(()); + }; + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match child.start_kill() { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if child.try_wait()?.is_some() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "provider rollback did not reap its child", + )); + } + sleep(Duration::from_millis(5)); + } + } + } + + impl SpawnTransaction for Transaction { + fn commit(&mut self) -> io::Result<()> { + self.shared.event(Event::Commit); + self.cleanup.take(); + self.committed = true; + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + self.shared.event(Event::Rollback); + self.terminate_and_reap() + } + } + + impl Drop for Transaction { + fn drop(&mut self) { + let Some(behavior) = self.drop_behavior.take() else { + return; + }; + self.shared.event(Event::TransactionDrop); + match behavior { + DropBehavior::PanicCommitted(payload) => { + assert!(self.committed, "the process transaction must be committed"); + panic_any(payload); + } + DropBehavior::PanicSecondary(payload) => panic_any(payload), + } + } + } + + #[derive(Debug)] + struct Provider(Arc); + + impl SpawnProvider for Provider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result { + let paths = self.0.paths(); + let mut command = <$native_command>::new(std::env::current_exe()?); + command + .args([ + "--exact", + concat!(stringify!($module), "::delayed_marker_process"), + "--nocapture", + ]) + .env(MARKER_ENV, &paths.directory); + let child = command.spawn()?; + let child = Arc::new(Mutex::new(Box::new(child) as Box)); + *self.0.last_child.lock().unwrap() = Some(Arc::clone(&child)); + Ok(ProviderProduct::new( + Box::new(SharedProcessChild { + child: Arc::clone(&child), + }), + Box::new(Transaction { + cleanup: Some(child), + shared: Arc::clone(&self.0), + committed: false, + drop_behavior: self.0.take_drop_behavior(), + }), + )) + } + } + + #[derive(Debug)] + struct ProviderWrapper(Provider); + + impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct PrimaryError(Arc<()>); + + impl std::fmt::Display for PrimaryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("post-spawn failure") + } + } + + impl std::error::Error for PrimaryError {} + + #[derive(Debug)] + struct FailPostSpawn(Mutex>>); + + impl CommandWrapper for FailPostSpawn { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result<()> { + self.0.lock().unwrap().take().map_or(Ok(()), |identity| { + Err(io::Error::other(PrimaryError(identity))) + }) + } + } + + fn runtime() -> Option { + $runtime + } + + fn command(shared: Arc) -> CommandWrap { + let mut command = CommandWrap::new("provider-owned-program"); + command.wrap(ProviderWrapper(Provider(shared))); + command + } + + fn wait_for_path(path: &Path) { + let deadline = Instant::now() + EXIT_TIMEOUT; + while !path.exists() { + assert!( + Instant::now() < deadline, + "path was not created: {}", + path.display() + ); + sleep(Duration::from_millis(5)); + } + } + + fn reap_external(shared: &Shared) { + let child = shared.child(); + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if child.try_wait().unwrap().is_some() { + return; + } + assert!(Instant::now() < deadline, "external child was not reaped"); + sleep(Duration::from_millis(5)); + } + } + + #[test] + fn delayed_marker_process() { + let Some(directory) = std::env::var_os(MARKER_ENV) else { + return; + }; + let paths = MarkerPaths::new(Path::new(&directory)); + fs::write(&paths.ready, b"ready").expect("publish child readiness"); + let deadline = Instant::now() + EXIT_TIMEOUT; + while !paths.go.exists() { + if Instant::now() >= deadline { + return; + } + sleep(Duration::from_millis(5)); + } + fs::write(paths.marker, b"survived").expect("write delayed marker"); + } + + #[test] + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_residue_destruction_occurs_after_a_controllable_child_is_returned() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let directory = tempfile::tempdir().unwrap(); + let paths = MarkerPaths::new(directory.path()); + let shared = Arc::new(Shared::default()); + shared.set_paths(paths.clone()); + let identity = Arc::new(()); + shared.set_drop_behavior(DropBehavior::PanicCommitted(DeferredPayload( + Arc::clone(&identity), + ))); + let mut command = command(Arc::clone(&shared)); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let mut child = match outcome { + Ok(Ok(child)) => child, + Ok(Err(error)) => panic!("provider spawn failed: {error}"), + Err(payload) => { + let payload = payload + .downcast::() + .expect("the committed transaction supplied the panic payload"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + wait_for_path(&paths.ready); + fs::write(&paths.go, b"go").unwrap(); + wait_for_path(&paths.marker); + reap_external(&shared); + shared.clear_child(); + panic!("spawn lost its child while the delayed marker process survived"); + } + }; + + wait_for_path(&paths.ready); + child.start_kill().expect("start child termination"); + let first = $wait_for_child!(runtime, child).expect("await child termination"); + let second = $wait_for_child!(runtime, child).expect("repeat child wait"); + assert_eq!(first, second); + assert_eq!( + child.try_wait().expect("repeat child try_wait"), + Some(first) + ); + fs::write(&paths.go, b"go").unwrap(); + assert!( + !paths.marker.exists(), + "terminated child wrote its delayed marker" + ); + + let payload = catch_unwind(AssertUnwindSafe(|| drop(child))) + .expect_err("child disposal destroys committed transaction residue"); + let payload = payload + .downcast::() + .expect("the exact deferred transaction payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(shared.events(), vec![Event::Commit, Event::TransactionDrop]); + assert!(!shared.events().contains(&Event::Rollback)); + assert!( + shared + .child() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + .unwrap() + .is_some() + ); + shared.clear_child(); + + let reused_directory = tempfile::tempdir().unwrap(); + let reused_paths = MarkerPaths::new(reused_directory.path()); + shared.set_paths(reused_paths.clone()); + let mut child = command.spawn().expect("the command remains reusable"); + wait_for_path(&reused_paths.ready); + child.start_kill().expect("start reused child termination"); + let first = + $wait_for_child!(runtime, child).expect("await reused child termination"); + let second = $wait_for_child!(runtime, child).expect("repeat reused child wait"); + assert_eq!(first, second); + fs::write(&reused_paths.go, b"go").unwrap(); + assert!(!reused_paths.marker.exists()); + drop(child); + shared.clear_child(); + assert_eq!( + shared.events(), + vec![Event::Commit, Event::TransactionDrop, Event::Commit] + ); + } + + #[test] + #[cfg_attr(miri, ignore = "requires native child processes")] + fn rollback_reaps_the_child_before_transaction_disposal_panics() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let directory = tempfile::tempdir().unwrap(); + let paths = MarkerPaths::new(directory.path()); + let shared = Arc::new(Shared::default()); + shared.set_paths(paths.clone()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_drop_behavior(DropBehavior::PanicSecondary(PanickingDropPayload( + Arc::clone(&secondary_drops), + ))); + let identity = Arc::new(()); + let mut command = command(Arc::clone(&shared)); + command.wrap(FailPostSpawn(Mutex::new(Some(Arc::clone(&identity))))); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let error = outcome + .expect("transaction disposal must not replace the primary error") + .expect_err("the post-spawn hook must fail"); + let error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("the exact primary error payload is preserved"); + assert!(Arc::ptr_eq(&error.0, &identity)); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert_eq!( + shared.events(), + vec![Event::Rollback, Event::TransactionDrop] + ); + assert!( + shared + .child() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + .unwrap() + .is_some() + ); + fs::write(&paths.go, b"go").unwrap(); + assert!( + !paths.marker.exists(), + "rolled-back child wrote its delayed marker" + ); + shared.clear_child(); + + let reused_directory = tempfile::tempdir().unwrap(); + let reused_paths = MarkerPaths::new(reused_directory.path()); + shared.set_paths(reused_paths.clone()); + let mut child = command.spawn().expect("the command remains reusable"); + wait_for_path(&reused_paths.ready); + child.start_kill().expect("start reused child termination"); + $wait_for_child!(runtime, child).expect("await reused child termination"); + fs::write(&reused_paths.go, b"go").unwrap(); + assert!(!reused_paths.marker.exists()); + drop(child); + shared.clear_child(); + } + } + }; +} + #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] mod tokio_kill_on_drop_policy { use std::io; @@ -1268,3 +2315,38 @@ spawn_provider_tests!( .unwrap() ) ); + +#[cfg(feature = "std")] +real_provider_tests!( + std_process_frontend, + process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, + process_wrap::std::CommandWrapper, + process_wrap::std::ChildWrapper, + process_wrap::std::ProviderProduct, + process_wrap::std::SpawnProvider, + std::process::Command, + std_shared_process_methods, + std_wait_for_child, + None +); + +#[cfg(feature = "tokio1")] +real_provider_tests!( + tokio_process_frontend, + process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, + process_wrap::tokio::CommandWrapper, + process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ProviderProduct, + process_wrap::tokio::SpawnProvider, + tokio::process::Command, + tokio_shared_process_methods, + tokio_wait_for_child, + Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + ) +); From af8ec83af3bf1a5545b239adfac99afbdeb2800b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 08:13:30 +1300 Subject: [PATCH 053/114] fix(provider): transfer committed residue with child Co-Authored-By: Claude Code --- src/generic_wrap.rs | 163 +++++++++++++++++++++----- tests/spawn_provider.rs | 252 +++++++++++++++++++++++++++++++++++++++- tests/tokio_pty_unix.rs | 4 +- 3 files changed, 387 insertions(+), 32 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 766d937..a952962 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -62,7 +62,9 @@ macro_rules! Wrap { /// armed, independently owned from the child chain, and able to undo this specific spawn until /// process-wrap commits it. On Windows, that includes every ordinary child-finalization and /// cleanup-disarm hook plus JobObject owner validation and non-owner disarming. Only the sole - /// JobObject owner hook, if present, runs after commit. + /// JobObject owner hook, if present, runs after commit. After that owner disarms, process-wrap + /// transfers the committed transaction residue in a private transparent layer with the returned + /// child. #[derive(Debug)] pub struct ProviderProduct { child: Box, @@ -74,8 +76,10 @@ macro_rules! Wrap { /// /// Construct this only after the child has been created successfully. The transaction must own /// everything needed to terminate and reap that child and release provider resources if a later - /// hook, pre-commit child-finalization step, or transaction commit fails or unwinds. These panic - /// guarantees do not apply to `panic=abort`. + /// hook, pre-commit child-finalization step, or transaction commit fails or unwinds. A successful + /// commit must release every rollback-only strong owner and independent liveness resource; the + /// remaining residue is transferred with the returned child. These panic guarantees do not apply + /// to `panic=abort`. pub fn new( child: Box, transaction: Box, @@ -90,6 +94,89 @@ macro_rules! Wrap { } } + #[derive(Debug)] + enum ProviderTransactionState { + Armed(Box), + Committed(Box), + Transferred, + } + + impl ProviderTransactionState { + fn new(transaction: Box) -> Self { + Self::Armed(transaction) + } + + fn commit(&mut self) -> ::std::io::Result<()> { + let Self::Armed(transaction) = self else { + unreachable!("only an armed provider transaction can commit"); + }; + transaction.commit()?; + let Self::Armed(transaction) = ::std::mem::replace(self, Self::Transferred) else { + unreachable!("the provider transaction remains armed until commit returns"); + }; + *self = Self::Committed(transaction); + Ok(()) + } + + fn take_for_failure(&mut self) -> Option<(bool, Box)> { + match ::std::mem::replace(self, Self::Transferred) { + Self::Armed(transaction) => Some((true, transaction)), + Self::Committed(transaction) => Some((false, transaction)), + Self::Transferred => None, + } + } + + fn transfer(&mut self, child: Box) -> Box { + let Self::Committed(transaction) = ::std::mem::replace(self, Self::Transferred) else { + unreachable!("only committed transaction residue can transfer to a child"); + }; + Box::new(CommittedProviderChild { + child, + residue: ::std::sync::Arc::new(::std::sync::Mutex::new(transaction)), + }) + } + } + + #[derive(Debug)] + struct CommittedProviderChild { + child: Box, + residue: ::std::sync::Arc<::std::sync::Mutex>>, + } + + impl $childer for CommittedProviderChild { + fn inner(&self) -> &dyn $childer { + self.child.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self.child.as_mut() + } + + fn into_inner(self: Box) -> Box { + let Self { child, residue: _ } = *self; + child + } + + #[cfg(windows)] + fn process_handle(&self) -> Option<::std::os::windows::io::BorrowedHandle<'_>> { + self.child.process_handle() + } + + #[cfg(windows)] + fn resume_after_job_assignment(&mut self) -> Option<::std::io::Result<()>> { + self.child.resume_after_job_assignment() + } + + fn try_clone(&self) -> Option> { + self.child.try_clone().map(|child| { + Box::new(Self { + child, + residue: ::std::sync::Arc::clone(&self.residue), + }) as Box + }) + } + } + /// An alternate transport for spawning this frontend's child contract. /// /// Providers are exposed by command wrappers through [`CommandWrapper::spawn_provider`]. A @@ -101,9 +188,15 @@ macro_rules! Wrap { /// rejection, `validate_command`, every `pre_spawn` hook in registration order, native-only /// attempt rejection, `validate_attempt`, and `spawn`. After `spawn` returns a product, every /// `post_spawn` and child-wrapping hook runs in registration order. On Windows, process-wrap then - /// completes the pre-commit child phase while provider rollback remains armed, commits and drops - /// the transaction, and finally disarms the sole JobObject cleanup owner. `spawn_with` and - /// `spawn_with_child` reject a registered provider instead of bypassing it. + /// completes the pre-commit child phase while provider rollback remains armed, commits the + /// transaction, and disarms the sole JobObject cleanup owner. Successful commit ends failed-spawn + /// rollback. After the final owner succeeds, process-wrap transfers the committed transaction + /// residue in a private transparent layer with the returned child, so arbitrary residue + /// destruction occurs outside the spawn lifecycle. `spawn_with` and `spawn_with_child` reject a + /// registered provider instead of bypassing it. + /// + /// A committed transaction residue must retain no armed cleanup or independent process, + /// terminal, controller, handle, pseudoconsole, or other liveness resource. /// /// Rollback, wrapper restoration, and original panic-payload preservation apply only to /// unwinding panics. With `panic=abort`, the process terminates before lifecycle recovery can @@ -443,11 +536,36 @@ macro_rules! Wrap { result } - fn rollback_transaction(transaction: Box) { - let _ = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - let mut transaction = transaction; + fn quarantine_cleanup_panic(result: ::std::thread::Result<()>) { + if let Err(payload) = result { + ::std::mem::forget(payload); + } + } + + fn dispose_transaction(transaction: Box) { + let disposal = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { + drop(transaction); + })); + Self::quarantine_cleanup_panic(disposal); + } + + fn rollback_transaction(mut transaction: Box) { + let rollback = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { let _ = transaction.rollback(); })); + Self::quarantine_cleanup_panic(rollback); + Self::dispose_transaction(transaction); + } + + fn cleanup_failed_transaction(transaction: &mut ProviderTransactionState) { + let Some((armed, transaction)) = transaction.take_for_failure() else { + return; + }; + if armed { + Self::rollback_transaction(transaction); + } else { + Self::dispose_transaction(transaction); + } } fn finish_provider_spawn( @@ -456,7 +574,7 @@ macro_rules! Wrap { product: ProviderProduct, ) -> ::std::io::Result> { let (mut child, transaction) = product.into_parts(); - let mut transaction = Some(transaction); + let mut transaction = ProviderTransactionState::new(transaction); let result = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { #[cfg(windows)] let prepared = self.run_prepare_child(attempt, child.as_mut())?; @@ -467,32 +585,20 @@ macro_rules! Wrap { let child = self.run_wrap_child(child)?; #[cfg(windows)] let final_owner = child.finalize_spawn_before_commit()?; - transaction - .as_mut() - .expect("the provider transaction remains armed until commit") - .commit()?; - drop( - transaction - .take() - .expect("a committed provider transaction is still present"), - ); + transaction.commit()?; #[cfg(windows)] child.finalize_spawn_final_owner(final_owner)?; - Ok(child) + Ok(transaction.transfer(child)) })); match result { Ok(Ok(child)) => Ok(child), Ok(Err(error)) => { - if let Some(transaction) = transaction.take() { - Self::rollback_transaction(transaction); - } + Self::cleanup_failed_transaction(&mut transaction); Err(error) } Err(payload) => { - if let Some(transaction) = transaction.take() { - Self::rollback_transaction(transaction); - } + Self::cleanup_failed_transaction(&mut transaction); ::std::panic::resume_unwind(payload) } } @@ -538,8 +644,9 @@ macro_rules! Wrap { /// /// With no alternate provider, this runs all `pre_spawn` hooks, spawns through the native /// frontend, runs all capability-level `post_spawn` hooks, then stacks all - /// `wrap_child`s. A registered provider replaces only the native transport and commits its - /// cleanup transaction after the same complete hook chain succeeds. + /// `wrap_child`s. A registered provider replaces only the native transport, commits its cleanup + /// transaction after the same complete hook chain succeeds, and returns the child with the + /// committed transaction residue in a private transparent layer. pub fn spawn(&mut self) -> ::std::io::Result> { if let Some(provider_index) = self.select_spawn_provider()? { return self.spawn_with_provider(provider_index); diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 7911b77..282d673 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -651,7 +651,8 @@ macro_rules! spawn_provider_tests { _child: &mut dyn ChildWrapper, _command: &CommandWrap, ) -> io::Result<()> { - match self.0.lock().unwrap().take() { + let failure = self.0.lock().unwrap().take(); + match failure { Some(PrimaryFailure::Error(identity)) => { Err(io::Error::other(IdentityError(identity))) } @@ -890,7 +891,7 @@ macro_rules! spawn_provider_tests { let mut command = provider_command(Arc::clone(&shared), "provider"); let child = command.spawn().unwrap(); - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(child.inner().type_id(), TypeId::of::()); assert_eq!(shared.events(), successful_events("provider")); } @@ -1718,6 +1719,218 @@ macro_rules! tokio_wait_for_child { }; } +macro_rules! provider_capability_tests { + ( + $module:ident, + $command_wrap:path, + $spawn_attempt:path, + $command_wrapper:path, + $child_wrapper:path, + $provider_product:path, + $spawn_provider:path, + $native_command:path, + $native_child:path, + $wait_for_child:ident, + $runtime:expr + ) => { + mod $module { + use std::{ + any::TypeId, + io, + process::Stdio, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use process_wrap::SpawnTransaction; + use $child_wrapper as ChildWrapper; + use $command_wrap as CommandWrap; + use $command_wrapper as CommandWrapper; + use $provider_product as ProviderProduct; + use $spawn_attempt as SpawnAttempt; + use $spawn_provider as SpawnProvider; + + #[derive(Debug)] + struct CompletedTransaction { + drops: Arc, + committed: bool, + } + + impl SpawnTransaction for CompletedTransaction { + fn commit(&mut self) -> io::Result<()> { + self.committed = true; + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl Drop for CompletedTransaction { + fn drop(&mut self) { + assert!( + self.committed, + "the completed-child transaction must commit" + ); + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[derive(Debug)] + struct Provider { + drops: Arc, + } + + impl SpawnProvider for Provider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result { + let mut command = <$native_command>::new(std::env::current_exe()?); + command + .args([ + "--exact", + concat!(stringify!($module), "::completed_child_process"), + "--nocapture", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn()?; + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "capability child did not exit", + )); + } + sleep(Duration::from_millis(5)); + } + Ok(ProviderProduct::new( + Box::new(child), + Box::new(CompletedTransaction { + drops: Arc::clone(&self.drops), + committed: false, + }), + )) + } + } + + #[derive(Debug)] + struct ProviderWrapper(Provider); + + impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct OuterLayer(Box); + + impl ChildWrapper for OuterLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.0.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.0.as_mut() + } + + fn into_inner(self: Box) -> Box { + self.0 + } + + #[cfg(windows)] + fn process_handle(&self) -> Option> { + self.0.process_handle() + } + } + + #[derive(Debug)] + struct OuterWrapper; + + impl CommandWrapper for OuterWrapper { + fn wrap_child( + &mut self, + child: Box, + _command: &CommandWrap, + ) -> io::Result> { + Ok(Box::new(OuterLayer(child))) + } + } + + fn runtime() -> Option { + $runtime + } + + fn command(drops: Arc) -> CommandWrap { + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(ProviderWrapper(Provider { drops })) + .wrap(OuterWrapper); + command + } + + #[test] + fn completed_child_process() {} + + #[test] + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_sidecar_delegates_the_complete_native_child_contract() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = command(Arc::clone(&drops)); + + let mut child = command.spawn().expect("spawn completed provider child"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert_eq!(child.inner().type_id(), TypeId::of::()); + assert_eq!( + child.inner().inner().type_id(), + TypeId::of::<$native_child>() + ); + assert!(child.stdin().is_some()); + assert!(child.stdout().is_some()); + assert!(child.stderr().is_some()); + let native_id = child + .try_inner_child() + .expect("traverse to immutable native child") + .id(); + assert_eq!(child.id(), native_id); + // SAFETY: the process is complete and the forwarding layer owns no supervision state. + assert!(unsafe { child.try_inner_child_mut() }.is_some()); + let first = $wait_for_child!(runtime, child).expect("first repeated wait"); + let second = $wait_for_child!(runtime, child).expect("second repeated wait"); + assert_eq!(first, second); + assert_eq!(child.try_wait().expect("repeat try_wait"), Some(first)); + assert!(child.try_clone().is_none()); + #[cfg(windows)] + assert!(child.try_process_handle().is_some()); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + + let child = command.spawn().expect("spawn child for native extraction"); + // SAFETY: the process is complete, the transaction has no cleanup resources after commit, + // and the forwarding layer owns no supervision state. + let mut child = + unsafe { child.try_into_inner_child() }.expect("extract native child"); + assert_eq!(drops.load(Ordering::SeqCst), 2); + let first = $wait_for_child!(runtime, child).expect("wait extracted native child"); + let second = $wait_for_child!(runtime, child).expect("repeat extracted child wait"); + assert_eq!(first, second); + } + } + }; +} + macro_rules! real_provider_tests { ( $module:ident, @@ -2316,6 +2529,41 @@ spawn_provider_tests!( ) ); +#[cfg(feature = "std")] +provider_capability_tests!( + std_provider_capabilities, + process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, + process_wrap::std::CommandWrapper, + process_wrap::std::ChildWrapper, + process_wrap::std::ProviderProduct, + process_wrap::std::SpawnProvider, + std::process::Command, + std::process::Child, + std_wait_for_child, + None +); + +#[cfg(feature = "tokio1")] +provider_capability_tests!( + tokio_provider_capabilities, + process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, + process_wrap::tokio::CommandWrapper, + process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ProviderProduct, + process_wrap::tokio::SpawnProvider, + tokio::process::Command, + tokio::process::Child, + tokio_wait_for_child, + Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + ) +); + #[cfg(feature = "std")] real_provider_tests!( std_process_frontend, diff --git a/tests/tokio_pty_unix.rs b/tests/tokio_pty_unix.rs index d45d637..a7a076d 100644 --- a/tests/tokio_pty_unix.rs +++ b/tests/tokio_pty_unix.rs @@ -519,7 +519,7 @@ async fn assert_group_signal(mut command: Command) -> io::Result<()> { "stty -echo; trap '' HUP; trap 'exit 0' TERM; sleep 30 & printf ready; wait", ]); let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(child.inner().type_id(), TypeId::of::()); assert!(child.try_wait()?.is_none()); let (input, mut output, _resize) = controller.into_parts(); @@ -588,7 +588,7 @@ async fn process_group_composes_when_registered_after_first_spawn() -> io::Resul command.wrap(ProcessGroup::leader()); let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(child.inner().type_id(), TypeId::of::()); let (input, mut output, _resize) = controller.into_parts(); drop(input); let (status, bytes) = wait_and_drain(child.as_mut(), &mut output).await?; From 4059d7a01ef4c77abfed4797f02c0fc079cb0ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 08:21:15 +1300 Subject: [PATCH 054/114] fix(pty): release committed rollback resources Co-Authored-By: Claude Code --- src/tokio/pty.rs | 8 ++ src/tokio/pty/unix.rs | 61 +++++++++++++-- src/tokio/pty/windows/backend.rs | 128 ++++++++++++++++++++++++++++--- src/tokio/pty/windows/console.rs | 22 ++++++ src/tokio/pty/windows/spawn.rs | 2 +- 5 files changed, 203 insertions(+), 18 deletions(-) diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index 9f988f4..7dc41d2 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -443,6 +443,14 @@ impl ControllerSlot { } } + #[cfg(all(test, windows))] + pub(super) fn empty_for_test() -> Self { + Self { + controller: Mutex::new(None), + committed: AtomicBool::new(false), + } + } + pub(super) fn commit(&self) { self.committed.store(true, Ordering::Release); } diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 1e35a82..3735a7a 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -271,16 +271,16 @@ fn with_slave_stdio( #[derive(Debug)] struct PtyTransaction { - child: Arc>, - controller: Arc, + child: Option>>, + controller: Option>, armed: bool, } impl PtyTransaction { fn new(child: Arc>, controller: Arc) -> Self { Self { - child, - controller, + child: Some(child), + controller: Some(controller), armed: true, } } @@ -289,16 +289,25 @@ impl PtyTransaction { if !std::mem::replace(&mut self.armed, false) { return Ok(()); } - self.controller.rollback(); - terminate_and_reap(&self.child) + if let Some(controller) = self.controller.take() { + controller.rollback(); + } + self.child + .take() + .map_or(Ok(()), |child| terminate_and_reap(&child)) } } impl SpawnTransaction for PtyTransaction { fn commit(&mut self) -> io::Result<()> { if self.armed { - self.controller.commit(); + self.controller + .as_ref() + .expect("an armed PTY transaction owns its controller") + .commit(); self.armed = false; + self.child.take(); + self.controller.take(); } Ok(()) } @@ -676,6 +685,44 @@ mod tests { ) } + #[tokio::test] + async fn committed_transaction_releases_rollback_only_strong_owners() { + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "exit 0"]); + let child = Arc::new(Mutex::new( + command.spawn().expect("spawn transaction child"), + )); + + let (master, slave) = open_pty(PtySize::default()).expect("allocate transaction PTY"); + drop(slave); + let master = Arc::new(AsyncFd::new(master).expect("register transaction PTY master")); + let controller = Arc::new(ControllerSlot::new(PtyController::new( + Input { + master: Some(Arc::clone(&master)), + }, + Output { + master: Arc::clone(&master), + }, + Resize { + master: Arc::downgrade(&master), + }, + ))); + drop(master); + + let mut transaction = PtyTransaction::new(Arc::clone(&child), Arc::clone(&controller)); + assert_eq!(Arc::strong_count(&child), 2); + assert_eq!(Arc::strong_count(&controller), 2); + transaction.commit().expect("commit PTY transaction"); + assert_eq!(Arc::strong_count(&child), 1); + assert_eq!(Arc::strong_count(&controller), 1); + + let mut child = child.lock().unwrap(); + if child.try_wait().unwrap().is_none() { + child.start_kill().expect("terminate transaction child"); + child.wait().await.expect("reap transaction child"); + } + } + #[test] fn winsize_preserves_character_and_pixel_dimensions() { let native = winsize(PtySize { diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs index 7efbbe2..84752b8 100644 --- a/src/tokio/pty/windows/backend.rs +++ b/src/tokio/pty/windows/backend.rs @@ -52,29 +52,137 @@ pub(in crate::tokio::pty) fn spawn( Ok(ProviderProduct::new( Box::new(child), Box::new(ConPtyTransaction { - cleanup, - controller, - release, + cleanup: Some(cleanup), + controller: Some(controller), + release: Some(release), }), )) } #[derive(Debug)] struct ConPtyTransaction { - cleanup: SpawnCleanup, - controller: Arc, - release: Release, + cleanup: Option, + controller: Option>, + release: Option, } impl SpawnTransaction for ConPtyTransaction { fn commit(&mut self) -> io::Result<()> { - self.release.release()?; - self.cleanup.disarm(); + self.release + .as_ref() + .expect("an armed ConPTY transaction owns its release capability") + .release()?; + self.cleanup + .as_mut() + .expect("an armed ConPTY transaction owns its cleanup duplicate") + .disarm(); + self.cleanup.take(); + self.controller.take(); + self.release.take(); Ok(()) } fn rollback(&mut self) -> io::Result<()> { - self.controller.rollback(); - self.cleanup.rollback() + if let Some(controller) = self.controller.take() { + controller.rollback(); + } + self.release.take(); + self.cleanup + .take() + .map_or(Ok(()), |mut cleanup| cleanup.rollback()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + os::windows::io::{FromRawHandle, OwnedHandle}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, + }; + + use windows::{ + Win32::{ + Foundation::HANDLE, + System::{ + Console::{COORD, HPCON}, + Threading::CreateEventW, + }, + }, + core::HRESULT, + }; + + use super::*; + + static RELEASES: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "system" fn create( + _size: COORD, + _input: HANDLE, + _output: HANDLE, + _flags: u32, + _pseudo_console: *mut HPCON, + ) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn resize(_pseudo_console: HPCON, _size: COORD) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn release(_pseudo_console: HPCON) -> HRESULT { + RELEASES.fetch_add(1, Ordering::SeqCst); + HRESULT(0) + } + + unsafe extern "system" fn close(_pseudo_console: HPCON) {} + + static API: api::ConPtyApi = api::ConPtyApi { + create, + resize, + release, + close, + }; + + #[test] + fn committed_transaction_releases_rollback_only_owners() -> io::Result<()> { + RELEASES.store(0, Ordering::SeqCst); + // SAFETY: default security, manual reset, initially unsignaled, and no name request a new event + // handle which is transferred immediately to `OwnedHandle`. + let event = unsafe { CreateEventW(None, true, false, None) }?; + let expected_handle = event.0 as usize; + // SAFETY: the successful CreateEventW result is uniquely owned here. + let process = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let (released, observe_release) = mpsc::channel(); + let cleanup = SpawnCleanup::tracked(process, released); + let controller = Arc::new(ControllerSlot::empty_for_test()); + let controller_observer = Arc::clone(&controller); + let (release, release_observer) = Release::tracked(&API, HPCON(42)); + let mut transaction = ConPtyTransaction { + cleanup: Some(cleanup), + controller: Some(controller), + release: Some(release), + }; + + assert_eq!(Arc::strong_count(&controller_observer), 2); + assert_eq!(release_observer.strong_count(), 1); + transaction.commit()?; + + assert_eq!(RELEASES.load(Ordering::SeqCst), 1); + assert_eq!( + observe_release + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + expected_handle + ); + assert_eq!(Arc::strong_count(&controller_observer), 1); + assert_eq!(release_observer.strong_count(), 0); + drop(transaction); + assert_eq!(RELEASES.load(Ordering::SeqCst), 1); + Ok(()) } } diff --git a/src/tokio/pty/windows/console.rs b/src/tokio/pty/windows/console.rs index a4a53e8..c4ebad8 100644 --- a/src/tokio/pty/windows/console.rs +++ b/src/tokio/pty/windows/console.rs @@ -25,6 +25,17 @@ pub(super) struct Release { state: Arc, } +#[cfg(test)] +#[derive(Debug)] +pub(super) struct ReleaseTracker(std::sync::Weak); + +#[cfg(test)] +impl ReleaseTracker { + pub(super) fn strong_count(&self) -> usize { + self.0.strong_count() + } +} + #[derive(Debug)] struct State { handle: HPCON, @@ -104,6 +115,17 @@ impl PseudoConsole { } impl Release { + #[cfg(test)] + pub(super) fn tracked(api: &'static ConPtyApi, handle: HPCON) -> (Self, ReleaseTracker) { + let state = Arc::new(State { + handle, + api, + lifecycle: Mutex::new(Lifecycle::default()), + }); + let tracker = ReleaseTracker(Arc::downgrade(&state)); + (Self { state }, tracker) + } + pub(super) fn release(&self) -> io::Result<()> { let mut lifecycle = self .state diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index 98ad74c..aec455a 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -128,7 +128,7 @@ impl SpawnCleanup { } #[cfg(test)] - fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { + pub(super) fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { Self { process: Some(ReapHandle::tracked(process, released)), } From 77b65b235ca99122a2fdf57bcae317e81744450e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 08:21:42 +1300 Subject: [PATCH 055/114] docs(provider): define committed residue ownership Co-Authored-By: Claude Code --- README.md | 18 +++++++++++++----- src/command.rs | 17 +++++++++++++++-- src/generic_wrap.rs | 6 +++--- src/lib.rs | 19 ++++++++++++++----- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0693394..73f4efb 100644 --- a/README.md +++ b/README.md @@ -382,15 +382,23 @@ Callbacks run in this order: 8. every `post_spawn` hook 9. every child wrapper 10. transaction `commit` +11. on Windows, the sole JobObject cleanup owner +12. committed transaction-residue transfer to the returned child Validation must reject unsupported portable policy before allocating operating-system resources. `spawn` returns a child satisfying the frontend's complete `ChildWrapper` contract together with a fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. A -later hook, wrapper, or commit error or unwinding panic causes best-effort rollback while preserving -the original failure. Rollback, wrapper restoration, original panic-payload preservation, and -cleanup-diagnostic panic containment apply only to unwinding panics. With `panic=abort`, the process -terminates before those guarantees can run. Until `spawn` returns the product, cleanup remains the -provider's responsibility. +later hook, wrapper, pre-commit child step, or commit error or unwinding panic causes best-effort +rollback while preserving the original failure. Successful commit ends failed-spawn rollback. On +Windows, the sole JobObject owner remains authoritative until it disarms after commit. Process-wrap +then transfers the committed transaction residue in a private transparent layer with the returned +child. On a successful spawn, arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue must +retain no armed cleanup or independent process, terminal, controller, handle, pseudoconsole, or other +liveness resource. + +Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic +containment apply only to unwinding panics. With `panic=abort`, the process terminates before those +guarantees can run. Until `spawn` returns the product, cleanup remains the provider's responsibility. A command may register only one provider; conflicts are rejected before any provider callback or operating-system allocation. Both providers and wrapper state are reused across repeated spawns. diff --git a/src/command.rs b/src/command.rs index 61197b0..a80f5c9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -543,8 +543,16 @@ impl fmt::Debug for CommandState { /// provider rollback resources on success. If pre-commit work or `commit` returns an error or unwinds, /// the transaction must remain rollbackable; process-wrap then makes one best-effort /// [`rollback`](SpawnTransaction::rollback) call. A rollback error or unwinding panic is suppressed so -/// the original failure is preserved. After successful commit, process-wrap drops the transaction and -/// runs only the sole JobObject owner hook, whose contract requires failure-atomic disarming. +/// the original failure is preserved. Transaction destruction after rollback is contained separately, +/// and secondary panic payloads are quarantined without being inspected or destroyed. +/// +/// Successful commit ends failed-spawn rollback. Process-wrap retains the committed transaction +/// residue until the sole JobObject owner, if any, disarms successfully, then transfers that residue +/// in a private transparent layer with the returned child. On a successful spawn, arbitrary +/// transaction destruction therefore occurs only after the child has left the spawn lifecycle. A +/// committed transaction must retain no armed cleanup or independent process, terminal, controller, +/// handle, pseudoconsole, or other liveness resource. If the final owner fails after commit, its still-armed cleanup remains +/// authoritative while process-wrap contains disposal of the committed residue as secondary cleanup. /// /// On non-Windows targets there is no hidden child-finalization phase, so commit follows the public /// hooks directly. These panic guarantees require unwinding; `panic=abort` terminates the process @@ -554,6 +562,11 @@ impl fmt::Debug for CommandState { /// `spawn` implementation remains the provider's responsibility. pub trait SpawnTransaction: fmt::Debug + Send + 'static { /// Finalize the successful spawn and disarm rollback resources. + /// + /// On success, the transaction must release every rollback-only strong owner and every + /// independent process, terminal, controller, handle, pseudoconsole, or other liveness resource. + /// Process-wrap may retain and later destroy the committed transaction residue with the returned + /// child. fn commit(&mut self) -> std::io::Result<()>; /// Undo an uncommitted spawn. diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index a952962..e7d3cce 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -191,9 +191,9 @@ macro_rules! Wrap { /// completes the pre-commit child phase while provider rollback remains armed, commits the /// transaction, and disarms the sole JobObject cleanup owner. Successful commit ends failed-spawn /// rollback. After the final owner succeeds, process-wrap transfers the committed transaction - /// residue in a private transparent layer with the returned child, so arbitrary residue - /// destruction occurs outside the spawn lifecycle. `spawn_with` and `spawn_with_child` reject a - /// registered provider instead of bypassing it. + /// residue in a private transparent layer with the returned child. On the successful path, + /// arbitrary residue destruction occurs outside the spawn lifecycle. `spawn_with` and + /// `spawn_with_child` reject a registered provider instead of bypassing it. /// /// A committed transaction residue must retain no armed cleanup or independent process, /// terminal, controller, handle, pseudoconsole, or other liveness resource. diff --git a/src/lib.rs b/src/lib.rs index 2b3cf49..4d46bd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -276,15 +276,24 @@ //! 8. every `post_spawn` hook //! 9. every child wrapper //! 10. transaction `commit` +//! 11. on Windows, the sole JobObject cleanup owner +//! 12. committed transaction-residue transfer to the returned child //! //! Validation rejects unsupported portable policy before operating-system allocation. `spawn` //! returns a child satisfying the frontend's complete `ChildWrapper` contract and a fresh, armed //! `SpawnTransaction` which owns cleanup independently of the child chain. A later public hook, -//! wrapper, or commit error or unwinding panic causes best-effort rollback while preserving the -//! original failure. Rollback, wrapper restoration, original panic-payload preservation, and -//! cleanup-diagnostic panic containment apply only to unwinding panics. With `panic=abort`, the -//! process terminates before those guarantees can run. Cleanup before `spawn` returns that product -//! remains the provider's responsibility. +//! wrapper, pre-commit child step, or commit error or unwinding panic causes best-effort rollback +//! while preserving the original failure. Successful commit ends failed-spawn rollback. On Windows, +//! the sole JobObject owner remains authoritative until it disarms after commit. Process-wrap then +//! transfers the committed transaction residue in a private transparent layer with the returned +//! child. On a successful spawn, arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue +//! must retain no armed cleanup or independent process, terminal, controller, handle, +//! pseudoconsole, or other liveness resource. +//! +//! Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic +//! containment apply only to unwinding panics. With `panic=abort`, the process terminates before +//! those guarantees can run. Cleanup before `spawn` returns that product remains the provider's +//! responsibility. //! //! A command may register only one provider; conflicts are rejected before callbacks or allocation. //! Providers and wrapper state are reusable across repeated spawns. `spawn_with` and From ec0490854834dbdcc4ecd00d5e830be5e9a07adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 08:24:43 +1300 Subject: [PATCH 056/114] test(provider): keep lifecycle fixtures lint-clean Co-Authored-By: Claude Code --- src/tokio/pty/unix.rs | 5 ++- tests/spawn_provider.rs | 88 ++++++++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 3735a7a..142ed8f 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -716,7 +716,10 @@ mod tests { assert_eq!(Arc::strong_count(&child), 1); assert_eq!(Arc::strong_count(&controller), 1); - let mut child = child.lock().unwrap(); + let mut child = Arc::try_unwrap(child) + .expect("the committed transaction released its child owner") + .into_inner() + .unwrap(); if child.try_wait().unwrap().is_none() { child.start_kill().expect("terminate transaction child"); child.wait().await.expect("reap transaction child"); diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 282d673..03cc06a 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -1655,6 +1655,7 @@ macro_rules! spawn_provider_tests { }; } +#[cfg(feature = "std")] macro_rules! std_shared_process_methods { () => { fn id(&self) -> u32 { @@ -1673,6 +1674,7 @@ macro_rules! std_shared_process_methods { }; } +#[cfg(feature = "tokio1")] macro_rules! tokio_shared_process_methods { () => { fn id(&self) -> Option { @@ -1703,6 +1705,7 @@ macro_rules! tokio_shared_process_methods { }; } +#[cfg(feature = "std")] macro_rules! std_wait_for_child { ($runtime:expr, $child:expr) => {{ let _ = &$runtime; @@ -1710,6 +1713,7 @@ macro_rules! std_wait_for_child { }}; } +#[cfg(feature = "tokio1")] macro_rules! tokio_wait_for_child { ($runtime:expr, $child:expr) => { $runtime @@ -1784,6 +1788,7 @@ macro_rules! provider_capability_tests { #[derive(Debug)] struct Provider { drops: Arc, + wait_for_completion: bool, } impl SpawnProvider for Provider { @@ -1793,25 +1798,31 @@ macro_rules! provider_capability_tests { _command: &CommandWrap, ) -> io::Result { let mut command = <$native_command>::new(std::env::current_exe()?); + let child_test = if self.wait_for_completion { + concat!(stringify!($module), "::completed_child_process") + } else { + concat!(stringify!($module), "::live_child_process") + }; command - .args([ - "--exact", - concat!(stringify!($module), "::completed_child_process"), - "--nocapture", - ]) + .args(["--exact", child_test, "--nocapture"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if !self.wait_for_completion { + command.env("PROCESS_WRAP_LIVE_CAPABILITY_CHILD", "1"); + } let mut child = command.spawn()?; - let deadline = Instant::now() + Duration::from_secs(5); - while child.try_wait()?.is_none() { - if Instant::now() >= deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "capability child did not exit", - )); + if self.wait_for_completion { + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "capability child did not exit", + )); + } + sleep(Duration::from_millis(5)); } - sleep(Duration::from_millis(5)); } Ok(ProviderProduct::new( Box::new(child), @@ -1871,17 +1882,34 @@ macro_rules! provider_capability_tests { $runtime } - fn command(drops: Arc) -> CommandWrap { + fn command_with_completion( + drops: Arc, + wait_for_completion: bool, + ) -> CommandWrap { let mut command = CommandWrap::new("provider-owned-program"); command - .wrap(ProviderWrapper(Provider { drops })) + .wrap(ProviderWrapper(Provider { + drops, + wait_for_completion, + })) .wrap(OuterWrapper); command } + fn command(drops: Arc) -> CommandWrap { + command_with_completion(drops, true) + } + #[test] fn completed_child_process() {} + #[test] + fn live_child_process() { + if std::env::var_os("PROCESS_WRAP_LIVE_CAPABILITY_CHILD").is_some() { + sleep(Duration::from_secs(30)); + } + } + #[test] #[cfg_attr(miri, ignore = "requires native child processes")] fn committed_sidecar_delegates_the_complete_native_child_contract() { @@ -1912,21 +1940,43 @@ macro_rules! provider_capability_tests { assert_eq!(first, second); assert_eq!(child.try_wait().expect("repeat try_wait"), Some(first)); assert!(child.try_clone().is_none()); - #[cfg(windows)] - assert!(child.try_process_handle().is_some()); drop(child); assert_eq!(drops.load(Ordering::SeqCst), 1); let child = command.spawn().expect("spawn child for native extraction"); // SAFETY: the process is complete, the transaction has no cleanup resources after commit, // and the forwarding layer owns no supervision state. - let mut child = - unsafe { child.try_into_inner_child() }.expect("extract native child"); + let child = unsafe { child.try_into_inner_child() }; + let mut child = child.expect("extract native child"); assert_eq!(drops.load(Ordering::SeqCst), 2); let first = $wait_for_child!(runtime, child).expect("wait extracted native child"); let second = $wait_for_child!(runtime, child).expect("repeat extracted child wait"); assert_eq!(first, second); } + + #[cfg(windows)] + #[test] + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_sidecar_delegates_a_live_process_handle() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = command_with_completion(Arc::clone(&drops), false); + + let mut child = command.spawn().expect("spawn live provider child"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(child.try_process_handle().is_some()); + child.start_kill().expect("terminate live provider child"); + let first = $wait_for_child!(runtime, child).expect("reap live provider child"); + let second = $wait_for_child!(runtime, child).expect("repeat live child wait"); + assert_eq!(first, second); + assert_eq!( + child.try_wait().expect("repeat live child try_wait"), + Some(first) + ); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } } }; } From a5bd203d2357393969272e7bc2f675845b97b03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 09:41:01 +1300 Subject: [PATCH 057/114] test(lifecycle): expose child custody failures Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 587 ++++++++++++++++++++++++++++++++++++-- tests/spawn_with_child.rs | 314 +++++++++++++++++++- 2 files changed, 877 insertions(+), 24 deletions(-) diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 03cc06a..aee72e1 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -17,6 +17,7 @@ macro_rules! spawn_provider_tests { ffi::OsStr, io, panic::{AssertUnwindSafe, catch_unwind, panic_any}, + process::{Output, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -104,6 +105,9 @@ macro_rules! spawn_provider_tests { Wrap(&'static str), Commit, Rollback, + ChildDrop, + #[cfg(windows)] + PreparedDrop(&'static str), TransactionDrop, #[cfg(windows)] FinalizeSpawn(&'static str), @@ -130,8 +134,16 @@ macro_rules! spawn_provider_tests { failures: Mutex>, rollback_behavior: Mutex>, transaction_drop_behavior: Mutex>, + child_drop_payload: Mutex>, + commit_primary_failure: Mutex>, + transaction_debug_calls: AtomicUsize, + panic_in_transaction_debug: AtomicBool, make_attempt_native_only: AtomicBool, #[cfg(windows)] + use_resume_child: AtomicBool, + #[cfg(windows)] + resume_calls: AtomicUsize, + #[cfg(windows)] finalization_layers: Mutex>, } @@ -164,6 +176,22 @@ macro_rules! spawn_provider_tests { self.transaction_drop_behavior.lock().unwrap().take() } + fn set_child_drop_payload(&self, payload: PanickingDropPayload) { + *self.child_drop_payload.lock().unwrap() = Some(payload); + } + + fn take_child_drop_payload(&self) -> Option { + self.child_drop_payload.lock().unwrap().take() + } + + fn set_commit_primary_failure(&self, failure: PrimaryFailure) { + *self.commit_primary_failure.lock().unwrap() = Some(failure); + } + + fn take_commit_primary_failure(&self) -> Option { + self.commit_primary_failure.lock().unwrap().take() + } + #[cfg(windows)] fn set_finalization_layers(&self, layers: Vec) { *self.finalization_layers.lock().unwrap() = layers; @@ -238,6 +266,162 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[derive(Debug)] + struct ResumeChild(Arc); + + #[cfg(windows)] + impl ChildWrapper for ResumeChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn resume_after_job_assignment(&mut self) -> Option> { + self.0.resume_calls.fetch_add(1, Ordering::SeqCst); + Some(Ok(())) + } + + fn start_kill(&mut self) -> io::Result<()> { + Ok(()) + } + + fn try_wait(&mut self) -> io::Result> { + Ok(Some(successful_exit_status())) + } + } + + #[derive(Debug)] + struct PanickingChild { + shared: Arc, + payload: Option, + } + + impl ChildWrapper for PanickingChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + } + + impl Drop for PanickingChild { + fn drop(&mut self) { + self.shared.event(Event::ChildDrop); + panic_any( + self.payload + .take() + .expect("the child destructor panics only once"), + ); + } + } + + fn assert_provider_child(child: &dyn ChildWrapper) { + let id = child.type_id(); + let expected = id == TypeId::of::() || id == TypeId::of::(); + #[cfg(windows)] + let expected = expected || id == TypeId::of::(); + assert!(expected); + } + + #[cfg(windows)] + #[derive(Debug)] + struct PanickingPrepared { + shared: Arc, + name: &'static str, + payload: Option, + } + + #[cfg(windows)] + impl Drop for PanickingPrepared { + fn drop(&mut self) { + self.shared.event(Event::PreparedDrop(self.name)); + panic_any( + self.payload + .take() + .expect("the prepared-state destructor panics only once"), + ); + } + } + + #[cfg(windows)] + macro_rules! prepared_wrapper { + ($name:ident, $label:literal) => { + #[derive(Debug)] + struct $name { + shared: Arc, + payload: Mutex>, + } + + impl CommandWrapper for $name { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(self.payload.lock().unwrap().take().map(|payload| { + Box::new(PanickingPrepared { + shared: Arc::clone(&self.shared), + name: $label, + payload: Some(payload), + }) as Box + })) + } + + fn wrap_prepared_child( + &mut self, + child: Box, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + debug_assert!(prepared.is_none()); + Ok(child) + } + } + }; + } + + #[cfg(windows)] + prepared_wrapper!(FirstPrepared, "first"); + #[cfg(windows)] + prepared_wrapper!(SecondPrepared, "second"); + + #[cfg(windows)] + #[derive(Debug)] + struct FailPrepare(Mutex>); + + #[cfg(windows)] + impl CommandWrapper for FailPrepare { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + match self.0.lock().unwrap().take() { + Some(PrimaryFailure::Error(identity)) => { + Err(io::Error::other(IdentityError(identity))) + } + Some(PrimaryFailure::Panic(identity)) => panic_any(PrimaryPanic(identity)), + None => Ok(None), + } + } + } + #[cfg(windows)] #[derive(Debug)] struct FinalizationChild { @@ -322,12 +506,25 @@ macro_rules! spawn_provider_tests { }) } - #[derive(Debug)] struct Transaction { shared: Arc, committed: bool, } + impl std::fmt::Debug for Transaction { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.shared.transaction_debug_calls.fetch_add(1, Ordering::SeqCst); + if self + .shared + .panic_in_transaction_debug + .load(Ordering::SeqCst) + { + panic_any("transaction Debug must remain private"); + } + formatter.debug_struct("Transaction").finish_non_exhaustive() + } + } + impl Transaction { fn new(shared: Arc) -> Self { Self { @@ -340,6 +537,14 @@ macro_rules! spawn_provider_tests { impl SpawnTransaction for Transaction { fn commit(&mut self) -> io::Result<()> { self.shared.event(Event::Commit); + if let Some(failure) = self.shared.take_commit_primary_failure() { + match failure { + PrimaryFailure::Error(identity) => { + return Err(io::Error::other(IdentityError(identity))); + } + PrimaryFailure::Panic(identity) => panic_any(PrimaryPanic(identity)), + } + } self.shared.fail(Point::Commit)?; self.committed = true; Ok(()) @@ -467,8 +672,24 @@ macro_rules! spawn_provider_tests { assert!(!attempt.is_native_only()); self.shared.event(Event::Spawn(self.name)); self.shared.fail(Point::Spawn)?; + let child: Box = + if let Some(payload) = self.shared.take_child_drop_payload() { + Box::new(PanickingChild { + shared: Arc::clone(&self.shared), + payload: Some(payload), + }) + } else { + #[cfg(windows)] + if self.shared.use_resume_child.swap(false, Ordering::SeqCst) { + Box::new(ResumeChild(Arc::clone(&self.shared))) + } else { + Box::new(CustomChild) + } + #[cfg(not(windows))] + Box::new(CustomChild) + }; Ok(ProviderProduct::new( - Box::new(CustomChild), + child, Box::new(Transaction::new(Arc::clone(&self.shared))), )) } @@ -532,7 +753,7 @@ macro_rules! spawn_provider_tests { command: &CommandWrap, ) -> io::Result<()> { self.assert_hook_visibility(command); - assert_eq!(child.type_id(), TypeId::of::()); + assert_provider_child(child); self.shared().event(Event::Post(self.name)); self.shared().fail(Point::Post) } @@ -543,7 +764,7 @@ macro_rules! spawn_provider_tests { command: &CommandWrap, ) -> io::Result> { self.assert_hook_visibility(command); - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_provider_child(child.as_ref()); self.shared().event(Event::Wrap(self.name)); self.shared().fail(Point::Wrap)?; Ok(child) @@ -592,7 +813,7 @@ macro_rules! spawn_provider_tests { ) -> io::Result<()> { self.assert_hook_visibility(command); if self.expect_custom_child { - assert_eq!(child.type_id(), TypeId::of::()); + assert_provider_child(child); } self.shared.event(Event::Post("peer")); self.shared.fail(Point::PeerPost) @@ -605,7 +826,7 @@ macro_rules! spawn_provider_tests { ) -> io::Result> { self.assert_hook_visibility(command); if self.expect_custom_child { - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_provider_child(child.as_ref()); } self.shared.event(Event::Wrap("peer")); self.shared.fail(Point::PeerWrap)?; @@ -726,15 +947,27 @@ macro_rules! spawn_provider_tests { was_panic: bool, ) { if was_panic { - let payload = outcome.expect_err("the primary panic must be resumed"); - let payload = payload - .downcast::() - .expect("cleanup must not replace the primary panic payload"); + let payload = match outcome { + Err(payload) => payload, + Ok(_) => panic!("the primary panic must be resumed"), + }; + let payload = match payload.downcast::() { + Ok(payload) => payload, + Err(secondary) => { + std::mem::forget(secondary); + panic!("cleanup replaced the primary panic payload"); + } + }; assert!(Arc::ptr_eq(&payload.0, identity)); } else { - let error = outcome - .expect("cleanup must not replace the primary error with a panic") - .expect_err("the primary error must be returned"); + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + panic!("cleanup replaced the primary error with a panic"); + } + }; + let error = result.expect_err("the primary error must be returned"); let payload = error .get_ref() .and_then(|error| error.downcast_ref::()) @@ -849,6 +1082,59 @@ macro_rules! spawn_provider_tests { } } + fn bounded_test_process( + test_name: &str, + environment: (&str, &str), + timeout: Duration, + ) -> (Output, bool) { + struct ChildGuard(Option); + + impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture"]) + .env(environment.0, environment.1) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start isolated lifecycle regression"); + let mut child = ChildGuard(Some(child)); + let deadline = Instant::now() + timeout; + loop { + let status = child + .0 + .as_mut() + .expect("the subprocess remains guarded while polling") + .try_wait() + .expect("poll isolated lifecycle regression"); + if status.is_some() { + let output = child + .0 + .take() + .expect("take completed subprocess") + .wait_with_output() + .expect("collect isolated lifecycle output"); + return (output, false); + } + if Instant::now() >= deadline { + let mut expired = child.0.take().expect("take expired subprocess"); + let _ = expired.kill(); + let output = expired + .wait_with_output() + .expect("reap expired lifecycle subprocess"); + return (output, true); + } + sleep(Duration::from_millis(5)); + } + } + fn wait_for_exit(mut child: Box) { let deadline = Instant::now() + EXIT_TIMEOUT; loop { @@ -895,6 +1181,24 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[cfg(windows)] + #[test] + fn committed_sidecar_delegates_post_transfer_exact_resume() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.use_resume_child.store(true, Ordering::SeqCst); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let mut child = command.spawn().expect("spawn provider child"); + assert_eq!(shared.resume_calls.load(Ordering::SeqCst), 0); + child + .resume_after_job_assignment() + .expect("the sidecar delegates an exact resume capability") + .expect("the delegated exact resume succeeds"); + assert_eq!(shared.resume_calls.load(Ordering::SeqCst), 1); + } + #[test] fn committed_transaction_residue_moves_with_the_child_and_supported_clones() { let runtime = runtime(); @@ -951,6 +1255,23 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[test] + fn committed_sidecar_debug_never_formats_transaction_residue() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared + .panic_in_transaction_debug + .store(true, Ordering::SeqCst); + let mut command = provider_command(Arc::clone(&shared), "provider"); + let child = command.spawn().expect("spawn provider child"); + + let formatted = catch_unwind(AssertUnwindSafe(|| format!("{child:?}"))) + .expect("formatting a provider child must not inspect its transaction residue"); + assert!(formatted.contains("CustomChild")); + assert_eq!(shared.transaction_debug_calls.load(Ordering::SeqCst), 0); + } + #[test] fn consuming_the_provider_sidecar_disposes_ordinary_residue() { let runtime = runtime(); @@ -1332,20 +1653,35 @@ macro_rules! spawn_provider_tests { } } + #[test] + fn bounded_subprocess_reaps_the_timeout_path() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TIMEOUT_HELPER"; + let child_value = stringify!($module); + if std::env::var_os(CHILD_ENV).as_deref() == Some(OsStr::new(child_value)) { + sleep(Duration::from_secs(30)); + return; + } + + let (output, timed_out) = bounded_test_process( + concat!(stringify!($module), "::bounded_subprocess_reaps_the_timeout_path"), + (CHILD_ENV, child_value), + Duration::ZERO, + ); + assert!(timed_out, "the zero-deadline seam must take the timeout branch"); + assert!(!output.status.success(), "the timed-out helper must be terminated"); + } + #[test] fn rollback_and_transaction_unwinds_are_separated() { const CHILD_ENV: &str = "PROCESS_WRAP_SEPARATE_PROVIDER_UNWINDS"; let child_value = stringify!($module); if std::env::var_os(CHILD_ENV).as_deref() != Some(OsStr::new(child_value)) { - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - concat!(stringify!($module), "::rollback_and_transaction_unwinds_are_separated"), - "--nocapture", - ]) - .env(CHILD_ENV, child_value) - .output() - .expect("start isolated lifecycle regression"); + let (output, timed_out) = bounded_test_process( + concat!(stringify!($module), "::rollback_and_transaction_unwinds_are_separated"), + (CHILD_ENV, child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "isolated lifecycle regression exceeded its deadline"); assert!( output.status.success(), "rollback and transaction disposal overlapped:\nstdout:\n{}\nstderr:\n{}", @@ -1381,6 +1717,96 @@ macro_rules! spawn_provider_tests { assert!(shared.events().ends_with(&[Event::Rollback, Event::TransactionDrop])); } + #[test] + fn child_destructor_panics_do_not_replace_post_spawn_or_commit_errors() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for fail_commit in [false, true] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload { + drops: Arc::clone(&secondary_drops), + }); + let identity = Arc::new(()); + let mut command = if fail_commit { + shared.set_commit_primary_failure(PrimaryFailure::Error(Arc::clone(&identity))); + provider_command(Arc::clone(&shared), "provider") + } else { + provider_command_with_primary_failure( + Arc::clone(&shared), + PrimaryFailure::Error(Arc::clone(&identity)), + ) + }; + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, false); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::ChildDrop])); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + fn child_destructor_panics_do_not_replace_post_spawn_or_commit_panics() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_CHILD_PANIC"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for phase in ["post", "commit"] { + let child_value = format!("{module}:{phase}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::child_destructor_panics_do_not_replace_post_spawn_or_commit_panics" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "isolated child cleanup exceeded its deadline"); + assert!( + output.status.success(), + "{phase} panic was not preserved across child cleanup:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let fail_commit = selected + .as_deref() + .expect("the subprocess selected a phase") + .ends_with(":commit"); + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload { + drops: Arc::clone(&secondary_drops), + }); + let identity = Arc::new(()); + let mut command = if fail_commit { + shared.set_commit_primary_failure(PrimaryFailure::Panic(Arc::clone(&identity))); + provider_command(Arc::clone(&shared), "provider") + } else { + provider_command_with_primary_failure( + Arc::clone(&shared), + PrimaryFailure::Panic(Arc::clone(&identity)), + ) + }; + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, true); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::ChildDrop])); + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + #[test] fn commit_failure_rolls_back_and_preserves_its_error() { let runtime = runtime(); @@ -1403,6 +1829,81 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[cfg(windows)] + #[test] + fn prepared_states_are_disposed_independently_after_the_primary_is_owned() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_PREPARED_PANIC"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for failure in ["error", "panic"] { + let child_value = format!("{module}:{failure}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::prepared_states_are_disposed_independently_after_the_primary_is_owned" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "prepared-state cleanup exceeded its deadline"); + assert!( + output.status.success(), + "prepared-state cleanup did not preserve the {failure}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let first_drops = Arc::new(AtomicUsize::new(0)); + let second_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let failure = if selected + .as_deref() + .expect("the subprocess selected a failure") + .ends_with(":panic") + { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let was_panic = matches!(&failure, PrimaryFailure::Panic(_)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(FirstPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload { + drops: Arc::clone(&first_drops), + })), + }) + .wrap(SecondPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload { + drops: Arc::clone(&second_drops), + })), + }) + .wrap(FailPrepare(Mutex::new(Some(failure)))); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(first_drops.load(Ordering::SeqCst), 0); + assert_eq!(second_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[ + Event::Rollback, + Event::PreparedDrop("first"), + Event::PreparedDrop("second"), + ])); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + #[cfg(windows)] #[test] fn precommit_child_hook_failures_roll_back_preserve_failure_and_allow_reuse() { @@ -1723,6 +2224,24 @@ macro_rules! tokio_wait_for_child { }; } +#[cfg(feature = "std")] +macro_rules! std_kill_child { + ($runtime:expr, $child:expr) => {{ + let _ = &$runtime; + $child.kill() + }}; +} + +#[cfg(feature = "tokio1")] +macro_rules! tokio_kill_child { + ($runtime:expr, $child:expr) => { + $runtime + .as_ref() + .expect("the Tokio frontend has a runtime") + .block_on(async { Box::into_pin($child.kill()).await }) + }; +} + macro_rules! provider_capability_tests { ( $module:ident, @@ -1735,6 +2254,7 @@ macro_rules! provider_capability_tests { $native_command:path, $native_child:path, $wait_for_child:ident, + $kill_child:ident, $runtime:expr ) => { mod $module { @@ -1954,6 +2474,27 @@ macro_rules! provider_capability_tests { assert_eq!(first, second); } + #[test] + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_sidecar_delegates_direct_kill_and_repeated_waits() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = command_with_completion(Arc::clone(&drops), false); + + let mut child = command.spawn().expect("spawn live provider child"); + $kill_child!(runtime, child).expect("kill and reap live provider child"); + let first = $wait_for_child!(runtime, child).expect("repeat killed child wait"); + let second = $wait_for_child!(runtime, child).expect("second killed child wait"); + assert_eq!(first, second); + assert_eq!( + child.try_wait().expect("repeat killed child try_wait"), + Some(first) + ); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + #[cfg(windows)] #[test] #[cfg_attr(miri, ignore = "requires native child processes")] @@ -2591,6 +3132,7 @@ provider_capability_tests!( std::process::Command, std::process::Child, std_wait_for_child, + std_kill_child, None ); @@ -2606,6 +3148,7 @@ provider_capability_tests!( tokio::process::Command, tokio::process::Child, tokio_wait_for_child, + tokio_kill_child, Some( tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/tests/spawn_with_child.rs b/tests/spawn_with_child.rs index 4b0cbc6..0e3f5b8 100644 --- a/tests/spawn_with_child.rs +++ b/tests/spawn_with_child.rs @@ -13,8 +13,12 @@ macro_rules! spawn_with_child_tests { use std::{ any::TypeId, io, - panic::{AssertUnwindSafe, catch_unwind}, - sync::{Arc, Mutex}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + process::{Output, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, thread::sleep, time::{Duration, Instant}, }; @@ -180,6 +184,107 @@ macro_rules! spawn_with_child_tests { } } + #[derive(Debug)] + struct SecondaryPayload(Arc); + + impl Drop for SecondaryPayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a secondary wrapper payload was dropped"); + } + } + + #[derive(Debug)] + struct PanickingLayer { + inner: Box, + payload: Option, + } + + impl ChildWrapper for PanickingLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_ref() + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner.as_mut() + } + + fn into_inner(mut self: Box) -> Box { + self.payload.take(); + std::mem::replace(&mut self.inner, Box::new(CustomLeaf)) + } + } + + impl Drop for PanickingLayer { + fn drop(&mut self) { + if let Some(payload) = self.payload.take() { + panic_any(payload); + } + } + } + + #[derive(Debug)] + struct AddPanickingLayer(Mutex>); + + impl CommandWrapper for AddPanickingLayer { + fn wrap_child( + &mut self, + child: Box, + _command: &CommandWrap, + ) -> io::Result> { + match self.0.lock().unwrap().take() { + Some(payload) => Ok(Box::new(PanickingLayer { + inner: child, + payload: Some(payload), + })), + None => Ok(child), + } + } + } + + #[derive(Debug)] + struct WrapIdentityError(Arc<()>); + + impl std::fmt::Display for WrapIdentityError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("primary wrap error") + } + } + + impl std::error::Error for WrapIdentityError {} + + #[derive(Debug)] + struct WrapIdentityPanic(Arc<()>); + + #[derive(Debug)] + enum WrapFailure { + Error(Arc<()>), + Panic(Arc<()>), + } + + #[derive(Debug)] + struct FailWrap(Mutex>); + + impl CommandWrapper for FailWrap { + fn wrap_child( + &mut self, + child: Box, + _command: &CommandWrap, + ) -> io::Result> { + match self.0.lock().unwrap().take() { + Some(WrapFailure::Error(identity)) => { + let _ = child; + Err(io::Error::other(WrapIdentityError(identity))) + } + Some(WrapFailure::Panic(identity)) => { + let _ = child; + panic_any(WrapIdentityPanic(identity)); + } + None => Ok(child), + } + } + } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Phase { Pre, @@ -258,6 +363,59 @@ macro_rules! spawn_with_child_tests { }); } + fn bounded_test_process( + test_name: &str, + environment: (&str, &str), + timeout: Duration, + ) -> (Output, bool) { + struct ChildGuard(Option); + + impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture"]) + .env(environment.0, environment.1) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start isolated wrapping regression"); + let mut child = ChildGuard(Some(child)); + let deadline = Instant::now() + timeout; + loop { + let status = child + .0 + .as_mut() + .expect("the subprocess remains guarded while polling") + .try_wait() + .expect("poll isolated wrapping regression"); + if status.is_some() { + let output = child + .0 + .take() + .expect("take completed subprocess") + .wait_with_output() + .expect("collect isolated wrapping output"); + return (output, false); + } + if Instant::now() >= deadline { + let mut expired = child.0.take().expect("take expired subprocess"); + let _ = expired.kill(); + let output = expired + .wait_with_output() + .expect("reap expired wrapping subprocess"); + return (output, true); + } + sleep(Duration::from_millis(5)); + } + } + fn wait_for_exit(mut child: Box) { let deadline = Instant::now() + EXIT_TIMEOUT; loop { @@ -338,6 +496,158 @@ macro_rules! spawn_with_child_tests { wait_for_exit(child); } + #[derive(Clone, Copy, Debug)] + enum Transport { + Native, + ExplicitNative, + Boxed, + } + + fn spawn_transport( + command: &mut CommandWrap, + transport: Transport, + ) -> io::Result> { + match transport { + Transport::Native => command.spawn(), + Transport::ExplicitNative => command.spawn_with(|command| command.spawn()), + Transport::Boxed => command + .spawn_with_child(|_| Ok(Box::new(CustomLeaf) as Box)), + } + } + + fn assert_wrap_error( + outcome: std::thread::Result>>, + identity: &Arc<()>, + ) { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + panic!("child cleanup replaced the primary wrapping error"); + } + }; + let error = result.expect_err("the wrapping hook must fail"); + let identity_error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("the exact wrapping error must survive cleanup"); + assert!(Arc::ptr_eq(&identity_error.0, identity)); + } + + fn assert_wrap_panic( + outcome: std::thread::Result>>, + identity: &Arc<()>, + ) { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => panic!("the wrapping hook must panic"), + }; + let payload = match payload.downcast::() { + Ok(payload) => payload, + Err(secondary) => { + std::mem::forget(secondary); + panic!("child cleanup replaced the primary wrapping panic"); + } + }; + assert!(Arc::ptr_eq(&payload.0, identity)); + } + + #[test] + fn consuming_wrap_errors_preserve_identity_and_cleanup_for_every_transport() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for transport in [ + Transport::Native, + Transport::ExplicitNative, + Transport::Boxed, + ] { + let secondary_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let mut command = command(); + let payload = SecondaryPayload(Arc::clone(&secondary_drops)); + let failure = WrapFailure::Error(Arc::clone(&identity)); + command + .wrap(AddPanickingLayer(Mutex::new(Some(payload)))) + .wrap(FailWrap(Mutex::new(Some(failure)))); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + spawn_transport(&mut command, transport) + })); + assert_wrap_error(outcome, &identity); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + + let child = spawn_transport(&mut command, transport) + .expect("the command and wrapping hooks remain reusable"); + if !matches!(transport, Transport::Boxed) { + wait_for_exit(child); + } + } + } + + #[test] + fn consuming_wrap_panics_preserve_identity_and_cleanup_for_every_transport() { + const CHILD_ENV: &str = "PROCESS_WRAP_CONSUMING_WRAP_PANIC"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected + .as_deref() + .is_none_or(|value| !value.starts_with(module)) + { + for transport in ["native", "explicit-native", "boxed"] { + let child_value = format!("{module}:{transport}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::consuming_wrap_panics_preserve_identity_and_cleanup_for_every_transport" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!( + !timed_out, + "isolated wrapping cleanup exceeded its deadline" + ); + assert!( + output.status.success(), + "{transport} wrapping panic was not preserved:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let selected = selected.expect("the subprocess selected a transport"); + let transport = if selected.ends_with(":native") { + Transport::Native + } else if selected.ends_with(":explicit-native") { + Transport::ExplicitNative + } else { + Transport::Boxed + }; + let secondary_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let mut command = command(); + let payload = SecondaryPayload(Arc::clone(&secondary_drops)); + let failure = WrapFailure::Panic(Arc::clone(&identity)); + command + .wrap(AddPanickingLayer(Mutex::new(Some(payload)))) + .wrap(FailWrap(Mutex::new(Some(failure)))); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + spawn_transport(&mut command, transport) + })); + assert_wrap_panic(outcome, &identity); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + let child = spawn_transport(&mut command, transport) + .expect("the command and wrapping hooks remain reusable"); + if !matches!(transport, Transport::Boxed) { + wait_for_exit(child); + } + } + #[test] fn ordinary_spawn_keeps_portable_state_for_post_spawn_hooks() { let runtime = runtime(); From 0056a0085f1a445b7ca8303495dcb153207e9ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 09:42:46 +1300 Subject: [PATCH 058/114] fix(provider): keep sidecar formatting residue-blind Co-Authored-By: Claude Code --- src/generic_wrap.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index e7d3cce..56256ca 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -137,12 +137,17 @@ macro_rules! Wrap { } } - #[derive(Debug)] struct CommittedProviderChild { child: Box, residue: ::std::sync::Arc<::std::sync::Mutex>>, } + impl ::std::fmt::Debug for CommittedProviderChild { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.child.fmt(formatter) + } + } + impl $childer for CommittedProviderChild { fn inner(&self) -> &dyn $childer { self.child.as_ref() From 4312881cd793f7c3b1a516a8bdcde2387a5be861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 10:33:33 +1300 Subject: [PATCH 059/114] fix(lifecycle): retain child cleanup custody Co-Authored-By: Claude Code --- src/generic_wrap.rs | 567 +++++++++++++++--- src/std.rs | 7 +- src/std/job_object.rs | 334 ++++++++--- src/std/process_group.rs | 63 +- src/std/process_session.rs | 12 +- src/tokio.rs | 7 +- src/tokio/job_object.rs | 350 ++++++++--- src/tokio/process_group.rs | 66 +- src/tokio/process_session.rs | 12 +- src/windows.rs | 337 +++++++++++ tests/child_wrapper_contract.rs | 66 +- tests/spawn_provider.rs | 395 ++++++++++-- tests/spawn_with_child.rs | 223 +++++-- .../std_windows/creation_flags_job_object.rs | 72 +-- tests/std_windows/process_handle.rs | 157 ++++- tests/tokio_pty_provider.rs | 70 ++- tests/tokio_pty_windows.rs | 65 +- .../creation_flags_job_object.rs | 68 ++- tests/tokio_windows/process_handle.rs | 157 ++++- tests/wrapper_hooks.rs | 28 +- 20 files changed, 2442 insertions(+), 614 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 56256ca..ffbdf9f 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -94,6 +94,210 @@ macro_rules! Wrap { } } + #[cfg(windows)] + #[doc(hidden)] + #[derive(Clone)] + pub struct PreparedChild { + value: ::std::sync::Arc< + ::std::sync::Mutex>>, + >, + } + + #[cfg(windows)] + impl ::std::fmt::Debug for PreparedChild { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + formatter.debug_struct("PreparedChild").finish_non_exhaustive() + } + } + + #[cfg(windows)] + impl PreparedChild { + fn new(value: Box) -> Self { + Self { + value: ::std::sync::Arc::new(::std::sync::Mutex::new(Some(value))), + } + } + + /// Inspect prepared state without taking ownership from process-wrap. + #[doc(hidden)] + pub fn with(&self, inspect: impl FnOnce(&T) -> R) -> Option { + let value = self + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + value.as_deref()?.downcast_ref::().map(inspect) + } + + /// Mutate prepared state without taking ownership from process-wrap. + #[doc(hidden)] + pub fn with_mut( + &self, + mutate: impl FnOnce(&mut T) -> R, + ) -> Option { + let mut value = self + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + value.as_deref_mut()?.downcast_mut::().map(mutate) + } + + fn take(&self) -> Option> { + self.value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + } + + fn has_layer_owner(&self) -> bool { + ::std::sync::Arc::strong_count(&self.value) > 1 + } + } + + /// Empty ownership slots for a child layer returned by a wrapping hook. + /// + /// A layer keeps these slots empty while its hook runs. Process-wrap fills them only after the + /// hook returns successfully, so failure cleanup remains outside caller-defined callbacks. + pub struct ChildWrapperSlots<'a> { + child: &'a mut Option>, + #[cfg(windows)] + prepared: Option<&'a mut Option>, + } + + impl<'a> ChildWrapperSlots<'a> { + /// Describe a detached layer's empty child slot. + pub fn new(child: &'a mut Option>) -> Self { + Self { + child, + #[cfg(windows)] + prepared: None, + } + } + + /// Describe the empty prepared-state slot paired with this layer. + #[cfg(windows)] + #[doc(hidden)] + pub fn with_prepared(mut self, prepared: &'a mut Option) -> Self { + self.prepared = Some(prepared); + self + } + } + + /// A detached child layer whose ownership slots process-wrap fills after its hook returns. + /// + /// Implement this trait for a layer returned through [`PendingChildWrapper::new`]. The layer's + /// child slot must be empty until process-wrap installs the current child. + pub trait ChildWrapperLayer: $childer { + /// Expose this detached layer's empty ownership slots. + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_>; + } + + /// A child layer awaiting failure-atomic installation by process-wrap. + /// + /// Wrapping hooks return this value instead of taking ownership of the current child. If a hook + /// succeeds, process-wrap installs that child into the layer's declared empty slot. + pub struct PendingChildWrapper { + layer: Box, + install: fn( + &mut dyn $childer, + &mut Option>, + #[cfg(windows)] Option<&PreparedChild>, + ) -> ::std::io::Result<()>, + } + + impl ::std::fmt::Debug for PendingChildWrapper { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.layer.fmt(formatter) + } + } + + impl PendingChildWrapper { + /// Return a detached child layer for failure-atomic installation. + /// + /// `layer.child_wrapper_slots()` must expose an empty child slot. Process-wrap validates the + /// slot and fills it after the wrapping callback has returned. + pub fn new(layer: L) -> Self + where + L: ChildWrapperLayer + 'static, + { + Self { + layer: Box::new(layer), + install: Self::install_layer::, + } + } + + fn install_layer( + layer: &mut dyn $childer, + child: &mut Option>, + #[cfg(windows)] prepared: Option<&PreparedChild>, + ) -> ::std::io::Result<()> + where + L: ChildWrapperLayer + 'static, + { + let layer = (layer as &mut dyn ::std::any::Any) + .downcast_mut::() + .expect("a pending child layer retains its concrete type"); + let slots = layer.child_wrapper_slots(); + if slots.child.is_some() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a pending child layer must have an empty child slot", + )); + } + #[cfg(windows)] + match (prepared, slots.prepared) { + (Some(prepared), Some(slot)) if slot.is_none() => { + *slot = Some(prepared.clone()); + } + (Some(_), _) => { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state requires an empty matching layer slot", + )); + } + (None, Some(slot)) if slot.is_some() => { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a pending prepared-state slot must be empty", + )); + } + (None, _) => {} + } + *slots.child = Some(child.take().expect("the lifecycle retains child custody")); + Ok(()) + } + + fn install( + &mut self, + child: &mut Option>, + #[cfg(windows)] prepared: Option<&PreparedChild>, + ) -> ::std::io::Result<()> { + (self.install)( + self.layer.as_mut(), + child, + #[cfg(windows)] + prepared, + ) + } + + fn into_child(self) -> Box { + self.layer + } + } + + enum SpawnFailure { + Error(::std::io::Error), + Panic(Box), + } + + impl SpawnFailure { + fn finish(self) -> ::std::io::Result { + match self { + Self::Error(error) => Err(error), + Self::Panic(payload) => ::std::panic::resume_unwind(payload), + } + } + } + #[derive(Debug)] enum ProviderTransactionState { Armed(Box), @@ -126,13 +330,19 @@ macro_rules! Wrap { } } - fn transfer(&mut self, child: Box) -> Box { + fn transfer( + &mut self, + child: Box, + #[cfg(windows)] prepared: Vec>, + ) -> Box { let Self::Committed(transaction) = ::std::mem::replace(self, Self::Transferred) else { unreachable!("only committed transaction residue can transfer to a child"); }; Box::new(CommittedProviderChild { child, residue: ::std::sync::Arc::new(::std::sync::Mutex::new(transaction)), + #[cfg(windows)] + prepared, }) } } @@ -140,6 +350,8 @@ macro_rules! Wrap { struct CommittedProviderChild { child: Box, residue: ::std::sync::Arc<::std::sync::Mutex>>, + #[cfg(windows)] + prepared: Vec>, } impl ::std::fmt::Debug for CommittedProviderChild { @@ -158,7 +370,12 @@ macro_rules! Wrap { } fn into_inner(self: Box) -> Box { - let Self { child, residue: _ } = *self; + let Self { + child, + residue: _, + #[cfg(windows)] + prepared: _, + } = *self; child } @@ -177,6 +394,8 @@ macro_rules! Wrap { Box::new(Self { child, residue: ::std::sync::Arc::clone(&self.residue), + #[cfg(windows)] + prepared: self.prepared.clone(), }) as Box }) } @@ -393,17 +612,25 @@ macro_rules! Wrap { Ok(()) } + fn capture_io( + invoke: impl FnOnce() -> ::std::io::Result, + ) -> ::std::result::Result { + match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(invoke)) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(SpawnFailure::Error(error)), + Err(payload) => Err(SpawnFailure::Panic(payload)), + } + } + #[cfg(windows)] #[inline] fn run_prepare_child( &mut self, attempt: &mut SpawnAttempt, child: &mut dyn $childer, - ) -> ::std::io::Result< - Vec>>, - > { + prepared: &mut Vec>, + ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); - let mut prepared = Vec::with_capacity(len); for index in 0..len { #[cfg(feature = "tracing")] { @@ -415,12 +642,15 @@ macro_rules! Wrap { .0; ::tracing::debug!(?id, "prepare_child"); } - prepared.push(self.with_wrapper_at(index, |wrapper, command| { - wrapper.prepare_child(attempt, child, command) - })?); + let value = Self::capture_io(|| { + self.with_wrapper_at(index, |wrapper, command| { + wrapper.prepare_child(attempt, child, command) + }) + })?; + prepared.push(value.map(PreparedChild::new)); } - Ok(prepared) + Ok(()) } #[inline] @@ -428,7 +658,7 @@ macro_rules! Wrap { &mut self, attempt: &mut SpawnAttempt, child: &mut dyn $childer, - ) -> ::std::io::Result<()> { + ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { #[cfg(feature = "tracing")] @@ -441,8 +671,10 @@ macro_rules! Wrap { .0; ::tracing::debug!(?id, "post_spawn"); } - self.with_wrapper_at(index, |wrapper, command| { - wrapper.post_spawn(attempt, child, command) + Self::capture_io(|| { + self.with_wrapper_at(index, |wrapper, command| { + wrapper.post_spawn(attempt, child, command) + }) })?; } @@ -452,11 +684,10 @@ macro_rules! Wrap { #[inline] fn run_wrap_child( &mut self, - mut child: Box, - #[cfg(windows)] mut prepared: Vec< - Option>, - >, - ) -> ::std::io::Result> { + child: &mut Option>, + pending: &mut Option, + #[cfg(windows)] prepared: &[Option], + ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { #[cfg(feature = "tracing")] @@ -469,36 +700,76 @@ macro_rules! Wrap { .0; ::tracing::debug!(?id, "wrap_child"); } - child = self.with_wrapper_at(index, |wrapper, command| { - #[cfg(windows)] - { - wrapper.wrap_prepared_child( + let layer = Self::capture_io(|| { + self.with_wrapper_at(index, |wrapper, command| { + let child = child + .as_deref_mut() + .expect("process-wrap retains child custody between wrapping hooks"); + #[cfg(windows)] + { + wrapper.wrap_prepared_child(child, prepared[index].as_ref(), command) + } + #[cfg(not(windows))] + { + wrapper.wrap_child(child, command) + } + }) + })?; + *pending = layer; + + if let Some(layer) = pending.as_mut() { + Self::capture_io(|| { + layer.install( child, - prepared[index].take(), - command, + #[cfg(windows)] + prepared[index].as_ref(), ) + })?; + let layer = pending + .take() + .expect("the installed child layer remains in custody"); + debug_assert!(child.is_none()); + *child = Some(layer.into_child()); + } else { + #[cfg(windows)] + if prepared[index].is_some() { + return Err(SpawnFailure::Error(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state requires a matching child layer", + ))); } - #[cfg(not(windows))] - { - wrapper.wrap_child(child, command) - } - })?; + } } - Ok(child) + Ok(()) } fn finish_spawn( &mut self, attempt: &mut SpawnAttempt, - mut child: Box, + child: Box, ) -> ::std::io::Result> { + let mut child = Some(child); + let mut pending = None; #[cfg(windows)] - let mut cleanup = if attempt.starts_suspended() { - let handle = match child.as_ref().try_process_handle() { + let mut prepared = Vec::with_capacity(self.wrapper_registry().wrappers.len()); + #[cfg(windows)] + let mut cleanup = match Self::capture_io(|| { + if !attempt.starts_suspended() { + return Ok(None); + } + let handle = match child + .as_ref() + .expect("the native lifecycle retains child custody") + .as_ref() + .try_process_handle() + { Some(handle) => handle, None => { - let _ = child.start_kill(); + let _ = child + .as_deref_mut() + .expect("the native lifecycle retains child custody") + .start_kill(); return Err(::std::io::Error::new( ::std::io::ErrorKind::Unsupported, "child wrapper does not expose a Windows process handle", @@ -506,39 +777,91 @@ macro_rules! Wrap { } }; match crate::command::WindowsSpawnCleanup::new(handle) { - Ok(cleanup) => Some(cleanup), + Ok(cleanup) => Ok(Some(cleanup)), Err(error) => { let _ = crate::command::terminate_process_and_wait(handle); - return Err(error); + Err(error) } } - } else { - None + }) { + Ok(cleanup) => cleanup, + Err(failure) => { + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); + } }; - let result = (|| { + let result: ::std::result::Result<(), SpawnFailure> = (|| { #[cfg(windows)] - let prepared = self.run_prepare_child(attempt, child.as_mut())?; - self.run_post_spawn(attempt, child.as_mut())?; + self.run_prepare_child( + attempt, + child + .as_deref_mut() + .expect("the native lifecycle retains child custody"), + &mut prepared, + )?; + self.run_post_spawn( + attempt, + child + .as_deref_mut() + .expect("the native lifecycle retains child custody"), + )?; + self.run_wrap_child( + &mut child, + &mut pending, + #[cfg(windows)] + &prepared, + )?; #[cfg(windows)] { - self.run_wrap_child(child, prepared) - } - #[cfg(not(windows))] - { - self.run_wrap_child(child) + let final_owner = Self::capture_io(|| { + child + .as_deref_mut() + .expect("the native lifecycle retains child custody") + .finalize_spawn_before_commit() + })?; + Self::capture_io(|| { + child + .as_deref_mut() + .expect("the native lifecycle retains child custody") + .finalize_spawn_final_owner(final_owner) + })?; + if prepared + .iter() + .flatten() + .any(|prepared| !prepared.has_layer_owner()) + { + return Err(SpawnFailure::Error(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a prepared child layer released its state before transfer", + ))); + } } + Ok(()) })(); - #[cfg(windows)] - let result = result.and_then(|mut child| { - let final_owner = child.finalize_spawn_before_commit()?; - child.finalize_spawn_final_owner(final_owner)?; - if let Some(cleanup) = cleanup.as_mut() { - cleanup.disarm(); + + match result { + Ok(()) => { + #[cfg(windows)] + if let Some(cleanup) = cleanup.as_mut() { + cleanup.disarm(); + } + Ok(child + .take() + .expect("the completed native lifecycle retains its child")) + } + Err(failure) => { + #[cfg(windows)] + drop(cleanup.take()); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + #[cfg(windows)] + Self::cleanup_prepared(&mut prepared); + failure.finish::>() } - Ok(child) - }); - result + } } fn quarantine_cleanup_panic(result: ::std::thread::Result<()>) { @@ -547,13 +870,38 @@ macro_rules! Wrap { } } - fn dispose_transaction(transaction: Box) { + fn dispose_value(value: T) { let disposal = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - drop(transaction); + drop(value); })); Self::quarantine_cleanup_panic(disposal); } + fn cleanup_pending_child(pending: &mut Option) { + if let Some(pending) = pending.take() { + Self::dispose_value(pending); + } + } + + fn cleanup_child(child: &mut Option>) { + if let Some(child) = child.take() { + Self::dispose_value(child); + } + } + + #[cfg(windows)] + fn cleanup_prepared(prepared: &mut [Option]) { + for prepared in prepared.iter_mut().filter_map(Option::take) { + if let Some(value) = prepared.take() { + Self::dispose_value(value); + } + } + } + + fn dispose_transaction(transaction: Box) { + Self::dispose_value(transaction); + } + fn rollback_transaction(mut transaction: Box) { let rollback = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { let _ = transaction.rollback(); @@ -578,33 +926,69 @@ macro_rules! Wrap { attempt: &mut SpawnAttempt, product: ProviderProduct, ) -> ::std::io::Result> { - let (mut child, transaction) = product.into_parts(); + let (child, transaction) = product.into_parts(); + let mut child = Some(child); + let mut pending = None; let mut transaction = ProviderTransactionState::new(transaction); - let result = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - #[cfg(windows)] - let prepared = self.run_prepare_child(attempt, child.as_mut())?; - self.run_post_spawn(attempt, child.as_mut())?; + #[cfg(windows)] + let mut prepared = Vec::with_capacity(self.wrapper_registry().wrappers.len()); + let result: ::std::result::Result<(), SpawnFailure> = (|| { #[cfg(windows)] - let mut child = self.run_wrap_child(child, prepared)?; - #[cfg(not(windows))] - let child = self.run_wrap_child(child)?; + self.run_prepare_child( + attempt, + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody"), + &mut prepared, + )?; + self.run_post_spawn( + attempt, + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody"), + )?; + self.run_wrap_child( + &mut child, + &mut pending, + #[cfg(windows)] + &prepared, + )?; #[cfg(windows)] - let final_owner = child.finalize_spawn_before_commit()?; - transaction.commit()?; + let final_owner = Self::capture_io(|| { + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody") + .finalize_spawn_before_commit() + })?; + Self::capture_io(|| transaction.commit())?; #[cfg(windows)] - child.finalize_spawn_final_owner(final_owner)?; - Ok(transaction.transfer(child)) - })); + Self::capture_io(|| { + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody") + .finalize_spawn_final_owner(final_owner) + })?; + Ok(()) + })(); match result { - Ok(Ok(child)) => Ok(child), - Ok(Err(error)) => { - Self::cleanup_failed_transaction(&mut transaction); - Err(error) + Ok(()) => { + let child = child + .take() + .expect("the completed provider lifecycle retains its child"); + Ok(transaction.transfer( + child, + #[cfg(windows)] + prepared, + )) } - Err(payload) => { + Err(failure) => { Self::cleanup_failed_transaction(&mut transaction); - ::std::panic::resume_unwind(payload) + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + #[cfg(windows)] + Self::cleanup_prepared(&mut prepared); + failure.finish::>() } } } @@ -851,32 +1235,35 @@ macro_rules! Wrap { Ok(()) } - /// Called to wrap a child into this command wrapper's child wrapper. + /// Called to describe a child layer after a transport creates the child. /// - /// If the wrapper needs to override methods on the child, it should create an instance of its - /// own type implementing `ChildWrapper` and return it here. Wrappers run in registration order - /// and stop at the first error or unwinding panic, so `.wrap(Foo).wrap(Bar)` produces an outer - /// `Bar(Foo(child))` layer. On the provider path, an error or unwinding panic triggers - /// best-effort transaction rollback. + /// Wrappers run in registration order. The current child remains borrowed from process-wrap while + /// this callback runs. To add a layer, return a [`PendingChildWrapper`] containing a + /// [`ChildWrapperLayer`] whose ownership slots are empty; process-wrap installs the current child + /// only after the callback has returned successfully. Returning `None` leaves the child unchanged. + /// This custody protocol keeps child destruction outside the callback boundary when a later hook + /// fails. `.wrap(Foo).wrap(Bar)` still produces an outer `Bar(Foo(child))` layer. /// - /// Default implementation: no-op (returns the child unchanged). + /// On the provider path, an error or unwinding panic triggers best-effort transaction rollback. + /// + /// Default implementation: no-op (returns no layer). fn wrap_child( &mut self, - child: Box, + _child: &mut dyn $childer, _command: &Command, - ) -> ::std::io::Result> { - Ok(child) + ) -> ::std::io::Result> { + Ok(None) } - /// Install a child wrapper using state returned by `prepare_child`. + /// Describe a child layer using state returned by `prepare_child`. #[doc(hidden)] #[cfg(windows)] fn wrap_prepared_child( &mut self, - child: Box, - prepared: Option>, + child: &mut dyn $childer, + prepared: Option<&PreparedChild>, command: &Command, - ) -> ::std::io::Result> { + ) -> ::std::io::Result> { debug_assert!( prepared.is_none(), "the default child preparation does not produce wrapper state" diff --git a/src/std.rs b/src/std.rs index 4dbfc1f..02408b2 100644 --- a/src/std.rs +++ b/src/std.rs @@ -20,10 +20,13 @@ pub use crate::SpawnTransaction; #[cfg_attr(docsrs, doc(cfg(windows)))] #[doc(inline)] pub use crate::WindowsSpawnPolicy; +#[cfg(windows)] +#[doc(hidden)] +pub use core::PreparedChild; #[doc(inline)] pub use core::{ - ChildWrapper, Command, CommandWrap, CommandWrapper, ProviderProduct, SpawnAttempt, - SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrap, CommandWrapper, + PendingChildWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, }; #[cfg(all(windows, feature = "creation-flags"))] #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] diff --git a/src/std/job_object.rs b/src/std/job_object.rs index b0ff356..75d0bcb 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -24,7 +24,10 @@ use crate::{ #[cfg(feature = "creation-flags")] use super::CreationFlags; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, PreparedChild, SpawnAttempt, +}; /// Wrapper which creates a job object context for a `Command`. /// @@ -62,7 +65,7 @@ fn terminate_child(child: &mut dyn ChildWrapper) { #[derive(Debug)] struct PreparedJobObject { - job_port: JobPort, + job_port: Option, } impl JobObject { @@ -111,7 +114,9 @@ impl JobObject { } } - Ok(PreparedJobObject { job_port }) + Ok(PreparedJobObject { + job_port: Some(job_port), + }) } } @@ -134,62 +139,82 @@ impl CommandWrapper for JobObject { fn wrap_prepared_child( &mut self, - inner: Box, - prepared: Option>, + _inner: &mut dyn ChildWrapper, + prepared: Option<&PreparedChild>, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); - let prepared = match prepared.downcast::() { - Ok(prepared) => *prepared, - Err(_) => unreachable!("JobObject prepared state retains its concrete type"), - }; - Ok(Box::new(JobObjectChild::new( - inner, - prepared.job_port, + assert!( + prepared + .with::(|prepared| prepared.job_port.is_some()) + .expect("JobObject prepared state retains its concrete type"), + "JobObject prepared state retains its job handles" + ); + Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( false, - ))) - } - - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self, inner)))] - fn wrap_child( - &mut self, - mut inner: Box, - core: &CommandWrap, - ) -> Result> { - let prepared = self.prepare_job(inner.as_mut(), core)?; - let mut child = JobObjectChild::new(inner, prepared.job_port, false); - child.disarm_job_object_layer()?; - Ok(Box::new(child)) + )))) } } /// Wrapper for `Child` which waits on all processes within the job. #[derive(Debug)] pub struct JobObjectChild { - inner: Box, + inner: Option>, + prepared: Option, exit_status: ChildExitStatus, job_drained: bool, - job_port: JobPort, final_kill_on_drop: bool, spawn_finalized: bool, } impl JobObjectChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(job_port)))] - pub(crate) fn new( - inner: Box, - job_port: JobPort, - final_kill_on_drop: bool, - ) -> Self { + pub(crate) fn detached(final_kill_on_drop: bool) -> Self { Self { - inner, + inner: None, + prepared: None, exit_status: ChildExitStatus::Running, job_drained: false, - job_port, final_kill_on_drop, spawn_finalized: false, } } + + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed JobObject layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed JobObject layer owns its child") + } + + fn prepared(&self) -> &PreparedChild { + self.prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state") + } + + fn with_job_port(&self, inspect: impl FnOnce(&JobPort) -> R) -> R { + self.prepared() + .with::(|prepared| { + inspect( + prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"), + ) + }) + .expect("JobObject prepared state retains its concrete type") + } +} + +impl ChildWrapperLayer for JobObjectChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner).with_prepared(&mut self.prepared) + } } fn wait_for_exit_and_job_drain_with( @@ -228,63 +253,83 @@ fn wait_for_exit_and_job_drain_with( impl ChildWrapper for JobObjectChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { - let Self { - inner, - job_port, - final_kill_on_drop, - spawn_finalized, - .. - } = *self; - if spawn_finalized && final_kill_on_drop { - // manually drop the completion port - let its = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `its` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(HANDLE(its.completion_port.as_raw_handle())) }.ok(); - // we leave the job handle unclosed, otherwise the Child is useless - // (as closing it may terminate the job) + fn into_inner(mut self: Box) -> Box { + let inner = self + .inner + .take() + .expect("an installed JobObject layer owns its child"); + if self.spawn_finalized && self.final_kill_on_drop { + self.prepared() + .with_mut::(|prepared| { + let job_port = prepared + .job_port + .take() + .expect("the installed JobObject layer retains its job handles"); + // Manually close the completion port while retaining the job handle. Closing a + // kill-on-close job here would make the extracted child unusable. + let job_port = std::mem::ManuallyDrop::new(job_port); + // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. + unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); + }) + .expect("JobObject prepared state retains its concrete type"); } - // Before spawn finalization, dropping the still-armed job instead guarantees that removing this - // layer cannot let descendants escape a later lifecycle failure. + // Before spawn finalization, dropping the still-armed prepared job instead guarantees that + // removing this layer cannot let descendants escape a later lifecycle failure. inner } fn process_handle(&self) -> Option> { - self.inner.try_process_handle() + self.inner_ref().try_process_handle() } fn owns_job_object_cleanup_layer(&self) -> bool { true } fn disarm_job_object_layer(&mut self) -> Result<()> { - set_job_kill_on_drop(self.job_port.job, self.final_kill_on_drop)?; + #[cfg(test)] + crate::windows::test_support::fail_final_owner()?; + let final_kill_on_drop = self.final_kill_on_drop; + self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; Ok(()) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - terminate_job(self.job_port.job, 1) + self.with_job_port(|job_port| terminate_job(job_port.job, 1)) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Result { + let prepared = self.prepared().clone(); let Self { inner, exit_status, job_drained, - job_port, .. } = self; + let inner = inner + .as_deref_mut() + .expect("an installed JobObject layer owns its child"); wait_for_exit_and_job_drain_with( exit_status, job_drained, || inner.wait(), - |timeout| poll_job_drain(job_port.job, job_port.completion_port.as_handle(), timeout), + |timeout| { + prepared + .with::(|prepared| { + let job_port = prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"); + poll_job_drain(job_port.job, job_port.completion_port.as_handle(), timeout) + }) + .expect("JobObject prepared state retains its concrete type") + }, Instant::now, std::thread::sleep, |_| {}, @@ -294,19 +339,22 @@ impl ChildWrapper for JobObjectChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { if matches!(self.exit_status, ChildExitStatus::Running) { - let Some(status) = self.inner.try_wait()? else { + let Some(status) = self.inner_mut_ref().try_wait()? else { return Ok(None); }; self.exit_status = ChildExitStatus::Exited(status); } if !self.job_drained - && poll_job_drain( - self.job_port.job, - self.job_port.completion_port.as_handle(), - Duration::ZERO, - )? - .is_break() + && self + .with_job_port(|job_port| { + poll_job_drain( + job_port.job, + job_port.completion_port.as_handle(), + Duration::ZERO, + ) + })? + .is_break() { self.job_drained = true; } @@ -322,13 +370,161 @@ impl ChildWrapper for JobObjectChild { mod tests { use std::{ cell::{Cell, RefCell}, - os::windows::process::ExitStatusExt, - sync::mpsc, + os::windows::{io::AsHandle, process::CommandExt, process::ExitStatusExt}, + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Arc, atomic::Ordering, mpsc}, thread, }; + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + use crate::std::{ProviderProduct, SpawnProvider}; + use crate::windows::test_support::{ + LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, + TreePaths, arm_owner_failure, assert_tree_terminated, clear_owner_failure, + observe_descendant, publish_process_guards, + }; + use super::*; + #[derive(Debug)] + struct TreeProvider { + paths: TreePaths, + state: Arc, + } + + impl SpawnProvider for TreeProvider { + fn spawn( + &self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let policy = attempt.windows_spawn_policy(); + assert!(policy.has_job_object()); + assert!(policy.is_temporarily_suspended()); + let mut command = self.paths.direct_command()?; + command.creation_flags(CREATE_SUSPENDED.0); + let child = command.spawn()?; + let rollback_guard = publish_process_guards(&self.state, child.as_handle())?; + Ok(ProviderProduct::new( + Box::new(child), + Box::new(PanickingCommittedTransaction::new( + Arc::clone(&self.state), + rollback_guard, + )), + )) + } + } + + #[derive(Debug)] + struct TreeProviderWrapper(TreeProvider); + + impl CommandWrapper for TreeProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct ObserveTree { + paths: TreePaths, + state: Arc, + } + + impl CommandWrapper for ObserveTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + observe_descendant(&self.paths, &self.state) + } + } + + fn primary_owner_failure_preserved( + outcome: std::thread::Result>>, + identity: &Arc<()>, + was_panic: bool, + ) -> bool { + if was_panic { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => return false, + }; + match payload.downcast::() { + Ok(payload) => Arc::ptr_eq(&payload.0, identity), + Err(secondary) => { + std::mem::forget(secondary); + false + } + } + } else { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + return false; + } + }; + let error = match result { + Err(error) => error, + Ok(_) => return false, + }; + error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .is_some_and(|error| Arc::ptr_eq(&error.0, identity)) + } + } + + #[test] + fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + arm_owner_failure(failure); + + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ObserveTree { + paths: paths.clone(), + state: Arc::clone(&state), + }) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(&state), + })); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let tree_result = assert_tree_terminated(&paths, &state); + + assert!( + !failure_was_not_consumed, + "the final owner hook did not run" + ); + assert!( + primary_preserved, + "cleanup replaced the final owner failure" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 1); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!(state.residue_drops.load(Ordering::SeqCst), 1); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + tree_result?; + } + Ok(()) + } + #[test] fn sustained_nonterminal_wakes_preserve_poll_cadence() -> Result<()> { let status = ExitStatus::from_raw(0); diff --git a/src/std/process_group.rs b/src/std/process_group.rs index eff9047..48579e9 100644 --- a/src/std/process_group.rs +++ b/src/std/process_group.rs @@ -12,7 +12,10 @@ use tracing::instrument; use crate::{ChildExitStatus, unix::ProcessGroupTarget}; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, SpawnAttempt, +}; /// Wrapper which sets the process group of a [`Command`](super::Command). /// @@ -53,25 +56,32 @@ impl ProcessGroup { /// unrelated group. #[derive(Debug)] pub struct ProcessGroupChild { - inner: Box, + inner: Option>, exit_status: ChildExitStatus, pgid: Pid, } impl ProcessGroupChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - pub(crate) fn new( - inner: Box, - pgid: Pid, - exit_status: Option, - ) -> Self { + pub(crate) fn detached(pgid: Pid, exit_status: Option) -> Self { Self { - inner, + inner: None, exit_status: exit_status.map_or(ChildExitStatus::Running, ChildExitStatus::Exited), pgid, } } + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed process-group layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed process-group layer owns its child") + } + /// Get the process group ID of this child process. /// /// See: [`man 'setpgid(2)'`](https://www.man7.org/linux/man-pages/man2/setpgid.2.html) @@ -89,9 +99,9 @@ impl CommandWrapper for ProcessGroup { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let direct_pid = Pid::from_raw(i32::try_from(inner.id()).map_err(Error::other)?); let pgid = match self.target { ProcessGroupTarget::Leader => direct_pid, @@ -101,7 +111,10 @@ impl CommandWrapper for ProcessGroup { }; let exit_status = inner.try_wait()?; - Ok(Box::new(ProcessGroupChild::new(inner, pgid, exit_status))) + Ok(Some(PendingChildWrapper::new(ProcessGroupChild::detached( + pgid, + exit_status, + )))) } } @@ -115,23 +128,31 @@ impl ProcessGroupChild { } } +impl ChildWrapperLayer for ProcessGroupChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for ProcessGroupChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed process-group layer owns its child") } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Running) { - if let Some(status) = self.inner.try_wait()? { - self.exit_status = ChildExitStatus::Exited(status); - } + if matches!(self.exit_status, ChildExitStatus::Running) + && let Some(status) = self.inner_mut_ref().try_wait()? + { + self.exit_status = ChildExitStatus::Exited(status); } self.signal_imp(Signal::SIGKILL) } @@ -140,7 +161,7 @@ impl ChildWrapper for ProcessGroupChild { fn wait(&mut self) -> Result { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.wait()?; + let status = self.inner_mut_ref().wait()?; self.exit_status = ChildExitStatus::Exited(status); Ok(status) } @@ -152,7 +173,7 @@ impl ChildWrapper for ProcessGroupChild { fn try_wait(&mut self) -> Result> { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.try_wait()?; + let status = self.inner_mut_ref().try_wait()?; if let Some(status) = status { self.exit_status = ChildExitStatus::Exited(status); } diff --git a/src/std/process_session.rs b/src/std/process_session.rs index 2e121de..dc3e884 100644 --- a/src/std/process_session.rs +++ b/src/std/process_session.rs @@ -4,7 +4,7 @@ use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; -use super::{CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, core::ChildWrapper}; /// Wrapper which creates a new session and group for the `Command`. /// @@ -30,16 +30,14 @@ impl CommandWrapper for ProcessSession { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let direct_pid = Pid::from_raw(i32::try_from(inner.id()).map_err(Error::other)?); let exit_status = inner.try_wait()?; - Ok(Box::new(super::ProcessGroupChild::new( - inner, - direct_pid, - exit_status, + Ok(Some(PendingChildWrapper::new( + super::ProcessGroupChild::detached(direct_pid, exit_status), ))) } } diff --git a/src/tokio.rs b/src/tokio.rs index 374af26..df86b93 100644 --- a/src/tokio.rs +++ b/src/tokio.rs @@ -20,10 +20,13 @@ pub use crate::SpawnTransaction; #[cfg_attr(docsrs, doc(cfg(windows)))] #[doc(inline)] pub use crate::WindowsSpawnPolicy; +#[cfg(windows)] +#[doc(hidden)] +pub use core::PreparedChild; #[doc(inline)] pub use core::{ - ChildWrapper, Command, CommandWrap, CommandWrapper, ProviderProduct, SpawnAttempt, - SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrap, CommandWrapper, + PendingChildWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, }; #[cfg(all(windows, feature = "creation-flags"))] #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 05bfb22..5be02fa 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -27,7 +27,10 @@ use crate::{ use super::CreationFlags; #[cfg(feature = "kill-on-drop")] use super::KillOnDrop; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, PreparedChild, SpawnAttempt, +}; /// Wrapper which creates a job object context for a `Command`. /// @@ -63,7 +66,7 @@ fn terminate_child(child: &mut dyn ChildWrapper) { #[derive(Debug)] struct PreparedJobObject { - job_port: JobPort, + job_port: Option, final_kill_on_drop: bool, } @@ -120,7 +123,7 @@ impl JobObject { } Ok(PreparedJobObject { - job_port, + job_port: Some(job_port), final_kill_on_drop: kill_on_drop, }) } @@ -145,65 +148,82 @@ impl CommandWrapper for JobObject { fn wrap_prepared_child( &mut self, - inner: Box, - prepared: Option>, + _inner: &mut dyn ChildWrapper, + prepared: Option<&PreparedChild>, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); - let prepared = match prepared.downcast::() { - Ok(prepared) => *prepared, - Err(_) => unreachable!("JobObject prepared state retains its concrete type"), - }; - Ok(Box::new(JobObjectChild::new( - inner, - prepared.job_port, - prepared.final_kill_on_drop, - ))) - } - - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self, inner)))] - fn wrap_child( - &mut self, - mut inner: Box, - core: &CommandWrap, - ) -> Result> { - let prepared = self.prepare_job(inner.as_mut(), core)?; - let mut child = JobObjectChild::new(inner, prepared.job_port, prepared.final_kill_on_drop); - child.disarm_job_object_layer()?; - Ok(Box::new(child)) + let final_kill_on_drop = prepared + .with::(|prepared| { + assert!( + prepared.job_port.is_some(), + "JobObject prepared state retains its job handles" + ); + prepared.final_kill_on_drop + }) + .expect("JobObject prepared state retains its concrete type"); + Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( + final_kill_on_drop, + )))) } } /// Wrapper for `Child` which waits on all processes within the job. #[derive(Debug)] pub struct JobObjectChild { - inner: Box, + inner: Option>, + prepared: Option, exit_status: ChildExitStatus, job_drained: bool, poll_cadence: Option>, - job_port: JobPort, final_kill_on_drop: bool, spawn_finalized: bool, } impl JobObjectChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(job_port)))] - pub(crate) fn new( - inner: Box, - job_port: JobPort, - final_kill_on_drop: bool, - ) -> Self { + pub(crate) fn detached(final_kill_on_drop: bool) -> Self { Self { - inner, + inner: None, + prepared: None, exit_status: ChildExitStatus::Running, job_drained: false, poll_cadence: None, - job_port, final_kill_on_drop, spawn_finalized: false, } } + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed JobObject layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed JobObject layer owns its child") + } + + fn prepared(&self) -> &PreparedChild { + self.prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state") + } + + fn with_job_port(&self, inspect: impl FnOnce(&JobPort) -> R) -> R { + self.prepared() + .with::(|prepared| { + inspect( + prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"), + ) + }) + .expect("JobObject prepared state retains its concrete type") + } + fn arm_poll_cadence(&mut self, elapsed: Duration) { debug_assert!(self.poll_cadence.is_none()); let remaining = JOB_POLL_INTERVAL.saturating_sub(elapsed); @@ -234,49 +254,62 @@ impl JobObjectChild { } } +impl ChildWrapperLayer for JobObjectChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner).with_prepared(&mut self.prepared) + } +} + impl ChildWrapper for JobObjectChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { - let Self { - inner, - job_port, - final_kill_on_drop, - spawn_finalized, - .. - } = *self; - if spawn_finalized && final_kill_on_drop { - // manually drop the completion port - let its = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `its` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(HANDLE(its.completion_port.as_raw_handle())) }.ok(); - // we leave the job handle unclosed, otherwise the Child is useless - // (as closing it may terminate the job) + fn into_inner(mut self: Box) -> Box { + let inner = self + .inner + .take() + .expect("an installed JobObject layer owns its child"); + if self.spawn_finalized && self.final_kill_on_drop { + self.prepared() + .with_mut::(|prepared| { + let job_port = prepared + .job_port + .take() + .expect("the installed JobObject layer retains its job handles"); + // Manually close the completion port while retaining the job handle. Closing a + // kill-on-close job here would make the extracted child unusable. + let job_port = std::mem::ManuallyDrop::new(job_port); + // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. + unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); + }) + .expect("JobObject prepared state retains its concrete type"); } - // Before spawn finalization, dropping the still-armed job instead guarantees that removing this - // layer cannot let descendants escape a later lifecycle failure. + // Before spawn finalization, dropping the still-armed prepared job instead guarantees that + // removing this layer cannot let descendants escape a later lifecycle failure. inner } fn process_handle(&self) -> Option> { - self.inner.try_process_handle() + self.inner_ref().try_process_handle() } fn owns_job_object_cleanup_layer(&self) -> bool { true } fn disarm_job_object_layer(&mut self) -> Result<()> { - set_job_kill_on_drop(self.job_port.job, self.final_kill_on_drop)?; + #[cfg(test)] + crate::windows::test_support::fail_final_owner()?; + let final_kill_on_drop = self.final_kill_on_drop; + self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; Ok(()) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - terminate_job(self.job_port.job, 1) + self.with_job_port(|job_port| terminate_job(job_port.job, 1)) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -286,7 +319,7 @@ impl ChildWrapper for JobObjectChild { ChildExitStatus::Running => { // Cache direct-child exit independently from whole-job drain. There is no await // between observing the status and retaining it, so cancellation cannot lose it. - let status = self.inner.wait().await?; + let status = self.inner_mut_ref().wait().await?; self.exit_status = ChildExitStatus::Exited(status); status } @@ -298,12 +331,15 @@ impl ChildWrapper for JobObjectChild { // until the already-armed cadence completes. self.wait_for_poll_cadence().await?; let poll_started = Instant::now(); - if poll_job_drain( - self.job_port.job, - self.job_port.completion_port.as_handle(), - Duration::ZERO, - )? - .is_break() + if self + .with_job_port(|job_port| { + poll_job_drain( + job_port.job, + job_port.completion_port.as_handle(), + Duration::ZERO, + ) + })? + .is_break() { // No await occurs between the authoritative accounting result and this durable // transition, so a canceled future cannot consume the drain state. @@ -320,19 +356,22 @@ impl ChildWrapper for JobObjectChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { if matches!(self.exit_status, ChildExitStatus::Running) { - let Some(status) = self.inner.try_wait()? else { + let Some(status) = self.inner_mut_ref().try_wait()? else { return Ok(None); }; self.exit_status = ChildExitStatus::Exited(status); } if !self.job_drained - && poll_job_drain( - self.job_port.job, - self.job_port.completion_port.as_handle(), - Duration::ZERO, - )? - .is_break() + && self + .with_job_port(|job_port| { + poll_job_drain( + job_port.job, + job_port.completion_port.as_handle(), + Duration::ZERO, + ) + })? + .is_break() { self.job_drained = true; self.clear_poll_cadence(); @@ -344,3 +383,168 @@ impl ChildWrapper for JobObjectChild { } } } + +#[cfg(test)] +mod tests { + use std::{ + os::windows::{io::BorrowedHandle, process::CommandExt}, + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Arc, atomic::Ordering}, + }; + + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + use crate::tokio::{ProviderProduct, SpawnProvider}; + use crate::windows::test_support::{ + LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, + TreePaths, arm_owner_failure, assert_tree_terminated, clear_owner_failure, + observe_descendant, publish_process_guards, + }; + + use super::*; + + #[derive(Debug)] + struct TreeProvider { + paths: TreePaths, + state: Arc, + } + + impl SpawnProvider for TreeProvider { + fn spawn( + &self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let policy = attempt.windows_spawn_policy(); + assert!(policy.has_job_object()); + assert!(policy.is_temporarily_suspended()); + let mut command = self.paths.direct_command()?; + command.creation_flags(CREATE_SUSPENDED.0); + let mut command = tokio::process::Command::from(command); + let child = command.spawn()?; + let handle = child + .raw_handle() + .ok_or_else(|| Error::other("the provider child has no process handle"))?; + // SAFETY: `child` owns this process handle throughout both cloning operations. + let handle = unsafe { BorrowedHandle::borrow_raw(handle) }; + let rollback_guard = publish_process_guards(&self.state, handle)?; + Ok(ProviderProduct::new( + Box::new(child), + Box::new(PanickingCommittedTransaction::new( + Arc::clone(&self.state), + rollback_guard, + )), + )) + } + } + + #[derive(Debug)] + struct TreeProviderWrapper(TreeProvider); + + impl CommandWrapper for TreeProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct ObserveTree { + paths: TreePaths, + state: Arc, + } + + impl CommandWrapper for ObserveTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + observe_descendant(&self.paths, &self.state) + } + } + + fn primary_owner_failure_preserved( + outcome: std::thread::Result>>, + identity: &Arc<()>, + was_panic: bool, + ) -> bool { + if was_panic { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => return false, + }; + match payload.downcast::() { + Ok(payload) => Arc::ptr_eq(&payload.0, identity), + Err(secondary) => { + std::mem::forget(secondary); + false + } + } + } else { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + return false; + } + }; + let error = match result { + Err(error) => error, + Ok(_) => return false, + }; + error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .is_some_and(|error| Arc::ptr_eq(&error.0, identity)) + } + } + + #[tokio::test(flavor = "current_thread")] + async fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> + { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + arm_owner_failure(failure); + + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ObserveTree { + paths: paths.clone(), + state: Arc::clone(&state), + }) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(&state), + })); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let tree_result = assert_tree_terminated(&paths, &state); + + assert!( + !failure_was_not_consumed, + "the final owner hook did not run" + ); + assert!( + primary_preserved, + "cleanup replaced the final owner failure" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 1); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!(state.residue_drops.load(Ordering::SeqCst), 1); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + tree_result?; + } + Ok(()) + } +} diff --git a/src/tokio/process_group.rs b/src/tokio/process_group.rs index 6017041..e21273c 100644 --- a/src/tokio/process_group.rs +++ b/src/tokio/process_group.rs @@ -14,7 +14,10 @@ use tracing::instrument; use crate::{ChildExitStatus, unix::ProcessGroupTarget}; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, SpawnAttempt, +}; /// Wrapper which sets the process group of a `Command`. /// @@ -60,25 +63,32 @@ impl ProcessGroup { /// unrelated group. #[derive(Debug)] pub struct ProcessGroupChild { - inner: Box, + inner: Option>, exit_status: ChildExitStatus, pgid: Pid, } impl ProcessGroupChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - pub(crate) fn new( - inner: Box, - pgid: Pid, - exit_status: Option, - ) -> Self { + pub(crate) fn detached(pgid: Pid, exit_status: Option) -> Self { Self { - inner, + inner: None, exit_status: exit_status.map_or(ChildExitStatus::Running, ChildExitStatus::Exited), pgid, } } + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed process-group layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed process-group layer owns its child") + } + /// Get the process group ID of this child process. /// /// See: [`man 'setpgid(2)'`](https://www.man7.org/linux/man-pages/man2/setpgid.2.html) @@ -96,9 +106,9 @@ impl CommandWrapper for ProcessGroup { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let mut direct_id = inner.id(); #[cfg(feature = "pty")] if direct_id.is_none() { @@ -119,37 +129,49 @@ impl CommandWrapper for ProcessGroup { }; let exit_status = inner.try_wait()?; - Ok(Box::new(ProcessGroupChild::new(inner, pgid, exit_status))) + Ok(Some(PendingChildWrapper::new(ProcessGroupChild::detached( + pgid, + exit_status, + )))) } } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn signal_imp(&self, sig: Signal) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Exited(_)) || self.inner.id().is_none() { + if matches!(self.exit_status, ChildExitStatus::Exited(_)) || self.inner_ref().id().is_none() + { return Ok(()); } killpg(self.pgid, sig).map_err(Error::from) } } +impl ChildWrapperLayer for ProcessGroupChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for ProcessGroupChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed process-group layer owns its child") } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Running) { - if let Some(status) = self.inner.try_wait()? { - self.exit_status = ChildExitStatus::Exited(status); - } + if matches!(self.exit_status, ChildExitStatus::Running) + && let Some(status) = self.inner_mut_ref().try_wait()? + { + self.exit_status = ChildExitStatus::Exited(status); } self.signal_imp(Signal::SIGKILL) } @@ -159,7 +181,7 @@ impl ChildWrapper for ProcessGroupChild { Box::pin(async { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.wait().await?; + let status = self.inner_mut_ref().wait().await?; self.exit_status = ChildExitStatus::Exited(status); Ok(status) } @@ -172,7 +194,7 @@ impl ChildWrapper for ProcessGroupChild { fn try_wait(&mut self) -> Result> { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.try_wait()?; + let status = self.inner_mut_ref().try_wait()?; if let Some(status) = status { self.exit_status = ChildExitStatus::Exited(status); } diff --git a/src/tokio/process_session.rs b/src/tokio/process_session.rs index 80d658f..ac19caf 100644 --- a/src/tokio/process_session.rs +++ b/src/tokio/process_session.rs @@ -4,7 +4,7 @@ use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; -use super::{CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, core::ChildWrapper}; /// Wrapper which creates a new session and group for the `Command`. /// @@ -33,9 +33,9 @@ impl CommandWrapper for ProcessSession { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let mut direct_id = inner.id(); #[cfg(feature = "pty")] if direct_id.is_none() { @@ -50,10 +50,8 @@ impl CommandWrapper for ProcessSession { let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); let exit_status = inner.try_wait()?; - Ok(Box::new(super::ProcessGroupChild::new( - inner, - direct_pid, - exit_status, + Ok(Some(PendingChildWrapper::new( + super::ProcessGroupChild::detached(direct_pid, exit_status), ))) } } diff --git a/src/windows.rs b/src/windows.rs index 6363caf..d323cbd 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -139,6 +139,343 @@ mod job_wait_tests { } } +#[cfg(test)] +pub(crate) mod test_support { + use std::{ + cell::RefCell, + fs, + io::{Error, ErrorKind, Result}, + os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}, + panic::panic_any, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use windows::Win32::{ + Foundation::{HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, + WaitForSingleObject, + }, + }; + + use crate::SpawnTransaction; + + pub const DIRECT_HELPER: &str = "windows::test_support::final_owner_direct_child_helper"; + const DESCENDANT_HELPER: &str = "windows::test_support::final_owner_descendant_helper"; + const DESCENDANT_PID_ENV: &str = "PROCESS_WRAP_FINAL_OWNER_DESCENDANT_PID"; + const MARKER_ENV: &str = "PROCESS_WRAP_FINAL_OWNER_MARKER"; + const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + const MARKER_DELAY: Duration = Duration::from_millis(750); + + #[derive(Debug)] + pub struct OwnerError(pub Arc<()>); + + impl std::fmt::Display for OwnerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("final JobObject owner failed") + } + } + + impl std::error::Error for OwnerError {} + + #[derive(Debug)] + pub struct OwnerPanic(pub Arc<()>); + + pub enum OwnerFailure { + Error(Arc<()>), + Panic(Arc<()>), + } + + thread_local! { + static OWNER_FAILURE: RefCell> = const { RefCell::new(None) }; + } + + pub fn arm_owner_failure(failure: OwnerFailure) { + OWNER_FAILURE.with(|slot| { + assert!(slot.borrow_mut().replace(failure).is_none()); + }); + } + + pub fn fail_final_owner() -> Result<()> { + OWNER_FAILURE.with(|slot| match slot.borrow_mut().take() { + Some(OwnerFailure::Error(identity)) => Err(Error::other(OwnerError(identity))), + Some(OwnerFailure::Panic(identity)) => panic_any(OwnerPanic(identity)), + None => Ok(()), + }) + } + + pub fn clear_owner_failure() -> bool { + OWNER_FAILURE.with(|slot| slot.borrow_mut().take().is_some()) + } + + #[derive(Debug)] + pub struct ProcessGuard { + handle: OwnedHandle, + armed: bool, + } + + impl ProcessGuard { + pub fn open(pid: u32) -> Result { + // SAFETY: the requested rights operate on the process identified by `pid`; success is + // immediately converted into an owned handle. + let handle = + unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } + .map_err(Error::other)?; + // SAFETY: `OpenProcess` returned a newly owned process handle. + let handle = unsafe { OwnedHandle::from_raw_handle(handle.0) }; + Ok(Self { + handle, + armed: true, + }) + } + + pub fn clone_from(handle: BorrowedHandle<'_>) -> Result { + Ok(Self { + handle: handle.try_clone_to_owned()?, + armed: true, + }) + } + + pub fn disarm(&mut self) { + self.armed = false; + } + + pub fn wait_for_exit(&mut self, label: &str) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + // SAFETY: `self.handle` remains live and is used only for a nonblocking wait. + match unsafe { WaitForSingleObject(HANDLE(self.handle.as_raw_handle()), 0) } { + WAIT_OBJECT_0 => { + self.disarm(); + return Ok(()); + } + WAIT_TIMEOUT if Instant::now() < deadline => { + sleep(Duration::from_millis(10)); + } + WAIT_TIMEOUT => { + return Err(Error::new( + ErrorKind::TimedOut, + format!("{label} survived final-owner cleanup"), + )); + } + _ => return Err(Error::last_os_error()), + } + } + } + } + + impl Drop for ProcessGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + // SAFETY: this guard owns a live handle with termination and synchronization rights. + let _ = unsafe { TerminateProcess(HANDLE(self.handle.as_raw_handle()), 1) }; + // SAFETY: the same handle remains live; the finite timeout bounds fallback cleanup. + let _ = unsafe { WaitForSingleObject(HANDLE(self.handle.as_raw_handle()), 1_000) }; + } + } + + #[derive(Clone, Debug)] + pub struct TreePaths { + pub descendant_pid: PathBuf, + pub marker: PathBuf, + } + + impl TreePaths { + pub fn new(directory: &Path) -> Self { + Self { + descendant_pid: directory.join("descendant.pid"), + marker: directory.join("delayed-marker"), + } + } + + pub fn direct_command(&self) -> Result { + let mut command = Command::new(std::env::current_exe()?); + command + .args(["--exact", DIRECT_HELPER, "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_ENV, &self.descendant_pid) + .env(MARKER_ENV, &self.marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + Ok(command) + } + } + + #[derive(Debug, Default)] + pub struct LifecycleState { + pub commits: AtomicUsize, + pub rollbacks: AtomicUsize, + pub residue_drops: AtomicUsize, + pub payload_drops: Arc, + pub direct: Mutex>, + pub descendant: Mutex>, + } + + #[derive(Debug)] + struct ResiduePayload(Arc); + + impl Drop for ResiduePayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a quarantined committed-residue payload was dropped"); + } + } + + #[derive(Debug)] + pub struct PanickingCommittedTransaction { + state: Arc, + rollback_guard: Option, + committed: bool, + } + + impl PanickingCommittedTransaction { + pub fn new(state: Arc, rollback_guard: ProcessGuard) -> Self { + Self { + state, + rollback_guard: Some(rollback_guard), + committed: false, + } + } + } + + impl SpawnTransaction for PanickingCommittedTransaction { + fn commit(&mut self) -> Result<()> { + self.state.commits.fetch_add(1, Ordering::SeqCst); + self.committed = true; + if let Some(guard) = self.rollback_guard.as_mut() { + guard.disarm(); + } + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + self.state.rollbacks.fetch_add(1, Ordering::SeqCst); + self.rollback_guard.take(); + Ok(()) + } + } + + impl Drop for PanickingCommittedTransaction { + fn drop(&mut self) { + if self.committed { + self.state.residue_drops.fetch_add(1, Ordering::SeqCst); + panic_any(ResiduePayload(Arc::clone(&self.state.payload_drops))); + } + } + } + + pub fn publish_process_guards( + state: &Arc, + handle: BorrowedHandle<'_>, + ) -> Result { + let external = ProcessGuard::clone_from(handle)?; + let rollback = ProcessGuard::clone_from(handle)?; + *state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(external); + Ok(rollback) + } + + pub fn observe_descendant(paths: &TreePaths, state: &Arc) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + let pid = loop { + if let Ok(pid) = fs::read_to_string(&paths.descendant_pid) + .and_then(|pid| pid.trim().parse().map_err(Error::other)) + { + break pid; + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "the final-owner descendant did not report its process ID", + )); + } + sleep(Duration::from_millis(10)); + }; + let guard = ProcessGuard::open(pid)?; + *state + .descendant + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard); + Ok(()) + } + + pub fn assert_tree_terminated(paths: &TreePaths, state: &Arc) -> Result<()> { + let mut direct = state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| Error::other("the provider did not publish its direct child handle"))?; + let mut descendant = state + .descendant + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| Error::other("the hook did not publish its descendant handle"))?; + direct.wait_for_exit("the direct child")?; + descendant.wait_for_exit("the descendant")?; + + let marker_deadline = Instant::now() + MARKER_DELAY + Duration::from_millis(250); + while Instant::now() < marker_deadline { + if paths.marker.exists() { + return Err(Error::other( + "the terminated descendant wrote its delayed marker", + )); + } + sleep(Duration::from_millis(10)); + } + if paths.marker.exists() { + return Err(Error::other( + "the terminated descendant wrote its delayed marker", + )); + } + Ok(()) + } + + #[test] + #[ignore = "subprocess helper"] + fn final_owner_direct_child_helper() -> Result<()> { + let descendant_pid = std::env::var_os(DESCENDANT_PID_ENV) + .ok_or_else(|| Error::other("the descendant PID path is missing"))?; + let marker = std::env::var_os(MARKER_ENV) + .ok_or_else(|| Error::other("the delayed-marker path is missing"))?; + let mut descendant = Command::new(std::env::current_exe()?) + .args(["--exact", DESCENDANT_HELPER, "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_ENV, descendant_pid) + .env(MARKER_ENV, marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let _ = descendant.wait()?; + Ok(()) + } + + #[test] + #[ignore = "subprocess helper"] + fn final_owner_descendant_helper() -> Result<()> { + let descendant_pid = std::env::var_os(DESCENDANT_PID_ENV) + .ok_or_else(|| Error::other("the descendant PID path is missing"))?; + let marker = std::env::var_os(MARKER_ENV) + .ok_or_else(|| Error::other("the delayed-marker path is missing"))?; + fs::write(descendant_pid, std::process::id().to_string())?; + sleep(MARKER_DELAY); + fs::File::create(marker)?; + Ok(()) + } +} + #[derive(Clone, Copy, Debug)] pub struct JobHandle(pub HANDLE); diff --git a/tests/child_wrapper_contract.rs b/tests/child_wrapper_contract.rs index fcd387e..e443519 100644 --- a/tests/child_wrapper_contract.rs +++ b/tests/child_wrapper_contract.rs @@ -54,7 +54,9 @@ mod std_frontend { use process_wrap::std::ChildWrapper; #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] - use process_wrap::std::{CommandWrap, CommandWrapper}; + use process_wrap::std::{ + ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, PendingChildWrapper, + }; #[derive(Debug)] struct Layer { @@ -227,36 +229,49 @@ mod std_frontend { #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] #[derive(Debug)] - struct MarkerChild(Box); + struct MarkerChild(Option>); + + #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] + impl ChildWrapperLayer for MarkerChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl CommandWrapper for MarkerCommand { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> std::io::Result> { - Ok(Box::new(MarkerChild(child))) + ) -> std::io::Result> { + Ok(Some(PendingChildWrapper::new(MarkerChild(None)))) } } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl ChildWrapper for MarkerChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed marker layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed marker layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed marker layer owns its child") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } @@ -555,7 +570,9 @@ mod tokio_frontend { use process_wrap::tokio::ChildWrapper; #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] - use process_wrap::tokio::{CommandWrap, CommandWrapper}; + use process_wrap::tokio::{ + ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, PendingChildWrapper, + }; use tokio::process::{Child, Command}; #[derive(Debug)] @@ -729,36 +746,49 @@ mod tokio_frontend { #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] #[derive(Debug)] - struct MarkerChild(Box); + struct MarkerChild(Option>); + + #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] + impl ChildWrapperLayer for MarkerChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl CommandWrapper for MarkerCommand { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> std::io::Result> { - Ok(Box::new(MarkerChild(child))) + ) -> std::io::Result> { + Ok(Some(PendingChildWrapper::new(MarkerChild(None)))) } } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl ChildWrapper for MarkerChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed marker layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed marker layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed marker layer owns its child") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index aee72e1..cbdb4d8 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -7,6 +7,10 @@ macro_rules! spawn_provider_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $child_wrapper_layer:path, + $child_wrapper_slots:path, + $pending_child_wrapper:path, + $prepared_child:path, $provider_product:path, $spawn_provider:path, $runtime:expr @@ -15,12 +19,13 @@ macro_rules! spawn_provider_tests { use std::{ any::TypeId, ffi::OsStr, - io, + io::{self, Read}, panic::{AssertUnwindSafe, catch_unwind, panic_any}, - process::{Output, Stdio}, + process::{ExitStatus, Output, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, }, thread::sleep, time::{Duration, Instant}, @@ -28,8 +33,15 @@ macro_rules! spawn_provider_tests { use process_wrap::{CommandArg, SpawnTransaction}; use $child_wrapper as ChildWrapper; + #[cfg(windows)] + use $child_wrapper_layer as ChildWrapperLayer; + #[cfg(windows)] + use $child_wrapper_slots as ChildWrapperSlots; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; + #[cfg(windows)] + use $prepared_child as PreparedChild; use $provider_product as ProviderProduct; use $spawn_attempt as SpawnAttempt; use $spawn_provider as SpawnProvider; @@ -136,6 +148,8 @@ macro_rules! spawn_provider_tests { transaction_drop_behavior: Mutex>, child_drop_payload: Mutex>, commit_primary_failure: Mutex>, + #[cfg(windows)] + owner_primary_failure: Mutex>, transaction_debug_calls: AtomicUsize, panic_in_transaction_debug: AtomicBool, make_attempt_native_only: AtomicBool, @@ -192,6 +206,16 @@ macro_rules! spawn_provider_tests { self.commit_primary_failure.lock().unwrap().take() } + #[cfg(windows)] + fn set_owner_primary_failure(&self, failure: PrimaryFailure) { + *self.owner_primary_failure.lock().unwrap() = Some(failure); + } + + #[cfg(windows)] + fn take_owner_primary_failure(&self) -> Option { + self.owner_primary_failure.lock().unwrap().take() + } + #[cfg(windows)] fn set_finalization_layers(&self, layers: Vec) { *self.finalization_layers.lock().unwrap() = layers; @@ -357,6 +381,51 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[derive(Debug)] + struct PreparedLayer { + inner: Option>, + prepared: Option, + } + + #[cfg(windows)] + impl PreparedLayer { + fn detached() -> Self { + Self { + inner: None, + prepared: None, + } + } + } + + #[cfg(windows)] + impl ChildWrapperLayer for PreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner).with_prepared(&mut self.prepared) + } + } + + #[cfg(windows)] + impl ChildWrapper for PreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed prepared layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed prepared layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed prepared layer owns its child") + } + } + #[cfg(windows)] macro_rules! prepared_wrapper { ($name:ident, $label:literal) => { @@ -384,12 +453,11 @@ macro_rules! spawn_provider_tests { fn wrap_prepared_child( &mut self, - child: Box, - prepared: Option>, + _child: &mut dyn ChildWrapper, + prepared: Option<&PreparedChild>, _command: &CommandWrap, - ) -> io::Result> { - debug_assert!(prepared.is_none()); - Ok(child) + ) -> io::Result> { + Ok(prepared.map(|_| PendingChildWrapper::new(PreparedLayer::detached()))) } } }; @@ -412,7 +480,13 @@ macro_rules! spawn_provider_tests { _child: &mut dyn ChildWrapper, _command: &CommandWrap, ) -> io::Result>> { - match self.0.lock().unwrap().take() { + let failure = { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + }; + match failure { Some(PrimaryFailure::Error(identity)) => { Err(io::Error::other(IdentityError(identity))) } @@ -468,6 +542,14 @@ macro_rules! spawn_provider_tests { if self.owns_job_cleanup { self.shared.event(Event::DisarmOwner(self.name)); self.shared.fail(Point::DisarmOwner)?; + if let Some(failure) = self.shared.take_owner_primary_failure() { + match failure { + PrimaryFailure::Error(identity) => { + return Err(io::Error::other(IdentityError(identity))); + } + PrimaryFailure::Panic(identity) => panic_any(PrimaryPanic(identity)), + } + } self.armed = false; } else { self.shared.event(Event::DisarmNonOwner(self.name)); @@ -477,6 +559,30 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + impl FinalizationChild { + fn child_slot(&mut self) -> &mut Option> { + if self.inner.is_none() { + return &mut self.inner; + } + let inner = self + .inner + .as_deref_mut() + .expect("a prebuilt finalization chain has an inner layer"); + let inner = (inner as &mut dyn std::any::Any) + .downcast_mut::() + .expect("a detached finalization chain contains only finalization layers"); + inner.child_slot() + } + } + + #[cfg(windows)] + impl ChildWrapperLayer for FinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(self.child_slot()) + } + } + #[cfg(windows)] impl Drop for FinalizationChild { fn drop(&mut self) { @@ -487,17 +593,14 @@ macro_rules! spawn_provider_tests { } #[cfg(windows)] - fn add_finalization_layers( - shared: Arc, - child: Box, - ) -> Box { + fn finalization_layer_chain(shared: Arc) -> Option { shared .finalization_layers() .into_iter() .rev() - .fold(child, |inner, layer| { - Box::new(FinalizationChild { - inner: Some(inner), + .fold(None, |inner, layer| { + Some(FinalizationChild { + inner: inner.map(|inner| Box::new(inner) as Box), shared: Arc::clone(&shared), name: layer.name, owns_job_cleanup: layer.owns_job_cleanup, @@ -760,14 +863,14 @@ macro_rules! spawn_provider_tests { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, command: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.assert_hook_visibility(command); - assert_provider_child(child.as_ref()); + assert_provider_child(child); self.shared().event(Event::Wrap(self.name)); self.shared().fail(Point::Wrap)?; - Ok(child) + Ok(None) } fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { @@ -821,18 +924,20 @@ macro_rules! spawn_provider_tests { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, command: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.assert_hook_visibility(command); if self.expect_custom_child { - assert_provider_child(child.as_ref()); + assert_provider_child(child); } self.shared.event(Event::Wrap("peer")); self.shared.fail(Point::PeerWrap)?; #[cfg(windows)] - let child = add_finalization_layers(Arc::clone(&self.shared), child); - Ok(child) + return Ok(finalization_layer_chain(Arc::clone(&self.shared)) + .map(PendingChildWrapper::new)); + #[cfg(not(windows))] + Ok(None) } } @@ -1087,25 +1192,87 @@ macro_rules! spawn_provider_tests { environment: (&str, &str), timeout: Duration, ) -> (Output, bool) { + const REAP_TIMEOUT: Duration = Duration::from_secs(5); + struct ChildGuard(Option); + impl ChildGuard { + fn terminate_and_reap(&mut self) -> io::Result { + let child = self + .0 + .as_mut() + .expect("the guarded subprocess remains available"); + match child.kill() { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let deadline = Instant::now() + REAP_TIMEOUT; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "isolated lifecycle subprocess was not reaped after termination", + )); + } + sleep(Duration::from_millis(5)); + } + } + } + impl Drop for ChildGuard { fn drop(&mut self) { - if let Some(mut child) = self.0.take() { - let _ = child.kill(); - let _ = child.wait(); + if self.0.is_some() { + let _ = self.terminate_and_reap(); } } } - let child = std::process::Command::new(std::env::current_exe().unwrap()) + fn drain_pipe( + mut pipe: impl Read + Send + 'static, + ) -> mpsc::Receiver>> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.read_to_end(&mut bytes).map(|_| bytes); + let _ = sender.send(result); + }); + receiver + } + + fn collect_output( + status: ExitStatus, + stdout: mpsc::Receiver>>, + stderr: mpsc::Receiver>>, + ) -> Output { + let deadline = Instant::now() + REAP_TIMEOUT; + let receive = |receiver: mpsc::Receiver>>, label| { + let remaining = deadline.saturating_duration_since(Instant::now()); + receiver + .recv_timeout(remaining) + .unwrap_or_else(|error| panic!("did not drain isolated {label}: {error}")) + .unwrap_or_else(|error| panic!("could not read isolated {label}: {error}")) + }; + Output { + status, + stdout: receive(stdout, "stdout"), + stderr: receive(stderr, "stderr"), + } + } + + let mut spawned = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", test_name, "--nocapture"]) .env(environment.0, environment.1) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("start isolated lifecycle regression"); - let mut child = ChildGuard(Some(child)); + let stdout = drain_pipe(spawned.stdout.take().expect("capture isolated stdout")); + let stderr = drain_pipe(spawned.stderr.take().expect("capture isolated stderr")); + let mut child = ChildGuard(Some(spawned)); let deadline = Instant::now() + timeout; loop { let status = child @@ -1114,22 +1281,16 @@ macro_rules! spawn_provider_tests { .expect("the subprocess remains guarded while polling") .try_wait() .expect("poll isolated lifecycle regression"); - if status.is_some() { - let output = child - .0 - .take() - .expect("take completed subprocess") - .wait_with_output() - .expect("collect isolated lifecycle output"); - return (output, false); + if let Some(status) = status { + child.0.take(); + return (collect_output(status, stdout, stderr), false); } if Instant::now() >= deadline { - let mut expired = child.0.take().expect("take expired subprocess"); - let _ = expired.kill(); - let output = expired - .wait_with_output() - .expect("reap expired lifecycle subprocess"); - return (output, true); + let status = child + .terminate_and_reap() + .expect("terminate and reap expired lifecycle subprocess"); + child.0.take(); + return (collect_output(status, stdout, stderr), true); } sleep(Duration::from_millis(5)); } @@ -2001,10 +2162,13 @@ macro_rules! spawn_provider_tests { ); let events = shared.events(); assert!(!events.contains(&Event::Commit)); - assert_eq!(events.last(), Some(&Event::Rollback)); + assert!(events.contains(&Event::Rollback)); assert!(events.contains(&Event::DisarmNonOwner("nonowner"))); - assert!(events.contains(&Event::OwnerDropped("outer-owner", true))); - assert!(events.contains(&Event::OwnerDropped("inner-owner", true))); + assert!(events.ends_with(&[ + Event::Rollback, + Event::OwnerDropped("outer-owner", true), + Event::OwnerDropped("inner-owner", true), + ])); } #[cfg(windows)] @@ -2064,8 +2228,8 @@ macro_rules! spawn_provider_tests { assert!(events.contains(&Event::Commit)); assert!(events.contains(&Event::DisarmOwner("owner"))); assert!(events.ends_with(&[ - Event::OwnerDropped("owner", true), Event::TransactionDrop, + Event::OwnerDropped("owner", true), ])); assert!(!events.contains(&Event::Rollback)); assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); @@ -2079,6 +2243,95 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[test] + fn final_owner_failure_survives_child_prepared_and_residue_panics() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_FINAL_OWNER_CLEANUP_PANICS"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for failure in ["error", "panic"] { + let child_value = format!("{module}:{failure}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::final_owner_failure_survives_child_prepared_and_residue_panics" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "final-owner cleanup exceeded its deadline"); + assert!( + output.status.success(), + "cleanup replaced the final-owner {failure}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let child_payload_drops = Arc::new(AtomicUsize::new(0)); + let prepared_payload_drops = Arc::new(AtomicUsize::new(0)); + let residue_payload_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload { + drops: Arc::clone(&child_payload_drops), + }); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( + PanickingDropPayload { + drops: Arc::clone(&residue_payload_drops), + }, + )); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + let identity = Arc::new(()); + let failure = if selected + .as_deref() + .expect("the subprocess selected a failure") + .ends_with(":panic") + { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let was_panic = matches!(&failure, PrimaryFailure::Panic(_)); + shared.set_owner_primary_failure(failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command.wrap(FirstPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload { + drops: Arc::clone(&prepared_payload_drops), + })), + }); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + let events = shared.events(); + assert_eq!(events.iter().filter(|event| **event == Event::Commit).count(), 1); + assert!(!events.contains(&Event::Rollback)); + assert!(events.ends_with(&[ + Event::Commit, + Event::DisarmOwner("owner"), + Event::TransactionDrop, + Event::OwnerDropped("owner", true), + Event::ChildDrop, + Event::PreparedDrop("first"), + ])); + assert_eq!(child_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(prepared_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(residue_payload_drops.load(Ordering::SeqCst), 0); + + shared.clear_events(); + let child = command.spawn().expect("the command remains reusable"); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + expected.push(Event::OwnerDropped("owner", false)); + assert_eq!(shared.events(), expected); + } + #[cfg(windows)] #[test] fn final_owner_disarms_before_committed_residue_transfer() { @@ -2249,6 +2502,9 @@ macro_rules! provider_capability_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $child_wrapper_layer:path, + $child_wrapper_slots:path, + $pending_child_wrapper:path, $provider_product:path, $spawn_provider:path, $native_command:path, @@ -2272,8 +2528,11 @@ macro_rules! provider_capability_tests { use process_wrap::SpawnTransaction; use $child_wrapper as ChildWrapper; + use $child_wrapper_layer as ChildWrapperLayer; + use $child_wrapper_slots as ChildWrapperSlots; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; use $provider_product as ProviderProduct; use $spawn_attempt as SpawnAttempt; use $spawn_provider as SpawnProvider; @@ -2364,24 +2623,36 @@ macro_rules! provider_capability_tests { } #[derive(Debug)] - struct OuterLayer(Box); + struct OuterLayer(Option>); + + impl ChildWrapperLayer for OuterLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } impl ChildWrapper for OuterLayer { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed outer layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed outer layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed outer layer owns its child") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } @@ -2391,10 +2662,10 @@ macro_rules! provider_capability_tests { impl CommandWrapper for OuterWrapper { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &CommandWrap, - ) -> io::Result> { - Ok(Box::new(OuterLayer(child))) + ) -> io::Result> { + Ok(Some(PendingChildWrapper::new(OuterLayer(None)))) } } @@ -3098,6 +3369,10 @@ spawn_provider_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::ChildWrapperLayer, + process_wrap::std::ChildWrapperSlots, + process_wrap::std::PendingChildWrapper, + process_wrap::std::PreparedChild, process_wrap::std::ProviderProduct, process_wrap::std::SpawnProvider, None @@ -3110,6 +3385,10 @@ spawn_provider_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ChildWrapperLayer, + process_wrap::tokio::ChildWrapperSlots, + process_wrap::tokio::PendingChildWrapper, + process_wrap::tokio::PreparedChild, process_wrap::tokio::ProviderProduct, process_wrap::tokio::SpawnProvider, Some( @@ -3127,6 +3406,9 @@ provider_capability_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::ChildWrapperLayer, + process_wrap::std::ChildWrapperSlots, + process_wrap::std::PendingChildWrapper, process_wrap::std::ProviderProduct, process_wrap::std::SpawnProvider, std::process::Command, @@ -3143,6 +3425,9 @@ provider_capability_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ChildWrapperLayer, + process_wrap::tokio::ChildWrapperSlots, + process_wrap::tokio::PendingChildWrapper, process_wrap::tokio::ProviderProduct, process_wrap::tokio::SpawnProvider, tokio::process::Command, diff --git a/tests/spawn_with_child.rs b/tests/spawn_with_child.rs index 0e3f5b8..f474d08 100644 --- a/tests/spawn_with_child.rs +++ b/tests/spawn_with_child.rs @@ -7,25 +7,32 @@ macro_rules! spawn_with_child_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $child_wrapper_layer:path, + $child_wrapper_slots:path, + $pending_child_wrapper:path, $runtime:expr ) => { mod $module { use std::{ any::TypeId, - io, + io::{self, Read}, panic::{AssertUnwindSafe, catch_unwind, panic_any}, - process::{Output, Stdio}, + process::{ExitStatus, Output, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, + mpsc, }, thread::sleep, time::{Duration, Instant}, }; use $child_wrapper as ChildWrapper; + use $child_wrapper_layer as ChildWrapperLayer; + use $child_wrapper_slots as ChildWrapperSlots; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; use $spawn_attempt as SpawnAttempt; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); @@ -63,11 +70,11 @@ macro_rules! spawn_with_child_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.0.lock().unwrap().push(Event::Wrap("first")); - Ok(Box::new(FirstChild(child))) + Ok(Some(PendingChildWrapper::new(FirstChild(None)))) } } @@ -96,55 +103,69 @@ macro_rules! spawn_with_child_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.0.lock().unwrap().push(Event::Wrap("second")); - Ok(Box::new(SecondChild(child))) + Ok(Some(PendingChildWrapper::new(SecondChild(None)))) } } #[derive(Debug)] - struct FirstChild(Box); + struct FirstChild(Option>); + + impl ChildWrapperLayer for FirstChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } impl ChildWrapper for FirstChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0.as_deref().expect("the first layer is installed") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0.as_deref_mut().expect("the first layer is installed") } - fn into_inner(self: Box) -> Box { - self.0 + fn into_inner(mut self: Box) -> Box { + self.0.take().expect("the first layer is installed") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } #[derive(Debug)] - struct SecondChild(Box); + struct SecondChild(Option>); + + impl ChildWrapperLayer for SecondChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } impl ChildWrapper for SecondChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0.as_deref().expect("the second layer is installed") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("the second layer is installed") } - fn into_inner(self: Box) -> Box { - self.0 + fn into_inner(mut self: Box) -> Box { + self.0.take().expect("the second layer is installed") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } @@ -196,22 +217,34 @@ macro_rules! spawn_with_child_tests { #[derive(Debug)] struct PanickingLayer { - inner: Box, + inner: Option>, payload: Option, } + impl ChildWrapperLayer for PanickingLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } + } + impl ChildWrapper for PanickingLayer { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("the panicking layer is installed") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("the panicking layer is installed") } fn into_inner(mut self: Box) -> Box { - self.payload.take(); - std::mem::replace(&mut self.inner, Box::new(CustomLeaf)) + if let Some(payload) = self.payload.take() { + std::mem::forget(payload); + } + self.inner.take().expect("the panicking layer is installed") } } @@ -229,16 +262,15 @@ macro_rules! spawn_with_child_tests { impl CommandWrapper for AddPanickingLayer { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &CommandWrap, - ) -> io::Result> { - match self.0.lock().unwrap().take() { - Some(payload) => Ok(Box::new(PanickingLayer { - inner: child, + ) -> io::Result> { + Ok(self.0.lock().unwrap().take().map(|payload| { + PendingChildWrapper::new(PanickingLayer { + inner: None, payload: Some(payload), - })), - None => Ok(child), - } + }) + })) } } @@ -268,19 +300,22 @@ macro_rules! spawn_with_child_tests { impl CommandWrapper for FailWrap { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &CommandWrap, - ) -> io::Result> { - match self.0.lock().unwrap().take() { + ) -> io::Result> { + let failure = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + match failure { Some(WrapFailure::Error(identity)) => { - let _ = child; Err(io::Error::other(WrapIdentityError(identity))) } Some(WrapFailure::Panic(identity)) => { - let _ = child; panic_any(WrapIdentityPanic(identity)); } - None => Ok(child), + None => Ok(None), } } } @@ -339,11 +374,11 @@ macro_rules! spawn_with_child_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.visit(Phase::Wrap)?; - Ok(child) + Ok(None) } } @@ -368,25 +403,91 @@ macro_rules! spawn_with_child_tests { environment: (&str, &str), timeout: Duration, ) -> (Output, bool) { + const REAP_TIMEOUT: Duration = Duration::from_secs(5); + struct ChildGuard(Option); + impl ChildGuard { + fn terminate_and_reap(&mut self) -> io::Result { + let child = self + .0 + .as_mut() + .expect("the guarded subprocess remains available"); + match child.kill() { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let deadline = Instant::now() + REAP_TIMEOUT; + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "isolated wrapping subprocess was not reaped after termination", + )); + } + sleep(Duration::from_millis(5)); + } + } + } + impl Drop for ChildGuard { fn drop(&mut self) { - if let Some(mut child) = self.0.take() { - let _ = child.kill(); - let _ = child.wait(); + if self.0.is_some() { + let _ = self.terminate_and_reap(); } } } - let child = std::process::Command::new(std::env::current_exe().unwrap()) + fn drain_pipe( + mut pipe: impl Read + Send + 'static, + ) -> mpsc::Receiver>> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.read_to_end(&mut bytes).map(|_| bytes); + let _ = sender.send(result); + }); + receiver + } + + fn collect_output( + status: ExitStatus, + stdout: mpsc::Receiver>>, + stderr: mpsc::Receiver>>, + ) -> Output { + let deadline = Instant::now() + REAP_TIMEOUT; + let receive = |receiver: mpsc::Receiver>>, label| { + let remaining = deadline.saturating_duration_since(Instant::now()); + receiver + .recv_timeout(remaining) + .unwrap_or_else(|error| { + panic!("did not drain isolated {label}: {error}") + }) + .unwrap_or_else(|error| { + panic!("could not read isolated {label}: {error}") + }) + }; + Output { + status, + stdout: receive(stdout, "stdout"), + stderr: receive(stderr, "stderr"), + } + } + + let mut spawned = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", test_name, "--nocapture"]) .env(environment.0, environment.1) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("start isolated wrapping regression"); - let mut child = ChildGuard(Some(child)); + let stdout = drain_pipe(spawned.stdout.take().expect("capture isolated stdout")); + let stderr = drain_pipe(spawned.stderr.take().expect("capture isolated stderr")); + let mut child = ChildGuard(Some(spawned)); let deadline = Instant::now() + timeout; loop { let status = child @@ -395,22 +496,16 @@ macro_rules! spawn_with_child_tests { .expect("the subprocess remains guarded while polling") .try_wait() .expect("poll isolated wrapping regression"); - if status.is_some() { - let output = child - .0 - .take() - .expect("take completed subprocess") - .wait_with_output() - .expect("collect isolated wrapping output"); - return (output, false); + if let Some(status) = status { + child.0.take(); + return (collect_output(status, stdout, stderr), false); } if Instant::now() >= deadline { - let mut expired = child.0.take().expect("take expired subprocess"); - let _ = expired.kill(); - let output = expired - .wait_with_output() - .expect("reap expired wrapping subprocess"); - return (output, true); + let status = child + .terminate_and_reap() + .expect("terminate and reap expired wrapping subprocess"); + child.0.take(); + return (collect_output(status, stdout, stderr), true); } sleep(Duration::from_millis(5)); } @@ -760,6 +855,9 @@ spawn_with_child_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::ChildWrapperLayer, + process_wrap::std::ChildWrapperSlots, + process_wrap::std::PendingChildWrapper, None ); @@ -770,6 +868,9 @@ spawn_with_child_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ChildWrapperLayer, + process_wrap::tokio::ChildWrapperSlots, + process_wrap::tokio::PendingChildWrapper, Some( tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index a95da9a..4d7dd0d 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -126,11 +126,11 @@ struct FailWrapOnce { impl CommandWrapper for FailWrapOnce { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.failed { - return Ok(child); + return Ok(None); } self.failed = true; match self.failure { @@ -151,22 +151,34 @@ enum FailureHook { #[derive(Debug)] struct FailFinalizationChild { - inner: Box, + inner: Option>, failure: Failure, hook: FailureHook, } +impl ChildWrapperLayer for FailFinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for FailFinalizationChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed finalization layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed finalization layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed finalization layer owns its child") } fn finalize_spawn_layer(&mut self) -> Result<()> { @@ -207,7 +219,6 @@ impl ChildWrapper for FailFinalizationChild { struct FailAfterDescendant { failure: Failure, hook: FailureHook, - unwrap_child: bool, pid_file: PathBuf, guard: Arc>>, } @@ -258,11 +269,11 @@ impl CommandWrapper for FailAfterDescendant { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.hook == FailureHook::PostSpawn { - return Ok(child); + return Ok(None); } resume_process_threads(child.id())?; self.observe_descendant()?; @@ -272,16 +283,11 @@ impl CommandWrapper for FailAfterDescendant { | FailureHook::DisarmSpawnCleanup | FailureHook::DisarmJobObject ) { - return Ok(Box::new(FailFinalizationChild { - inner: child, + return Ok(Some(PendingChildWrapper::new(FailFinalizationChild { + inner: None, failure: self.failure, hook: self.hook, - })); - } - if self.unwrap_child { - drop(child.into_inner()); - } else { - drop(child); + }))); } self.fail() } @@ -298,9 +304,9 @@ struct ObserveDescendant { impl CommandWrapper for ObserveDescendant { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { resume_process_threads(child.id())?; let deadline = Instant::now() + EXIT_TIMEOUT; let pid = loop { @@ -318,7 +324,7 @@ impl CommandWrapper for ObserveDescendant { std::thread::sleep(Duration::from_millis(10)); }; *self.guard.lock().unwrap() = Some(ProcessGuard::open(pid)?); - Ok(child) + Ok(None) } } @@ -531,19 +537,18 @@ fn lifecycle_descendant_parent() { #[test] fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let cases = [ - (FailureHook::PostSpawn, false, false), - (FailureHook::WrapChild, false, false), - (FailureHook::WrapChild, true, false), - (FailureHook::WrapChild, true, true), - (FailureHook::FinalizeSpawn, false, false), - (FailureHook::FinalizeSpawn, true, false), - (FailureHook::DisarmSpawnCleanup, false, false), - (FailureHook::DisarmSpawnCleanup, true, false), - (FailureHook::DisarmJobObject, false, false), - (FailureHook::DisarmJobObject, true, false), + (FailureHook::PostSpawn, false), + (FailureHook::WrapChild, false), + (FailureHook::WrapChild, true), + (FailureHook::FinalizeSpawn, false), + (FailureHook::FinalizeSpawn, true), + (FailureHook::DisarmSpawnCleanup, false), + (FailureHook::DisarmSpawnCleanup, true), + (FailureHook::DisarmJobObject, false), + (FailureHook::DisarmJobObject, true), ]; for failure in [Failure::Error, Failure::Panic] { - for (hook, job_first, unwrap_child) in cases { + for (hook, job_first) in cases { let pid_file = descendant_pid_file()?; let guard = Arc::new(Mutex::new(None)); let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { @@ -554,7 +559,6 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let fail = FailAfterDescendant { failure, hook, - unwrap_child, pid_file: pid_file.clone(), guard: Arc::clone(&guard), }; diff --git a/tests/std_windows/process_handle.rs b/tests/std_windows/process_handle.rs index 5898fa5..b26f083 100644 --- a/tests/std_windows/process_handle.rs +++ b/tests/std_windows/process_handle.rs @@ -8,7 +8,7 @@ use std::{ path::{Path, PathBuf}, process::{Command, Command as StdCommand, ExitStatus}, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc, }, @@ -88,26 +88,79 @@ fn opaque_child() -> ( ) } +#[derive(Debug)] +struct NoopTransaction; + +impl SpawnTransaction for NoopTransaction { + fn commit(&mut self) -> Result<()> { + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct OpaqueProvider(Mutex>>); + +impl SpawnProvider for OpaqueProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let child = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("the opaque provider is used once"); + Ok(ProviderProduct::new(child, Box::new(NoopTransaction))) + } +} + +#[derive(Debug)] +struct OpaqueProviderWrapper(OpaqueProvider); + +impl CommandWrapper for OpaqueProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + #[derive(Debug)] struct TransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed transparent layer owns its child") } fn process_handle(&self) -> Option> { - self.inner.process_handle() + self.inner().process_handle() } } @@ -117,29 +170,43 @@ struct Transparent; impl CommandWrapper for Transparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(TransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(TransparentChild { + inner: None, + }))) } } #[derive(Debug)] struct LegacyTransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for LegacyTransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for LegacyTransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed legacy layer owns its child") } } @@ -254,28 +321,42 @@ struct LegacyTransparent; impl CommandWrapper for LegacyTransparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(LegacyTransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyTransparentChild { + inner: None, + }))) } } #[repr(transparent)] #[derive(Debug)] -struct LegacyInlineChild(std::process::Child); +struct LegacyInlineChild(Option>); + +impl ChildWrapperLayer for LegacyInlineChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for LegacyInlineChild { fn inner(&self) -> &dyn ChildWrapper { - &self.0 + self.0 + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - &mut self.0 + self.0 + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { - Box::new(self.0) + fn into_inner(mut self: Box) -> Box { + self.0 + .take() + .expect("an installed legacy layer owns its child") } } @@ -285,13 +366,10 @@ struct LegacyInline; impl CommandWrapper for LegacyInline { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - // SAFETY: `LegacyInline` adds no cleanup or supervision state. - let child = unsafe { child.try_into_inner_child() } - .map_err(|_| std::io::Error::other("legacy inline wrapper expected a native child"))?; - Ok(Box::new(LegacyInlineChild(child))) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyInlineChild(None)))) } } @@ -317,8 +395,13 @@ fn opaque_child_defaults_to_no_process_handle_without_traversing() { #[test] fn job_object_rejects_an_opaque_child_and_cleans_it_up() { let (child, inner_calls, killed, waited) = opaque_child(); - let core = CommandWrap::with_new("cmd.exe", |_| {}); - let error = JobObject.wrap_child(child, &core).unwrap_err(); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(OpaqueProviderWrapper(OpaqueProvider(Mutex::new(Some( + child, + ))))); + let error = command.spawn().unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); assert_eq!(inner_calls.load(Ordering::SeqCst), 1); @@ -334,7 +417,7 @@ fn transparent_child_delegates_the_native_process_handle() -> Result<()> { .expect("a native child exposes its process handle") .as_raw_handle(); let mut child: Box = Box::new(TransparentChild { - inner: Box::new(native), + inner: Some(Box::new(native)), }); let delegated_handle = child .process_handle() @@ -371,10 +454,18 @@ fn job_object_finds_terminal_capabilities_below_multiple_legacy_layers() -> Resu resumes: Arc::clone(&resumes), }); let child: Box = Box::new(LegacyTransparentChild { - inner: Box::new(LegacyTransparentChild { inner: terminal }), + inner: Some(Box::new(LegacyTransparentChild { + inner: Some(terminal), + })), }); - let core = CommandWrap::new("cmd.exe"); - let mut child = JobObject.wrap_child(child, &core)?; + let mut child = Some(child); + let mut command = CommandWrap::new("cmd.exe"); + command.wrap(JobObject); + let mut child = command.spawn_with_child(|_| { + Ok(child + .take() + .expect("the custom spawner is invoked exactly once")) + })?; assert_eq!(resumes.load(Ordering::SeqCst), 1); assert!(child.process_handle().is_some()); diff --git a/tests/tokio_pty_provider.rs b/tests/tokio_pty_provider.rs index 2c808dc..833f930 100644 --- a/tests/tokio_pty_provider.rs +++ b/tests/tokio_pty_provider.rs @@ -24,8 +24,8 @@ use std::{ use nix::libc; use process_wrap::tokio::{ - ChildWrapper, Command, CommandWrapper, ProviderProduct, Pty, PtyController, PtySize, - SpawnAttempt, SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrapper, + PendingChildWrapper, ProviderProduct, Pty, PtyController, PtySize, SpawnAttempt, SpawnProvider, }; use tokio::{io::AsyncReadExt, time::timeout}; @@ -69,19 +69,31 @@ async fn output(child: &mut dyn ChildWrapper, controller: PtyController) -> io:: } #[derive(Debug)] -struct TransparentChild(Box); +struct TransparentChild(Option>); + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed transparent layer owns its child") } } @@ -91,27 +103,39 @@ struct Transparent; impl CommandWrapper for Transparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { - Ok(Box::new(TransparentChild(child))) + ) -> io::Result> { + Ok(Some(PendingChildWrapper::new(TransparentChild(None)))) } } #[derive(Debug)] -struct SecondTransparentChild(Box); +struct SecondTransparentChild(Option>); + +impl ChildWrapperLayer for SecondTransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for SecondTransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed transparent layer owns its child") } } @@ -121,10 +145,10 @@ struct SecondTransparent; impl CommandWrapper for SecondTransparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { - Ok(Box::new(SecondTransparentChild(child))) + ) -> io::Result> { + Ok(Some(PendingChildWrapper::new(SecondTransparentChild(None)))) } } @@ -245,15 +269,15 @@ struct InspectWrap { impl CommandWrapper for InspectWrap { fn wrap_child( &mut self, - mut child: Box, + child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { + ) -> io::Result> { assert!(child.take_pty_controller().is_none()); *self .called .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = true; - Ok(child) + Ok(None) } } @@ -577,14 +601,14 @@ impl CommandWrapper for FailAfterSpawn { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { + ) -> io::Result> { if matches!(self.stage, Stage::WrapChild) { - self.record(child.as_ref()); + self.record(child); self.fail() } else { - Ok(child) + Ok(None) } } } diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index adb369f..f4ebee0 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -26,8 +26,9 @@ use process_wrap::tokio::JobObject; #[cfg(feature = "kill-on-drop")] use process_wrap::tokio::KillOnDrop; use process_wrap::tokio::{ - ChildWrapper, Command, CommandWrapper, ProviderProduct, Pty, PtyController, PtyOutput, PtySize, - SpawnAttempt, SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrapper, + PendingChildWrapper, ProviderProduct, Pty, PtyController, PtyOutput, PtySize, SpawnAttempt, + SpawnProvider, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -1229,22 +1230,34 @@ fn fail_lifecycle(failure: LifecycleFailure) -> io::Result { #[derive(Debug)] struct FailFinalizationChild { - inner: Box, + inner: Option>, failure: LifecycleFailure, stage: LifecycleStage, } +impl ChildWrapperLayer for FailFinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for FailFinalizationChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed finalization layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed finalization layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed finalization layer owns its child") } fn finalize_spawn_layer(&mut self) -> io::Result<()> { @@ -1308,22 +1321,22 @@ impl CommandWrapper for FailLifecycleOnce { fn wrap_child( &mut self, - mut child: Box, + child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { + ) -> io::Result> { if self.failed || self.stage == LifecycleStage::PostSpawn { - return Ok(child); + return Ok(None); } - self.capture(child.as_mut())?; + self.capture(child)?; self.failed = true; if self.stage == LifecycleStage::WrapChild { fail_lifecycle(self.failure) } else { - Ok(Box::new(FailFinalizationChild { - inner: child, + Ok(Some(PendingChildWrapper::new(FailFinalizationChild { + inner: None, failure: self.failure, stage: self.stage, - })) + }))) } } } @@ -1527,19 +1540,31 @@ async fn non_pty_children_have_no_controller() -> io::Result<()> { } #[derive(Debug)] -struct TransparentChild(Box); +struct TransparentChild(Option>); + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed transparent layer owns its child") } } @@ -1563,12 +1588,12 @@ impl CommandWrapper for ObserveControllerLifecycle { fn wrap_child( &mut self, - mut child: Box, + child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { + ) -> io::Result> { assert!(child.take_pty_controller().is_none()); self.wrap_child.store(true, Ordering::SeqCst); - Ok(Box::new(TransparentChild(child))) + Ok(Some(PendingChildWrapper::new(TransparentChild(None)))) } } diff --git a/tests/tokio_windows/creation_flags_job_object.rs b/tests/tokio_windows/creation_flags_job_object.rs index 5bd43f3..3314cba 100644 --- a/tests/tokio_windows/creation_flags_job_object.rs +++ b/tests/tokio_windows/creation_flags_job_object.rs @@ -123,11 +123,11 @@ struct FailWrapOnce { impl CommandWrapper for FailWrapOnce { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.failed { - return Ok(child); + return Ok(None); } self.failed = true; match self.failure { @@ -148,22 +148,34 @@ enum FailureHook { #[derive(Debug)] struct FailFinalizationChild { - inner: Box, + inner: Option>, failure: Failure, hook: FailureHook, } +impl ChildWrapperLayer for FailFinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for FailFinalizationChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed finalization layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed finalization layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed finalization layer owns its child") } fn finalize_spawn_layer(&mut self) -> Result<()> { @@ -204,7 +216,6 @@ impl ChildWrapper for FailFinalizationChild { struct FailAfterDescendant { failure: Failure, hook: FailureHook, - unwrap_child: bool, pid_file: PathBuf, guard: Arc>>, } @@ -261,13 +272,13 @@ impl CommandWrapper for FailAfterDescendant { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.hook == FailureHook::PostSpawn { - return Ok(child); + return Ok(None); } - resume_process_threads(child_id(child.as_ref())?)?; + resume_process_threads(child_id(child)?)?; self.observe_descendant()?; if matches!( self.hook, @@ -275,16 +286,11 @@ impl CommandWrapper for FailAfterDescendant { | FailureHook::DisarmSpawnCleanup | FailureHook::DisarmJobObject ) { - return Ok(Box::new(FailFinalizationChild { - inner: child, + return Ok(Some(PendingChildWrapper::new(FailFinalizationChild { + inner: None, failure: self.failure, hook: self.hook, - })); - } - if self.unwrap_child { - drop(child.into_inner()); - } else { - drop(child); + }))); } self.fail() } @@ -453,19 +459,18 @@ fn lifecycle_descendant_parent() { #[tokio::test] async fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let cases = [ - (FailureHook::PostSpawn, false, false), - (FailureHook::WrapChild, false, false), - (FailureHook::WrapChild, true, false), - (FailureHook::WrapChild, true, true), - (FailureHook::FinalizeSpawn, false, false), - (FailureHook::FinalizeSpawn, true, false), - (FailureHook::DisarmSpawnCleanup, false, false), - (FailureHook::DisarmSpawnCleanup, true, false), - (FailureHook::DisarmJobObject, false, false), - (FailureHook::DisarmJobObject, true, false), + (FailureHook::PostSpawn, false), + (FailureHook::WrapChild, false), + (FailureHook::WrapChild, true), + (FailureHook::FinalizeSpawn, false), + (FailureHook::FinalizeSpawn, true), + (FailureHook::DisarmSpawnCleanup, false), + (FailureHook::DisarmSpawnCleanup, true), + (FailureHook::DisarmJobObject, false), + (FailureHook::DisarmJobObject, true), ]; for failure in [Failure::Error, Failure::Panic] { - for (hook, job_first, unwrap_child) in cases { + for (hook, job_first) in cases { let pid_file = descendant_pid_file()?; let guard = Arc::new(Mutex::new(None)); let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { @@ -476,7 +481,6 @@ async fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let fail = FailAfterDescendant { failure, hook, - unwrap_child, pid_file: pid_file.clone(), guard: Arc::clone(&guard), }; diff --git a/tests/tokio_windows/process_handle.rs b/tests/tokio_windows/process_handle.rs index 0c4eca2..92d6a91 100644 --- a/tests/tokio_windows/process_handle.rs +++ b/tests/tokio_windows/process_handle.rs @@ -7,7 +7,7 @@ use std::{ pin::Pin, process::{Command as StdCommand, ExitStatus}, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, }, time::Instant, @@ -72,26 +72,79 @@ fn opaque_child() -> (Box, Arc, Arc) ) } +#[derive(Debug)] +struct NoopTransaction; + +impl SpawnTransaction for NoopTransaction { + fn commit(&mut self) -> Result<()> { + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct OpaqueProvider(Mutex>>); + +impl SpawnProvider for OpaqueProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let child = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("the opaque provider is used once"); + Ok(ProviderProduct::new(child, Box::new(NoopTransaction))) + } +} + +#[derive(Debug)] +struct OpaqueProviderWrapper(OpaqueProvider); + +impl CommandWrapper for OpaqueProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + #[derive(Debug)] struct TransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed transparent layer owns its child") } fn process_handle(&self) -> Option> { - self.inner.process_handle() + self.inner().process_handle() } } @@ -101,29 +154,43 @@ struct Transparent; impl CommandWrapper for Transparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(TransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(TransparentChild { + inner: None, + }))) } } #[derive(Debug)] struct LegacyTransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for LegacyTransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for LegacyTransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed legacy layer owns its child") } } @@ -244,28 +311,42 @@ struct LegacyTransparent; impl CommandWrapper for LegacyTransparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(LegacyTransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyTransparentChild { + inner: None, + }))) } } #[repr(transparent)] #[derive(Debug)] -struct LegacyInlineChild(tokio::process::Child); +struct LegacyInlineChild(Option>); + +impl ChildWrapperLayer for LegacyInlineChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for LegacyInlineChild { fn inner(&self) -> &dyn ChildWrapper { - &self.0 + self.0 + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - &mut self.0 + self.0 + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { - Box::new(self.0) + fn into_inner(mut self: Box) -> Box { + self.0 + .take() + .expect("an installed legacy layer owns its child") } } @@ -275,13 +356,10 @@ struct LegacyInline; impl CommandWrapper for LegacyInline { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - // SAFETY: `LegacyInline` adds no cleanup or supervision state. - let child = unsafe { child.try_into_inner_child() } - .map_err(|_| std::io::Error::other("legacy inline wrapper expected a native child"))?; - Ok(Box::new(LegacyInlineChild(child))) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyInlineChild(None)))) } } @@ -307,8 +385,13 @@ fn opaque_child_defaults_to_no_process_handle_without_traversing() { #[test] fn job_object_rejects_an_opaque_child_and_cleans_it_up() { let (child, inner_calls, killed) = opaque_child(); - let core = CommandWrap::with_new("cmd.exe", |_| {}); - let error = JobObject.wrap_child(child, &core).unwrap_err(); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(OpaqueProviderWrapper(OpaqueProvider(Mutex::new(Some( + child, + ))))); + let error = command.spawn().unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); assert_eq!(inner_calls.load(Ordering::SeqCst), 1); @@ -323,7 +406,7 @@ async fn transparent_child_delegates_the_native_process_handle() -> Result<()> { .expect("a native child exposes its process handle") .as_raw_handle(); let mut child: Box = Box::new(TransparentChild { - inner: Box::new(native), + inner: Some(Box::new(native)), }); let delegated_handle = child .process_handle() @@ -360,10 +443,18 @@ async fn job_object_finds_terminal_capabilities_below_multiple_legacy_layers() - resumes: Arc::clone(&resumes), }); let child: Box = Box::new(LegacyTransparentChild { - inner: Box::new(LegacyTransparentChild { inner: terminal }), + inner: Some(Box::new(LegacyTransparentChild { + inner: Some(terminal), + })), }); - let core = CommandWrap::new("cmd.exe"); - let mut child = JobObject.wrap_child(child, &core)?; + let mut child = Some(child); + let mut command = CommandWrap::new("cmd.exe"); + command.wrap(JobObject); + let mut child = command.spawn_with_child(|_| { + Ok(child + .take() + .expect("the custom spawner is invoked exactly once")) + })?; assert_eq!(resumes.load(Ordering::SeqCst), 1); assert!(child.process_handle().is_some()); diff --git a/tests/wrapper_hooks.rs b/tests/wrapper_hooks.rs index 09e5cca..0766ddd 100644 --- a/tests/wrapper_hooks.rs +++ b/tests/wrapper_hooks.rs @@ -8,6 +8,7 @@ macro_rules! wrapper_hook_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $pending_child_wrapper:path, $runtime:expr ) => { mod $module { @@ -23,6 +24,7 @@ macro_rules! wrapper_hook_tests { use $child_wrapper as ChildWrapper; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; use $spawn_attempt as SpawnAttempt; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); @@ -76,11 +78,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.observe(Phase::Wrap, core); - Ok(child) + Ok(None) } } @@ -119,11 +121,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.observe(Phase::Wrap, core); - Ok(child) + Ok(None) } } @@ -191,11 +193,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.visit(Phase::Wrap, core)?; - Ok(child) + Ok(None) } } @@ -234,11 +236,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.observe(Phase::Wrap, core); - Ok(child) + Ok(None) } } @@ -427,6 +429,7 @@ wrapper_hook_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::PendingChildWrapper, None ); @@ -438,6 +441,7 @@ wrapper_hook_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::PendingChildWrapper, Some( tokio::runtime::Builder::new_current_thread() .enable_all() From 7fa0f13e913ed946a61ac9f6255ce3dde5cae54b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 10:52:56 +1300 Subject: [PATCH 060/114] docs(lifecycle): describe detached child layers Co-Authored-By: Claude Code --- README.md | 20 +++++++----- src/command.rs | 9 ++++-- src/lib.rs | 87 ++++++++++++++++++++++++++++++-------------------- 3 files changed, 72 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 73f4efb..9e2b013 100644 --- a/README.md +++ b/README.md @@ -353,10 +353,11 @@ The trait provides extension or hook points into the lifecycle of a `Command`: child with no native child value. Changing command settings on `attempt` at this point cannot configure the already-created child. -- **`fn wrap_child(&mut self, child: Box, command: &Command) -> io::Result>`** - is called after all `post_spawn()` hooks. If your wrapper needs to override child methods, create - your own `ChildWrapper` layer and return it here. Child wraps run in registration order, so - `.wrap(Foo).wrap(Bar)` produces an outer `Bar(Foo(child))`. +- **`fn wrap_child(&mut self, child: &mut dyn ChildWrapper, command: &Command) -> io::Result>`** + is called after all `post_spawn()` hooks. The current child stays borrowed from process-wrap. To + override child methods, return a `PendingChildWrapper` containing a `ChildWrapperLayer` whose child + slot is empty; process-wrap installs the child after the callback returns successfully. Child wraps + run in registration order, so `.wrap(Foo).wrap(Bar)` produces an outer `Bar(Foo(child))`. - **`fn spawn_provider(&self) -> Option<&dyn SpawnProvider>`** exposes an alternate transport owned by this wrapper. A provider exposed during selection must remain available throughout the lifecycle; @@ -389,10 +390,13 @@ Validation must reject unsupported portable policy before allocating operating-s `spawn` returns a child satisfying the frontend's complete `ChildWrapper` contract together with a fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. A later hook, wrapper, pre-commit child step, or commit error or unwinding panic causes best-effort -rollback while preserving the original failure. Successful commit ends failed-spawn rollback. On -Windows, the sole JobObject owner remains authoritative until it disarms after commit. Process-wrap -then transfers the committed transaction residue in a private transparent layer with the returned -child. On a successful spawn, arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue must +rollback while preserving the original failure. After capturing that failure, process-wrap first +resolves transaction cleanup, then disposes any detached child layer, the child chain, and each +Windows prepared value independently. Secondary cleanup panic payloads are quarantined without +inspection or destruction. Successful commit ends failed-spawn rollback. On Windows, the sole +JobObject owner remains authoritative until it disarms after commit. Process-wrap then transfers the +committed transaction residue in a private transparent layer with the returned child. On a successful +spawn, arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue must retain no armed cleanup or independent process, terminal, controller, handle, pseudoconsole, or other liveness resource. diff --git a/src/command.rs b/src/command.rs index a80f5c9..5945477 100644 --- a/src/command.rs +++ b/src/command.rs @@ -533,8 +533,13 @@ impl fmt::Debug for CommandState { /// Cleanup and finalization owned by an alternate spawn provider. /// /// A provider returns a fresh, armed transaction with every child it successfully creates. The -/// transaction must own its cleanup resources independently of the child wrapper chain, because a -/// failing child wrapper may already have consumed or dropped that chain. +/// transaction must own its cleanup resources independently of the child wrapper chain, so rollback +/// authority remains available while process-wrap disposes other lifecycle values separately. +/// +/// After capturing a lifecycle error or unwinding panic, process-wrap first rolls back an armed +/// transaction or disposes committed residue, then disposes any detached child layer, the child chain, +/// and each Windows prepared-state value independently. Every secondary cleanup panic is quarantined +/// without inspecting or destroying its payload before the original failure is returned or resumed. /// /// On Windows, process-wrap calls [`commit`](SpawnTransaction::commit) only after every public /// post-spawn and child-wrapping hook and the complete pre-commit child phase succeed. That phase diff --git a/src/lib.rs b/src/lib.rs index 4d46bd3..08e2e60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -248,10 +248,12 @@ //! child with no native value. Changing command settings on `attempt` here cannot configure the //! already-created child. By default does nothing. //! -//! - **`fn wrap_child(&mut self, child: Box, command: &Command)`** is called after -//! all `post_spawn()` hooks. If your wrapper needs to override child methods, create and return its -//! own `ChildWrapper` layer. Child wraps run in registration order, so -//! `.wrap(Foo).wrap(Bar)` produces an outer `Bar(Foo(child))`. By default returns the input child. +//! - **`fn wrap_child(&mut self, child: &mut dyn ChildWrapper, command: &Command) -> io::Result>`** +//! is called after all `post_spawn()` hooks. The current child remains borrowed from process-wrap. +//! To override child methods, return a `PendingChildWrapper` containing a `ChildWrapperLayer` whose +//! child slot is empty; process-wrap installs the child after the callback returns successfully. +//! Child wraps run in registration order, so `.wrap(Foo).wrap(Bar)` produces an outer +//! `Bar(Foo(child))`. By default returns `None` and leaves the child unchanged. //! //! - **`fn spawn_provider(&self) -> Option<&dyn SpawnProvider>`** exposes an alternate transport owned //! by this wrapper. A provider exposed during selection must remain available throughout the spawn @@ -283,12 +285,15 @@ //! returns a child satisfying the frontend's complete `ChildWrapper` contract and a fresh, armed //! `SpawnTransaction` which owns cleanup independently of the child chain. A later public hook, //! wrapper, pre-commit child step, or commit error or unwinding panic causes best-effort rollback -//! while preserving the original failure. Successful commit ends failed-spawn rollback. On Windows, -//! the sole JobObject owner remains authoritative until it disarms after commit. Process-wrap then -//! transfers the committed transaction residue in a private transparent layer with the returned -//! child. On a successful spawn, arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue -//! must retain no armed cleanup or independent process, terminal, controller, handle, -//! pseudoconsole, or other liveness resource. +//! while preserving the original failure. After capturing that failure, process-wrap first resolves +//! transaction cleanup, then disposes any detached child layer, the child chain, and each Windows +//! prepared value independently. Secondary cleanup panic payloads are quarantined without inspection +//! or destruction. Successful commit ends failed-spawn rollback. On Windows, the sole JobObject owner +//! remains authoritative until it disarms after commit. Process-wrap then transfers the committed +//! transaction residue in a private transparent layer with the returned child. On a successful spawn, +//! arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue must retain no +//! armed cleanup or independent process, terminal, controller, handle, pseudoconsole, or other +//! liveness resource. //! //! Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic //! containment apply only to unwinding panics. With `panic=abort`, the process terminates before @@ -356,7 +361,8 @@ //! # #[cfg(feature = "std")] //! # mod example { //! # use process_wrap::std::{ -//! # ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper, SpawnAttempt, +//! # ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command as WrappedCommand, +//! # CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, //! # }; //! # use std::{ //! # fs::File, @@ -397,45 +403,51 @@ //! //! fn wrap_child( //! &mut self, -//! child: Box, +//! _child: &mut dyn ChildWrapper, //! _core: &CommandWrap, -//! ) -> io::Result> { +//! ) -> io::Result> { //! let wrapped_child = LogFileChild { -//! inner: child, +//! inner: None, //! thread: mem::take(&mut self.thread), //! }; -//! Ok(Box::new(wrapped_child)) +//! Ok(Some(PendingChildWrapper::new(wrapped_child))) //! } //! } //! //! #[derive(Debug)] //! struct LogFileChild { -//! inner: Box, +//! inner: Option>, //! thread: Option>, //! } //! +//! impl ChildWrapperLayer for LogFileChild { +//! fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { +//! ChildWrapperSlots::new(&mut self.inner) +//! } +//! } +//! //! impl ChildWrapper for LogFileChild { //! fn inner(&self) -> &dyn ChildWrapper { -//! &*self.inner +//! self.inner.as_deref().expect("an installed log layer owns its child") //! } //! //! fn inner_mut(&mut self) -> &mut dyn ChildWrapper { -//! &mut *self.inner +//! self.inner.as_deref_mut().expect("an installed log layer owns its child") //! } //! -//! fn into_inner(self: Box) -> Box { -//! self.inner +//! fn into_inner(mut self: Box) -> Box { +//! self.inner.take().expect("an installed log layer owns its child") //! } //! //! #[cfg(windows)] //! fn process_handle( //! &self, //! ) -> Option> { -//! self.inner.process_handle() +//! self.inner().process_handle() //! } //! //! fn wait(&mut self) -> io::Result { -//! let exit_status = self.inner.wait(); +//! let exit_status = self.inner_mut().wait(); //! //! if let Some(thread) = mem::take(&mut self.thread) { //! thread.join().unwrap(); @@ -464,7 +476,8 @@ //! # #[cfg(feature = "std")] //! # mod example { //! # use process_wrap::std::{ -//! # ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper, SpawnAttempt, +//! # ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command as WrappedCommand, +//! # CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, //! # }; //! # use std::{ //! # error::Error, @@ -507,45 +520,51 @@ //! # //! # fn wrap_child( //! # &mut self, -//! # child: Box, +//! # _child: &mut dyn ChildWrapper, //! # _core: &CommandWrap, -//! # ) -> io::Result> { +//! # ) -> io::Result> { //! # let wrapped_child = LogFileChild { -//! # inner: child, +//! # inner: None, //! # thread: mem::take(&mut self.thread), //! # }; -//! # Ok(Box::new(wrapped_child)) +//! # Ok(Some(PendingChildWrapper::new(wrapped_child))) //! # } //! # } //! # //! # #[derive(Debug)] //! # struct LogFileChild { -//! # inner: Box, +//! # inner: Option>, //! # thread: Option>, //! # } //! # +//! # impl ChildWrapperLayer for LogFileChild { +//! # fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { +//! # ChildWrapperSlots::new(&mut self.inner) +//! # } +//! # } +//! # //! # impl ChildWrapper for LogFileChild { //! # fn inner(&self) -> &dyn ChildWrapper { -//! # &*self.inner +//! # self.inner.as_deref().expect("an installed log layer owns its child") //! # } //! # //! # fn inner_mut(&mut self) -> &mut dyn ChildWrapper { -//! # &mut *self.inner +//! # self.inner.as_deref_mut().expect("an installed log layer owns its child") //! # } //! # -//! # fn into_inner(self: Box) -> Box { -//! # self.inner +//! # fn into_inner(mut self: Box) -> Box { +//! # self.inner.take().expect("an installed log layer owns its child") //! # } //! # //! # #[cfg(windows)] //! # fn process_handle( //! # &self, //! # ) -> Option> { -//! # self.inner.process_handle() +//! # self.inner().process_handle() //! # } //! # //! # fn wait(&mut self) -> io::Result { -//! # let exit_status = self.inner.wait(); +//! # let exit_status = self.inner_mut().wait(); //! # //! # if let Some(thread) = mem::take(&mut self.thread) { //! # thread.join().unwrap(); From bc029ebd8b06ba4f59938ed4853b35a10b8cc910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 11:37:38 +1300 Subject: [PATCH 061/114] test(windows): expose prepared custody escapes Co-Authored-By: Claude Code --- src/std/job_object.rs | 80 +++++++++- src/tokio/job_object.rs | 81 +++++++++- tests/spawn_provider.rs | 326 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 481 insertions(+), 6 deletions(-) diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 75d0bcb..3333ad8 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -381,8 +381,9 @@ mod tests { use crate::std::{ProviderProduct, SpawnProvider}; use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - TreePaths, arm_owner_failure, assert_tree_terminated, clear_owner_failure, - observe_descendant, publish_process_guards, + TreePaths, arm_extra_prepared_owner, arm_owner_failure, assert_tree_terminated, + clear_extra_prepared_owners, clear_owner_failure, observe_descendant, + publish_process_guards, }; use super::*; @@ -478,6 +479,81 @@ mod tests { } } + fn topology_command( + provider: bool, + paths: &TreePaths, + state: &Arc, + ) -> Result { + let mut command = if provider { + CommandWrap::new("provider-owned-program") + } else { + CommandWrap::from(paths.direct_command()?) + }; + command.wrap(JobObject).wrap(ObserveTree { + paths: paths.clone(), + state: Arc::clone(state), + }); + if provider { + command.wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(state), + })); + } + Ok(command) + } + + #[test] + fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_extra_prepared_owner(); + + let error = command + .spawn() + .expect_err("an unexpected prepared owner must fail the lifecycle"); + let (injection_pending, retained_owners) = clear_extra_prepared_owners(); + assert!( + !injection_pending, + "the installation must consume the injection" + ); + assert_eq!(retained_owners, 1); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "prepared child state has an unexpected custody topology" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 0); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait()?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + } + Ok(()) + } + #[test] fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> { for was_panic in [false, true] { diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 5be02fa..2941d8e 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -397,8 +397,9 @@ mod tests { use crate::tokio::{ProviderProduct, SpawnProvider}; use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - TreePaths, arm_owner_failure, assert_tree_terminated, clear_owner_failure, - observe_descendant, publish_process_guards, + TreePaths, arm_extra_prepared_owner, arm_owner_failure, assert_tree_terminated, + clear_extra_prepared_owners, clear_owner_failure, observe_descendant, + publish_process_guards, }; use super::*; @@ -500,6 +501,82 @@ mod tests { } } + fn topology_command( + provider: bool, + paths: &TreePaths, + state: &Arc, + ) -> Result { + let mut command = if provider { + CommandWrap::new("provider-owned-program") + } else { + CommandWrap::from(tokio::process::Command::from(paths.direct_command()?)) + }; + command.wrap(JobObject).wrap(ObserveTree { + paths: paths.clone(), + state: Arc::clone(state), + }); + if provider { + command.wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(state), + })); + } + Ok(command) + } + + #[tokio::test(flavor = "current_thread")] + async fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() + -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_extra_prepared_owner(); + + let error = command + .spawn() + .expect_err("an unexpected prepared owner must fail the lifecycle"); + let (injection_pending, retained_owners) = clear_extra_prepared_owners(); + assert!( + !injection_pending, + "the installation must consume the injection" + ); + assert_eq!(retained_owners, 1); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "prepared child state has an unexpected custody topology" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 0); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait().await?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> { diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index cbdb4d8..928f75d 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -11,6 +11,7 @@ macro_rules! spawn_provider_tests { $child_wrapper_slots:path, $pending_child_wrapper:path, $prepared_child:path, + $prepared_child_ref:path, $provider_product:path, $spawn_provider:path, $runtime:expr @@ -42,6 +43,8 @@ macro_rules! spawn_provider_tests { use $pending_child_wrapper as PendingChildWrapper; #[cfg(windows)] use $prepared_child as PreparedChild; + #[cfg(windows)] + use $prepared_child_ref as PreparedChildRef; use $provider_product as ProviderProduct; use $spawn_attempt as SpawnAttempt; use $spawn_provider as SpawnProvider; @@ -401,7 +404,8 @@ macro_rules! spawn_provider_tests { #[cfg(windows)] impl ChildWrapperLayer for PreparedLayer { fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { - ChildWrapperSlots::new(&mut self.inner).with_prepared(&mut self.prepared) + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) } } @@ -454,7 +458,7 @@ macro_rules! spawn_provider_tests { fn wrap_prepared_child( &mut self, _child: &mut dyn ChildWrapper, - prepared: Option<&PreparedChild>, + prepared: Option>, _command: &CommandWrap, ) -> io::Result> { Ok(prepared.map(|_| PendingChildWrapper::new(PreparedLayer::detached()))) @@ -468,6 +472,216 @@ macro_rules! spawn_provider_tests { #[cfg(windows)] prepared_wrapper!(SecondPrepared, "second"); + #[cfg(windows)] + #[derive(Debug)] + struct PreparedGuard(Arc); + + #[cfg(windows)] + impl Drop for PreparedGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct OptionPreparedLayer { + inner: Option>, + prepared: Option, + } + + #[cfg(windows)] + impl OptionPreparedLayer { + fn detached() -> Self { + Self { + inner: None, + prepared: None, + } + } + } + + #[cfg(windows)] + impl ChildWrapperLayer for OptionPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::>(&mut self.prepared) + } + } + + #[cfg(windows)] + impl ChildWrapper for OptionPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed option-prepared layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed option-prepared layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed option-prepared layer owns its child") + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct OptionPrepared { + drops: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for OptionPrepared { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(Some(Box::new(Some(PreparedGuard(Arc::clone(&self.drops)))))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!( + prepared + .as_ref() + .is_some_and(PreparedChildRef::is::>), + "the wrapping callback receives only matching type metadata" + ); + Ok(Some(PendingChildWrapper::new( + OptionPreparedLayer::detached(), + ))) + } + } + + #[cfg(windows)] + #[derive(Clone, Copy, Debug)] + enum MalformedPreparedCase { + CallbackError, + CallbackPanic, + MissingSlot, + ExtraSlot, + WrongType, + InstallPanic, + } + + #[cfg(windows)] + #[derive(Debug)] + struct MalformedPreparedLayer { + inner: Option>, + prepared: Option, + case: MalformedPreparedCase, + } + + #[cfg(windows)] + impl ChildWrapperLayer for MalformedPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + match self.case { + MalformedPreparedCase::MissingSlot => ChildWrapperSlots::new(&mut self.inner), + MalformedPreparedCase::ExtraSlot => ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared), + MalformedPreparedCase::WrongType => ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared), + MalformedPreparedCase::InstallPanic => { + panic_any("prepared layer installation failed") + } + MalformedPreparedCase::CallbackError | MalformedPreparedCase::CallbackPanic => { + unreachable!("callback failures do not return a layer") + } + } + } + } + + #[cfg(windows)] + impl ChildWrapper for MalformedPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed malformed-test layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed malformed-test layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed malformed-test layer owns its child") + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct MalformedPrepared { + next: Option, + active: Option, + drops: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for MalformedPrepared { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + self.active = self.next.take(); + match self.active { + Some(MalformedPreparedCase::ExtraSlot) | None => Ok(None), + Some(_) => Ok(Some(Box::new(PreparedGuard(Arc::clone(&self.drops))))), + } + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + let Some(case) = self.active.take() else { + assert!(prepared.is_none()); + return Ok(None); + }; + if matches!(case, MalformedPreparedCase::ExtraSlot) { + assert!(prepared.is_none()); + } else { + assert!( + prepared + .as_ref() + .is_some_and(PreparedChildRef::is::) + ); + } + match case { + MalformedPreparedCase::CallbackError => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "prepared wrapping callback failed", + )), + MalformedPreparedCase::CallbackPanic => { + panic_any("prepared wrapping callback failed") + } + _ => Ok(Some(PendingChildWrapper::new(MalformedPreparedLayer { + inner: None, + prepared: None, + case, + }))), + } + } + } + #[cfg(windows)] #[derive(Debug)] struct FailPrepare(Mutex>); @@ -1990,6 +2204,112 @@ macro_rules! spawn_provider_tests { assert_eq!(shared.events(), successful_events("provider")); } + #[cfg(windows)] + #[test] + fn option_prepared_state_stays_owned_until_the_returned_child_is_dropped() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command.wrap(OptionPrepared { + drops: Arc::clone(&drops), + }); + + let child = command.spawn().expect("spawn with prepared state"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + + let child = command.spawn().expect("reuse command with prepared state"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + + #[cfg(windows)] + #[test] + fn malformed_prepared_layers_fail_atomically_and_allow_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for (case, expected, was_panic, expected_drops) in [ + ( + MalformedPreparedCase::CallbackError, + "prepared wrapping callback failed", + false, + 1, + ), + ( + MalformedPreparedCase::CallbackPanic, + "prepared wrapping callback failed", + true, + 1, + ), + ( + MalformedPreparedCase::MissingSlot, + "prepared child state requires an empty matching layer slot", + false, + 1, + ), + ( + MalformedPreparedCase::ExtraSlot, + "a prepared-state slot requires matching prepared child state", + false, + 0, + ), + ( + MalformedPreparedCase::WrongType, + "prepared child state does not match the layer slot type", + false, + 1, + ), + ( + MalformedPreparedCase::InstallPanic, + "prepared layer installation failed", + true, + 1, + ), + ] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command.wrap(MalformedPrepared { + next: Some(case), + active: None, + drops: Arc::clone(&drops), + }); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + if was_panic { + let payload = outcome.expect_err("prepared installation must panic"); + assert_eq!( + *payload + .downcast::<&'static str>() + .expect("the exact installation panic is preserved"), + expected + ); + } else { + let error = outcome + .expect("prepared installation must return its error") + .expect_err("malformed prepared ownership must fail"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), expected); + } + assert_eq!(drops.load(Ordering::SeqCst), expected_drops); + let events = shared.events(); + assert!(!events.contains(&Event::Commit)); + assert_eq!( + events.iter().filter(|event| **event == Event::Rollback).count(), + 1 + ); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + assert_eq!(drops.load(Ordering::SeqCst), expected_drops); + } + } + #[cfg(windows)] #[test] fn prepared_states_are_disposed_independently_after_the_primary_is_owned() { @@ -3373,6 +3693,7 @@ spawn_provider_tests!( process_wrap::std::ChildWrapperSlots, process_wrap::std::PendingChildWrapper, process_wrap::std::PreparedChild, + process_wrap::std::PreparedChildRef, process_wrap::std::ProviderProduct, process_wrap::std::SpawnProvider, None @@ -3389,6 +3710,7 @@ spawn_provider_tests!( process_wrap::tokio::ChildWrapperSlots, process_wrap::tokio::PendingChildWrapper, process_wrap::tokio::PreparedChild, + process_wrap::tokio::PreparedChildRef, process_wrap::tokio::ProviderProduct, process_wrap::tokio::SpawnProvider, Some( From d51305200cf355124b69a2247869690298a272a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 12:00:14 +1300 Subject: [PATCH 062/114] fix(windows): enforce prepared state custody Co-Authored-By: Claude Code --- src/generic_wrap.rs | 357 +++++++++++++++++++++++++++++++++------- src/std.rs | 6 +- src/std/job_object.rs | 65 ++++++-- src/tokio.rs | 6 +- src/tokio/job_object.rs | 81 +++++---- src/windows.rs | 35 ++++ tests/spawn_provider.rs | 68 ++++++++ 7 files changed, 504 insertions(+), 114 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index ffbdf9f..39a4f07 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -94,13 +94,123 @@ macro_rules! Wrap { } } + #[cfg(windows)] + struct PreparedChildState { + value: ::std::sync::Mutex>>, + type_id: ::std::any::TypeId, + } + + #[cfg(windows)] + struct PreparedLayerOwner { + layer_identity: usize, + retains: fn( + &mut dyn $childer, + usize, + &::std::sync::Arc, + ) -> bool, + } + + #[cfg(windows)] + struct PendingPreparedChild { + state: ::std::sync::Arc, + layer_owner: Option, + } + + #[cfg(windows)] + impl PendingPreparedChild { + fn new(value: Box) -> Self { + let type_id = value.as_ref().type_id(); + Self { + state: ::std::sync::Arc::new(PreparedChildState { + value: ::std::sync::Mutex::new(Some(value)), + type_id, + }), + layer_owner: None, + } + } + + fn view(&self) -> PreparedChildRef<'_> { + PreparedChildRef { state: &self.state } + } + + fn layer_owner(&self) -> PreparedChild { + PreparedChild { + state: ::std::sync::Arc::clone(&self.state), + } + } + + fn take(&self) -> Option> { + self.state + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + } + + fn has_exact_layer_owner(&self, child: &mut dyn $childer) -> bool { + ::std::sync::Arc::strong_count(&self.state) == 2 + && self.layer_owner.as_ref().is_some_and(|owner| { + (owner.retains)(child, owner.layer_identity, &self.state) + }) + } + } + + /// Immutable type metadata for state awaiting child-layer installation. + /// + /// This view cannot clone process-wrap's custody owner or access the prepared value. Use + /// [`PreparedChildRef::is`] to select a matching detached layer, declare that layer's empty + /// typed slot through [`ChildWrapperSlots::with_prepared`], and inspect the value through the + /// installed [`PreparedChild`] only after process-wrap fills the slot. + #[cfg(windows)] + #[doc(hidden)] + pub struct PreparedChildRef<'a> { + state: &'a PreparedChildState, + } + + #[cfg(windows)] + impl ::std::fmt::Debug for PreparedChildRef<'_> { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + formatter.debug_struct("PreparedChildRef").finish_non_exhaustive() + } + } + + #[cfg(windows)] + impl PreparedChildRef<'_> { + /// Report whether the pending state has concrete type `T` without exposing its value. + pub fn is(&self) -> bool { + self.state.type_id == ::std::any::TypeId::of::() + } + } + + /// Prepared state installed in a matching child layer by process-wrap. + /// + /// This handle is deliberately not `Clone`, and arbitrary mutable access is not public. A + /// layer may inspect its installed value immutably with [`PreparedChild::with`]. Resource + /// types which themselves expose interior mutation or independently clonable native owners + /// remain responsible for those capabilities; this handle never exposes process-wrap's + /// pending custody owner. + /// + /// A detached layer cannot manufacture an installed handle: + /// + /// ```compile_fail + /// # use process_wrap::std::PreparedChild; + /// fn retain_extra_owner(prepared: &PreparedChild) { + /// let _escaped: PreparedChild = prepared.clone(); + /// } + /// ``` + /// + /// Nor can downstream code move fields out through arbitrary mutable access: + /// + /// ```compile_fail + /// # use process_wrap::std::PreparedChild; + /// fn take_guard(prepared: &PreparedChild) { + /// let _guard = prepared.with_mut::, _>(Option::take); + /// } + /// ``` #[cfg(windows)] #[doc(hidden)] - #[derive(Clone)] pub struct PreparedChild { - value: ::std::sync::Arc< - ::std::sync::Mutex>>, - >, + state: ::std::sync::Arc, } #[cfg(windows)] @@ -112,45 +222,34 @@ macro_rules! Wrap { #[cfg(windows)] impl PreparedChild { - fn new(value: Box) -> Self { - Self { - value: ::std::sync::Arc::new(::std::sync::Mutex::new(Some(value))), - } - } - - /// Inspect prepared state without taking ownership from process-wrap. + /// Inspect installed prepared state immutably. #[doc(hidden)] pub fn with(&self, inspect: impl FnOnce(&T) -> R) -> Option { let value = self + .state .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); value.as_deref()?.downcast_ref::().map(inspect) } - /// Mutate prepared state without taking ownership from process-wrap. - #[doc(hidden)] - pub fn with_mut( + pub(crate) fn with_mut( &self, mutate: impl FnOnce(&mut T) -> R, ) -> Option { let mut value = self + .state .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); value.as_deref_mut()?.downcast_mut::().map(mutate) } + } - fn take(&self) -> Option> { - self.value - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) - .take() - } - - fn has_layer_owner(&self) -> bool { - ::std::sync::Arc::strong_count(&self.value) > 1 - } + #[cfg(windows)] + struct PreparedChildSlot<'a> { + value: &'a mut Option, + expected_type: ::std::any::TypeId, } /// Empty ownership slots for a child layer returned by a wrapping hook. @@ -160,7 +259,7 @@ macro_rules! Wrap { pub struct ChildWrapperSlots<'a> { child: &'a mut Option>, #[cfg(windows)] - prepared: Option<&'a mut Option>, + prepared: Option>, } impl<'a> ChildWrapperSlots<'a> { @@ -173,11 +272,20 @@ macro_rules! Wrap { } } - /// Describe the empty prepared-state slot paired with this layer. + /// Describe an empty prepared-state slot paired with concrete state type `T`. + /// + /// Process-wrap verifies the pending value's concrete type and installs the only layer + /// custody handle after the wrapping callback returns successfully. #[cfg(windows)] #[doc(hidden)] - pub fn with_prepared(mut self, prepared: &'a mut Option) -> Self { - self.prepared = Some(prepared); + pub fn with_prepared( + mut self, + prepared: &'a mut Option, + ) -> Self { + self.prepared = Some(PreparedChildSlot { + value: prepared, + expected_type: ::std::any::TypeId::of::(), + }); self } } @@ -187,7 +295,12 @@ macro_rules! Wrap { /// Implement this trait for a layer returned through [`PendingChildWrapper::new`]. The layer's /// child slot must be empty until process-wrap installs the current child. pub trait ChildWrapperLayer: $childer { - /// Expose this detached layer's empty ownership slots. + /// Expose this layer's stable ownership slots. + /// + /// The slots are empty on the first call, while the layer is detached. Process-wrap may call + /// this method again after installation to verify that the exact layer still owns the child + /// and prepared-state handles it installed. Every call must return the same logical slots + /// without moving their contents or performing unrelated work. fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_>; } @@ -200,7 +313,7 @@ macro_rules! Wrap { install: fn( &mut dyn $childer, &mut Option>, - #[cfg(windows)] Option<&PreparedChild>, + #[cfg(windows)] Option<&mut PendingPreparedChild>, ) -> ::std::io::Result<()>, } @@ -225,10 +338,50 @@ macro_rules! Wrap { } } + #[cfg(windows)] + fn layer_retains_prepared( + mut child: &mut dyn $childer, + layer_identity: usize, + state: &::std::sync::Arc, + ) -> bool + where + L: ChildWrapperLayer + 'static, + { + loop { + if (&*child as &dyn ::std::any::Any).type_id() + == ::std::any::TypeId::of::() + { + let layer = (child as &mut dyn ::std::any::Any) + .downcast_mut::() + .expect("a matching child layer retains its concrete type"); + let identity = ::std::ptr::from_mut(layer).cast::<()>() as usize; + if identity == layer_identity { + let slots = layer.child_wrapper_slots(); + return slots.prepared.is_some_and(|slot| { + slot.expected_type == state.type_id + && slot.value.as_ref().is_some_and(|prepared| { + ::std::sync::Arc::ptr_eq(&prepared.state, state) + }) + }); + } + } + + let child_type = (&*child as &dyn ::std::any::Any).type_id(); + let child_ptr = ::std::ptr::from_mut(child); + let next = child.inner_mut(); + if ::std::ptr::addr_eq(child_ptr, ::std::ptr::from_mut(next)) + && child_type == (&*next as &dyn ::std::any::Any).type_id() + { + return false; + } + child = next; + } + } + fn install_layer( layer: &mut dyn $childer, child: &mut Option>, - #[cfg(windows)] prepared: Option<&PreparedChild>, + #[cfg(windows)] prepared: Option<&mut PendingPreparedChild>, ) -> ::std::io::Result<()> where L: ChildWrapperLayer + 'static, @@ -236,6 +389,8 @@ macro_rules! Wrap { let layer = (layer as &mut dyn ::std::any::Any) .downcast_mut::() .expect("a pending child layer retains its concrete type"); + #[cfg(windows)] + let layer_identity = ::std::ptr::from_mut(&mut *layer).cast::<()>() as usize; let slots = layer.child_wrapper_slots(); if slots.child.is_some() { return Err(::std::io::Error::new( @@ -245,8 +400,25 @@ macro_rules! Wrap { } #[cfg(windows)] match (prepared, slots.prepared) { - (Some(prepared), Some(slot)) if slot.is_none() => { - *slot = Some(prepared.clone()); + (Some(prepared), Some(slot)) if slot.value.is_none() => { + if prepared.state.type_id != slot.expected_type { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state does not match the layer slot type", + )); + } + debug_assert!(prepared.layer_owner.is_none()); + prepared.layer_owner = Some(PreparedLayerOwner { + layer_identity, + retains: Self::layer_retains_prepared::, + }); + *slot.value = Some(prepared.layer_owner()); + #[cfg(test)] + if crate::windows::test_support::take_extra_prepared_owner_injection() { + crate::windows::test_support::retain_extra_prepared_owner(Box::new( + prepared.layer_owner(), + )); + } } (Some(_), _) => { return Err(::std::io::Error::new( @@ -254,13 +426,19 @@ macro_rules! Wrap { "prepared child state requires an empty matching layer slot", )); } - (None, Some(slot)) if slot.is_some() => { + (None, Some(slot)) if slot.value.is_some() => { return Err(::std::io::Error::new( ::std::io::ErrorKind::InvalidInput, "a pending prepared-state slot must be empty", )); } - (None, _) => {} + (None, Some(_)) => { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a prepared-state slot requires matching prepared child state", + )); + } + (None, None) => {} } *slots.child = Some(child.take().expect("the lifecycle retains child custody")); Ok(()) @@ -269,7 +447,7 @@ macro_rules! Wrap { fn install( &mut self, child: &mut Option>, - #[cfg(windows)] prepared: Option<&PreparedChild>, + #[cfg(windows)] prepared: Option<&mut PendingPreparedChild>, ) -> ::std::io::Result<()> { (self.install)( self.layer.as_mut(), @@ -333,7 +511,7 @@ macro_rules! Wrap { fn transfer( &mut self, child: Box, - #[cfg(windows)] prepared: Vec>, + #[cfg(windows)] prepared: Vec>, ) -> Box { let Self::Committed(transaction) = ::std::mem::replace(self, Self::Transferred) else { unreachable!("only committed transaction residue can transfer to a child"); @@ -351,7 +529,7 @@ macro_rules! Wrap { child: Box, residue: ::std::sync::Arc<::std::sync::Mutex>>, #[cfg(windows)] - prepared: Vec>, + prepared: Vec>, } impl ::std::fmt::Debug for CommittedProviderChild { @@ -390,12 +568,16 @@ macro_rules! Wrap { } fn try_clone(&self) -> Option> { + #[cfg(windows)] + if self.prepared.iter().any(Option::is_some) { + return None; + } self.child.try_clone().map(|child| { Box::new(Self { child, residue: ::std::sync::Arc::clone(&self.residue), #[cfg(windows)] - prepared: self.prepared.clone(), + prepared: self.prepared.iter().map(|_| None).collect(), }) as Box }) } @@ -628,7 +810,7 @@ macro_rules! Wrap { &mut self, attempt: &mut SpawnAttempt, child: &mut dyn $childer, - prepared: &mut Vec>, + prepared: &mut Vec>, ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { @@ -647,7 +829,7 @@ macro_rules! Wrap { wrapper.prepare_child(attempt, child, command) }) })?; - prepared.push(value.map(PreparedChild::new)); + prepared.push(value.map(PendingPreparedChild::new)); } Ok(()) @@ -686,7 +868,7 @@ macro_rules! Wrap { &mut self, child: &mut Option>, pending: &mut Option, - #[cfg(windows)] prepared: &[Option], + #[cfg(windows)] prepared: &mut [Option], ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { @@ -707,7 +889,11 @@ macro_rules! Wrap { .expect("process-wrap retains child custody between wrapping hooks"); #[cfg(windows)] { - wrapper.wrap_prepared_child(child, prepared[index].as_ref(), command) + wrapper.wrap_prepared_child( + child, + prepared[index].as_ref().map(PendingPreparedChild::view), + command, + ) } #[cfg(not(windows))] { @@ -722,7 +908,7 @@ macro_rules! Wrap { layer.install( child, #[cfg(windows)] - prepared[index].as_ref(), + prepared[index].as_mut(), ) })?; let layer = pending @@ -744,6 +930,37 @@ macro_rules! Wrap { Ok(()) } + #[cfg(windows)] + fn validate_prepared_topology( + child: &mut dyn $childer, + prepared: &[Option], + ) -> ::std::io::Result<()> { + for prepared in prepared.iter().flatten() { + if !prepared.has_exact_layer_owner(child) { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state has an unexpected custody topology", + )); + } + } + Ok(()) + } + + #[cfg(windows)] + fn capture_prepared_topology( + child: &mut Option>, + prepared: &[Option], + ) -> ::std::result::Result<(), SpawnFailure> { + Self::capture_io(|| { + Self::validate_prepared_topology( + child + .as_deref_mut() + .expect("the lifecycle retains child custody during topology checks"), + prepared, + ) + }) + } + fn finish_spawn( &mut self, attempt: &mut SpawnAttempt, @@ -812,9 +1029,11 @@ macro_rules! Wrap { &mut child, &mut pending, #[cfg(windows)] - &prepared, + &mut prepared, )?; #[cfg(windows)] + Self::capture_prepared_topology(&mut child, &prepared)?; + #[cfg(windows)] { let final_owner = Self::capture_io(|| { child @@ -828,16 +1047,7 @@ macro_rules! Wrap { .expect("the native lifecycle retains child custody") .finalize_spawn_final_owner(final_owner) })?; - if prepared - .iter() - .flatten() - .any(|prepared| !prepared.has_layer_owner()) - { - return Err(SpawnFailure::Error(::std::io::Error::new( - ::std::io::ErrorKind::InvalidInput, - "a prepared child layer released its state before transfer", - ))); - } + Self::capture_prepared_topology(&mut child, &prepared)?; } Ok(()) })(); @@ -890,7 +1100,7 @@ macro_rules! Wrap { } #[cfg(windows)] - fn cleanup_prepared(prepared: &mut [Option]) { + fn cleanup_prepared(prepared: &mut [Option]) { for prepared in prepared.iter_mut().filter_map(Option::take) { if let Some(value) = prepared.take() { Self::dispose_value(value); @@ -951,15 +1161,19 @@ macro_rules! Wrap { &mut child, &mut pending, #[cfg(windows)] - &prepared, + &mut prepared, )?; #[cfg(windows)] + Self::capture_prepared_topology(&mut child, &prepared)?; + #[cfg(windows)] let final_owner = Self::capture_io(|| { child .as_deref_mut() .expect("the provider lifecycle retains child custody") .finalize_spawn_before_commit() })?; + #[cfg(windows)] + Self::capture_prepared_topology(&mut child, &prepared)?; Self::capture_io(|| transaction.commit())?; #[cfg(windows)] Self::capture_io(|| { @@ -968,6 +1182,8 @@ macro_rules! Wrap { .expect("the provider lifecycle retains child custody") .finalize_spawn_final_owner(final_owner) })?; + #[cfg(windows)] + Self::capture_prepared_topology(&mut child, &prepared)?; Ok(()) })(); @@ -1202,8 +1418,22 @@ macro_rules! Wrap { /// Prepare Windows child state which must exist before public post-spawn hooks run. /// - /// Process-wrap retains the returned state through post-spawn hooks and supplies it to the - /// matching wrapper's `wrap_prepared_child` call. + /// Return `Some(Box::new(state))` for a concrete `state: T`. Process-wrap keeps that value in + /// private, non-cloneable pending custody through later hooks. The matching + /// [`CommandWrapper::wrap_prepared_child`] callback receives only immutable type metadata, + /// never the value or a custody handle. + /// + /// The complete safe installation pattern is: return `T` here; have the detached layer store + /// an empty `Option`; confirm `PreparedChildRef::is::()` while wrapping; + /// return that layer through `PendingChildWrapper::new`; and expose its slot with + /// `ChildWrapperSlots::with_prepared::`. Process-wrap validates the type and exact owner + /// topology before filling the slot. Once installed, the layer may use + /// `PreparedChild::with::` for immutable access. + /// + /// A prepared type which itself exposes interior ownership transfer or independently clonable + /// native resources can deliberately extend their lifetime; that is part of the type's own + /// contract. Process-wrap does not expose a cloneable custody token or mutable pre-install + /// accessor which creates such an escape. #[doc(hidden)] #[cfg(windows)] fn prepare_child( @@ -1255,13 +1485,18 @@ macro_rules! Wrap { Ok(None) } - /// Describe a child layer using state returned by `prepare_child`. + /// Describe a child layer for state returned by `prepare_child`. + /// + /// `prepared` is a non-cloneable metadata view. It can establish presence and concrete type but + /// cannot expose, mutate, replace, or move the pending value. A matching layer declares an + /// empty typed slot; process-wrap alone creates its installed `PreparedChild` handle after this + /// callback succeeds. #[doc(hidden)] #[cfg(windows)] fn wrap_prepared_child( &mut self, child: &mut dyn $childer, - prepared: Option<&PreparedChild>, + prepared: Option>, command: &Command, ) -> ::std::io::Result> { debug_assert!( diff --git a/src/std.rs b/src/std.rs index 02408b2..2382398 100644 --- a/src/std.rs +++ b/src/std.rs @@ -20,14 +20,14 @@ pub use crate::SpawnTransaction; #[cfg_attr(docsrs, doc(cfg(windows)))] #[doc(inline)] pub use crate::WindowsSpawnPolicy; -#[cfg(windows)] -#[doc(hidden)] -pub use core::PreparedChild; #[doc(inline)] pub use core::{ ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrap, CommandWrapper, PendingChildWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, }; +#[cfg(windows)] +#[doc(hidden)] +pub use core::{PreparedChild, PreparedChildRef}; #[cfg(all(windows, feature = "creation-flags"))] #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] #[doc(inline)] diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 3333ad8..a6fd496 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -26,7 +26,7 @@ use crate::{ use super::CreationFlags; use super::{ ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, - PendingChildWrapper, PreparedChild, SpawnAttempt, + PendingChildWrapper, PreparedChild, PreparedChildRef, SpawnAttempt, }; /// Wrapper which creates a job object context for a `Command`. @@ -140,15 +140,13 @@ impl CommandWrapper for JobObject { fn wrap_prepared_child( &mut self, _inner: &mut dyn ChildWrapper, - prepared: Option<&PreparedChild>, + prepared: Option>, _core: &CommandWrap, ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); assert!( - prepared - .with::(|prepared| prepared.job_port.is_some()) - .expect("JobObject prepared state retains its concrete type"), - "JobObject prepared state retains its job handles" + prepared.is::(), + "JobObject prepared state retains its concrete type" ); Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( false, @@ -213,7 +211,8 @@ impl JobObjectChild { impl ChildWrapperLayer for JobObjectChild { fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { - ChildWrapperSlots::new(&mut self.inner).with_prepared(&mut self.prepared) + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) } } @@ -305,13 +304,16 @@ impl ChildWrapper for JobObjectChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Result { - let prepared = self.prepared().clone(); let Self { inner, + prepared, exit_status, job_drained, .. } = self; + let prepared = prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state"); let inner = inner .as_deref_mut() .expect("an installed JobObject layer owns its child"); @@ -381,9 +383,9 @@ mod tests { use crate::std::{ProviderProduct, SpawnProvider}; use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - TreePaths, arm_extra_prepared_owner, arm_owner_failure, assert_tree_terminated, - clear_extra_prepared_owners, clear_owner_failure, observe_descendant, - publish_process_guards, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_failure, + assert_tree_terminated, clear_extra_prepared_owners, clear_owner_failure, + observe_descendant, publish_process_guards, }; use super::*; @@ -443,6 +445,30 @@ mod tests { } } + #[derive(Debug)] + struct ObserveNativeTree(ObserveTree); + + impl CommandWrapper for ObserveNativeTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + let handle = child + .try_process_handle() + .ok_or_else(|| Error::other("the native child has no process handle"))?; + let guard = ProcessGuard::clone_from(handle)?; + *self + .0 + .state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard); + observe_descendant(&self.0.paths, &self.0.state) + } + } + fn primary_owner_failure_preserved( outcome: std::thread::Result>>, identity: &Arc<()>, @@ -489,15 +515,20 @@ mod tests { } else { CommandWrap::from(paths.direct_command()?) }; - command.wrap(JobObject).wrap(ObserveTree { + command.wrap(JobObject); + let observer = ObserveTree { paths: paths.clone(), state: Arc::clone(state), - }); + }; if provider { - command.wrap(TreeProviderWrapper(TreeProvider { - paths: paths.clone(), - state: Arc::clone(state), - })); + command + .wrap(observer) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(state), + })); + } else { + command.wrap(ObserveNativeTree(observer)); } Ok(command) } diff --git a/src/tokio.rs b/src/tokio.rs index df86b93..782c357 100644 --- a/src/tokio.rs +++ b/src/tokio.rs @@ -20,14 +20,14 @@ pub use crate::SpawnTransaction; #[cfg_attr(docsrs, doc(cfg(windows)))] #[doc(inline)] pub use crate::WindowsSpawnPolicy; -#[cfg(windows)] -#[doc(hidden)] -pub use core::PreparedChild; #[doc(inline)] pub use core::{ ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrap, CommandWrapper, PendingChildWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, }; +#[cfg(windows)] +#[doc(hidden)] +pub use core::{PreparedChild, PreparedChildRef}; #[cfg(all(windows, feature = "creation-flags"))] #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] #[doc(inline)] diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 2941d8e..d968301 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -29,7 +29,7 @@ use super::CreationFlags; use super::KillOnDrop; use super::{ ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, - PendingChildWrapper, PreparedChild, SpawnAttempt, + PendingChildWrapper, PreparedChild, PreparedChildRef, SpawnAttempt, }; /// Wrapper which creates a job object context for a `Command`. @@ -67,7 +67,6 @@ fn terminate_child(child: &mut dyn ChildWrapper) { #[derive(Debug)] struct PreparedJobObject { job_port: Option, - final_kill_on_drop: bool, } impl JobObject { @@ -76,16 +75,10 @@ impl JobObject { child: &mut dyn ChildWrapper, core: &CommandWrap, ) -> Result { - #[cfg(feature = "kill-on-drop")] - let kill_on_drop = core.has_wrap::(); - #[cfg(not(feature = "kill-on-drop"))] - let kill_on_drop = false; - let policy = job_creation_flags(user_creation_flags(core)); #[cfg(feature = "tracing")] debug!( - ?kill_on_drop, resume_after_assignment = policy.resume_after_assignment, "options from other wrappers" ); @@ -124,7 +117,6 @@ impl JobObject { Ok(PreparedJobObject { job_port: Some(job_port), - final_kill_on_drop: kill_on_drop, }) } } @@ -149,19 +141,18 @@ impl CommandWrapper for JobObject { fn wrap_prepared_child( &mut self, _inner: &mut dyn ChildWrapper, - prepared: Option<&PreparedChild>, - _core: &CommandWrap, + prepared: Option>, + core: &CommandWrap, ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); - let final_kill_on_drop = prepared - .with::(|prepared| { - assert!( - prepared.job_port.is_some(), - "JobObject prepared state retains its job handles" - ); - prepared.final_kill_on_drop - }) - .expect("JobObject prepared state retains its concrete type"); + assert!( + prepared.is::(), + "JobObject prepared state retains its concrete type" + ); + #[cfg(feature = "kill-on-drop")] + let final_kill_on_drop = core.has_wrap::(); + #[cfg(not(feature = "kill-on-drop"))] + let final_kill_on_drop = false; Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( final_kill_on_drop, )))) @@ -256,7 +247,8 @@ impl JobObjectChild { impl ChildWrapperLayer for JobObjectChild { fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { - ChildWrapperSlots::new(&mut self.inner).with_prepared(&mut self.prepared) + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) } } @@ -397,9 +389,9 @@ mod tests { use crate::tokio::{ProviderProduct, SpawnProvider}; use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - TreePaths, arm_extra_prepared_owner, arm_owner_failure, assert_tree_terminated, - clear_extra_prepared_owners, clear_owner_failure, observe_descendant, - publish_process_guards, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_failure, + assert_tree_terminated, clear_extra_prepared_owners, clear_owner_failure, + observe_descendant, publish_process_guards, }; use super::*; @@ -465,6 +457,30 @@ mod tests { } } + #[derive(Debug)] + struct ObserveNativeTree(ObserveTree); + + impl CommandWrapper for ObserveNativeTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + let handle = child + .try_process_handle() + .ok_or_else(|| Error::other("the native child has no process handle"))?; + let guard = ProcessGuard::clone_from(handle)?; + *self + .0 + .state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard); + observe_descendant(&self.0.paths, &self.0.state) + } + } + fn primary_owner_failure_preserved( outcome: std::thread::Result>>, identity: &Arc<()>, @@ -511,15 +527,20 @@ mod tests { } else { CommandWrap::from(tokio::process::Command::from(paths.direct_command()?)) }; - command.wrap(JobObject).wrap(ObserveTree { + command.wrap(JobObject); + let observer = ObserveTree { paths: paths.clone(), state: Arc::clone(state), - }); + }; if provider { - command.wrap(TreeProviderWrapper(TreeProvider { - paths: paths.clone(), - state: Arc::clone(state), - })); + command + .wrap(observer) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(state), + })); + } else { + command.wrap(ObserveNativeTree(observer)); } Ok(command) } diff --git a/src/windows.rs b/src/windows.rs index d323cbd..6f3ed63 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -142,6 +142,7 @@ mod job_wait_tests { #[cfg(test)] pub(crate) mod test_support { use std::{ + any::Any, cell::RefCell, fs, io::{Error, ErrorKind, Result}, @@ -195,6 +196,40 @@ pub(crate) mod test_support { thread_local! { static OWNER_FAILURE: RefCell> = const { RefCell::new(None) }; + static EXTRA_PREPARED_OWNER: RefCell = RefCell::new(ExtraPreparedOwner::default()); + } + + #[derive(Default)] + struct ExtraPreparedOwner { + inject: bool, + owners: Vec>, + } + + pub fn arm_extra_prepared_owner() { + EXTRA_PREPARED_OWNER.with(|state| { + let mut state = state.borrow_mut(); + assert!(!state.inject); + assert!(state.owners.is_empty()); + state.inject = true; + }); + } + + pub fn take_extra_prepared_owner_injection() -> bool { + EXTRA_PREPARED_OWNER.with(|state| std::mem::take(&mut state.borrow_mut().inject)) + } + + pub fn retain_extra_prepared_owner(owner: Box) { + EXTRA_PREPARED_OWNER.with(|state| state.borrow_mut().owners.push(owner)); + } + + pub fn clear_extra_prepared_owners() -> (bool, usize) { + EXTRA_PREPARED_OWNER.with(|state| { + let mut state = state.borrow_mut(); + let inject = std::mem::take(&mut state.inject); + let owner_count = state.owners.len(); + state.owners.clear(); + (inject, owner_count) + }) } pub fn arm_owner_failure(failure: OwnerFailure) { diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 928f75d..f33bc6c 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -564,6 +564,34 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[derive(Debug)] + struct StealInstalledPrepared { + steal_once: bool, + escaped: Option, + } + + #[cfg(windows)] + impl CommandWrapper for StealInstalledPrepared { + fn wrap_prepared_child( + &mut self, + child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!(prepared.is_none()); + if self.steal_once { + self.steal_once = false; + let layer = (child as &mut dyn std::any::Any) + .downcast_mut::() + .expect("the prepared layer is immediately inside this wrapper"); + self.escaped = layer.prepared.take(); + assert!(self.escaped.is_some()); + } + Ok(None) + } + } + #[cfg(windows)] #[derive(Clone, Copy, Debug)] enum MalformedPreparedCase { @@ -2227,6 +2255,46 @@ macro_rules! spawn_provider_tests { assert_eq!(drops.load(Ordering::SeqCst), 2); } + #[cfg(windows)] + #[test] + fn installed_prepared_owner_must_remain_in_its_declared_layer() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(StealInstalledPrepared { + steal_once: true, + escaped: None, + }); + + let error = command + .spawn() + .expect_err("moving the installed owner out of its layer must fail"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "prepared child state has an unexpected custody topology" + ); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let events = shared.events(); + assert!(!events.contains(&Event::Commit)); + assert_eq!( + events.iter().filter(|event| **event == Event::Rollback).count(), + 1 + ); + + shared.clear_events(); + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + assert_eq!(shared.events(), successful_events("provider")); + } + #[cfg(windows)] #[test] fn malformed_prepared_layers_fail_atomically_and_allow_reuse() { From 41a34c1198b9225a5d0ae529a3b8e647ca10b7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 12:18:31 +1300 Subject: [PATCH 063/114] fix(tests): guard bounded subprocess setup Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 116 +---------- tests/spawn_with_child.rs | 120 +---------- tests/support/bounded_process.rs | 334 +++++++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 214 deletions(-) create mode 100644 tests/support/bounded_process.rs diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index f33bc6c..ecd210c 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -1,5 +1,8 @@ #![cfg(all(any(feature = "std", feature = "tokio1"), any(unix, windows)))] +#[path = "support/bounded_process.rs"] +mod bounded_process; + macro_rules! spawn_provider_tests { ( $module:ident, @@ -20,18 +23,17 @@ macro_rules! spawn_provider_tests { use std::{ any::TypeId, ffi::OsStr, - io::{self, Read}, + io, panic::{AssertUnwindSafe, catch_unwind, panic_any}, - process::{ExitStatus, Output, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc, }, thread::sleep, time::{Duration, Instant}, }; + use super::bounded_process; use process_wrap::{CommandArg, SpawnTransaction}; use $child_wrapper as ChildWrapper; #[cfg(windows)] @@ -1433,109 +1435,13 @@ macro_rules! spawn_provider_tests { test_name: &str, environment: (&str, &str), timeout: Duration, - ) -> (Output, bool) { - const REAP_TIMEOUT: Duration = Duration::from_secs(5); - - struct ChildGuard(Option); - - impl ChildGuard { - fn terminate_and_reap(&mut self) -> io::Result { - let child = self - .0 - .as_mut() - .expect("the guarded subprocess remains available"); - match child.kill() { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} - Err(error) => return Err(error), - } - let deadline = Instant::now() + REAP_TIMEOUT; - loop { - if let Some(status) = child.try_wait()? { - return Ok(status); - } - if Instant::now() >= deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "isolated lifecycle subprocess was not reaped after termination", - )); - } - sleep(Duration::from_millis(5)); - } - } - } - - impl Drop for ChildGuard { - fn drop(&mut self) { - if self.0.is_some() { - let _ = self.terminate_and_reap(); - } - } - } - - fn drain_pipe( - mut pipe: impl Read + Send + 'static, - ) -> mpsc::Receiver>> { - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let mut bytes = Vec::new(); - let result = pipe.read_to_end(&mut bytes).map(|_| bytes); - let _ = sender.send(result); - }); - receiver - } - - fn collect_output( - status: ExitStatus, - stdout: mpsc::Receiver>>, - stderr: mpsc::Receiver>>, - ) -> Output { - let deadline = Instant::now() + REAP_TIMEOUT; - let receive = |receiver: mpsc::Receiver>>, label| { - let remaining = deadline.saturating_duration_since(Instant::now()); - receiver - .recv_timeout(remaining) - .unwrap_or_else(|error| panic!("did not drain isolated {label}: {error}")) - .unwrap_or_else(|error| panic!("could not read isolated {label}: {error}")) - }; - Output { - status, - stdout: receive(stdout, "stdout"), - stderr: receive(stderr, "stderr"), - } - } - - let mut spawned = std::process::Command::new(std::env::current_exe().unwrap()) + ) -> (std::process::Output, bool) { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command .args(["--exact", test_name, "--nocapture"]) - .env(environment.0, environment.1) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("start isolated lifecycle regression"); - let stdout = drain_pipe(spawned.stdout.take().expect("capture isolated stdout")); - let stderr = drain_pipe(spawned.stderr.take().expect("capture isolated stderr")); - let mut child = ChildGuard(Some(spawned)); - let deadline = Instant::now() + timeout; - loop { - let status = child - .0 - .as_mut() - .expect("the subprocess remains guarded while polling") - .try_wait() - .expect("poll isolated lifecycle regression"); - if let Some(status) = status { - child.0.take(); - return (collect_output(status, stdout, stderr), false); - } - if Instant::now() >= deadline { - let status = child - .terminate_and_reap() - .expect("terminate and reap expired lifecycle subprocess"); - child.0.take(); - return (collect_output(status, stdout, stderr), true); - } - sleep(Duration::from_millis(5)); - } + .env(environment.0, environment.1); + bounded_process::run(command, timeout, None) + .expect("run isolated lifecycle regression") } fn wait_for_exit(mut child: Box) { diff --git a/tests/spawn_with_child.rs b/tests/spawn_with_child.rs index f474d08..706d58b 100644 --- a/tests/spawn_with_child.rs +++ b/tests/spawn_with_child.rs @@ -1,5 +1,8 @@ #![cfg(all(any(feature = "std", feature = "tokio1"), any(unix, windows)))] +#[path = "support/bounded_process.rs"] +mod bounded_process; + macro_rules! spawn_with_child_tests { ( $module:ident, @@ -15,18 +18,17 @@ macro_rules! spawn_with_child_tests { mod $module { use std::{ any::TypeId, - io::{self, Read}, + io, panic::{AssertUnwindSafe, catch_unwind, panic_any}, - process::{ExitStatus, Output, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, - mpsc, }, thread::sleep, time::{Duration, Instant}, }; + use super::bounded_process; use $child_wrapper as ChildWrapper; use $child_wrapper_layer as ChildWrapperLayer; use $child_wrapper_slots as ChildWrapperSlots; @@ -402,113 +404,13 @@ macro_rules! spawn_with_child_tests { test_name: &str, environment: (&str, &str), timeout: Duration, - ) -> (Output, bool) { - const REAP_TIMEOUT: Duration = Duration::from_secs(5); - - struct ChildGuard(Option); - - impl ChildGuard { - fn terminate_and_reap(&mut self) -> io::Result { - let child = self - .0 - .as_mut() - .expect("the guarded subprocess remains available"); - match child.kill() { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} - Err(error) => return Err(error), - } - let deadline = Instant::now() + REAP_TIMEOUT; - loop { - if let Some(status) = child.try_wait()? { - return Ok(status); - } - if Instant::now() >= deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - "isolated wrapping subprocess was not reaped after termination", - )); - } - sleep(Duration::from_millis(5)); - } - } - } - - impl Drop for ChildGuard { - fn drop(&mut self) { - if self.0.is_some() { - let _ = self.terminate_and_reap(); - } - } - } - - fn drain_pipe( - mut pipe: impl Read + Send + 'static, - ) -> mpsc::Receiver>> { - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let mut bytes = Vec::new(); - let result = pipe.read_to_end(&mut bytes).map(|_| bytes); - let _ = sender.send(result); - }); - receiver - } - - fn collect_output( - status: ExitStatus, - stdout: mpsc::Receiver>>, - stderr: mpsc::Receiver>>, - ) -> Output { - let deadline = Instant::now() + REAP_TIMEOUT; - let receive = |receiver: mpsc::Receiver>>, label| { - let remaining = deadline.saturating_duration_since(Instant::now()); - receiver - .recv_timeout(remaining) - .unwrap_or_else(|error| { - panic!("did not drain isolated {label}: {error}") - }) - .unwrap_or_else(|error| { - panic!("could not read isolated {label}: {error}") - }) - }; - Output { - status, - stdout: receive(stdout, "stdout"), - stderr: receive(stderr, "stderr"), - } - } - - let mut spawned = std::process::Command::new(std::env::current_exe().unwrap()) + ) -> (std::process::Output, bool) { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command .args(["--exact", test_name, "--nocapture"]) - .env(environment.0, environment.1) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("start isolated wrapping regression"); - let stdout = drain_pipe(spawned.stdout.take().expect("capture isolated stdout")); - let stderr = drain_pipe(spawned.stderr.take().expect("capture isolated stderr")); - let mut child = ChildGuard(Some(spawned)); - let deadline = Instant::now() + timeout; - loop { - let status = child - .0 - .as_mut() - .expect("the subprocess remains guarded while polling") - .try_wait() - .expect("poll isolated wrapping regression"); - if let Some(status) = status { - child.0.take(); - return (collect_output(status, stdout, stderr), false); - } - if Instant::now() >= deadline { - let status = child - .terminate_and_reap() - .expect("terminate and reap expired wrapping subprocess"); - child.0.take(); - return (collect_output(status, stdout, stderr), true); - } - sleep(Duration::from_millis(5)); - } + .env(environment.0, environment.1); + bounded_process::run(command, timeout, None) + .expect("run isolated wrapping regression") } fn wait_for_exit(mut child: Box) { diff --git a/tests/support/bounded_process.rs b/tests/support/bounded_process.rs new file mode 100644 index 0000000..3c23b20 --- /dev/null +++ b/tests/support/bounded_process.rs @@ -0,0 +1,334 @@ +use std::{ + io::{self, Read}, + process::{Child, ExitStatus, Output, Stdio}, + sync::mpsc, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +const REAP_TIMEOUT: Duration = Duration::from_secs(5); +const POLL_INTERVAL: Duration = Duration::from_millis(5); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReaderStartFailure { + First, + Second, +} + +struct ChildGuard(Option); + +impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0 + .as_mut() + .expect("an armed subprocess guard owns its child") + } + + fn terminate_and_reap(&mut self) -> io::Result { + let child = self.child_mut(); + let kill_error = match child.kill() { + Ok(()) => None, + Err(error) if error.kind() == io::ErrorKind::InvalidInput => None, + Err(error) => Some(error), + }; + let deadline = Instant::now() + REAP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(error) => return Err(kill_error.unwrap_or(error)), + } + if Instant::now() >= deadline { + return Err(kill_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "isolated subprocess was not reaped after termination", + ) + })); + } + thread::sleep(POLL_INTERVAL); + } + } + + fn disarm(&mut self) { + self.0.take(); + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if self.0.is_some() { + let _ = self.terminate_and_reap(); + } + } +} + +struct PipeReader { + stream: &'static str, + receiver: mpsc::Receiver>>, + handle: Option>, +} + +impl PipeReader { + fn finish(mut self, deadline: Instant) -> io::Result> { + let remaining = deadline.saturating_duration_since(Instant::now()); + let result = match self.receiver.recv_timeout(remaining) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("did not drain isolated {} before the deadline", self.stream), + )), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::other(format!( + "isolated {} reader stopped without a result", + self.stream + ))), + }; + + let handle = self + .handle + .as_ref() + .expect("an unfinished pipe reader owns its thread"); + while !handle.is_finished() && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + if !handle.is_finished() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("isolated {} reader did not stop", self.stream), + )); + } + if self + .handle + .take() + .expect("a finished pipe reader owns its thread") + .join() + .is_err() + { + return Err(io::Error::other(format!( + "isolated {} reader panicked", + self.stream + ))); + } + result + } +} + +fn start_reader( + mut pipe: impl Read + Send + 'static, + stream: &'static str, + failure: Option, + position: ReaderStartFailure, +) -> io::Result { + if failure == Some(position) { + let ordinal = match position { + ReaderStartFailure::First => "first", + ReaderStartFailure::Second => "second", + }; + return Err(io::Error::other(format!( + "injected {ordinal} reader setup failure" + ))); + } + + let (sender, receiver) = mpsc::channel(); + let handle = thread::Builder::new() + .name(format!("process-wrap-{stream}-reader")) + .spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.read_to_end(&mut bytes).map(|_| bytes); + let _ = sender.send(result); + }) + .map_err(|error| { + io::Error::new( + error.kind(), + format!("could not start isolated {stream} reader: {error}"), + ) + })?; + Ok(PipeReader { + stream, + receiver, + handle: Some(handle), + }) +} + +fn finish_readers( + status: ExitStatus, + stdout: PipeReader, + stderr: PipeReader, +) -> io::Result { + let deadline = Instant::now() + REAP_TIMEOUT; + let stdout = stdout.finish(deadline); + let stderr = stderr.finish(deadline); + Ok(Output { + status, + stdout: stdout?, + stderr: stderr?, + }) +} + +fn preserve_after_cleanup( + child: &mut ChildGuard, + stdout: Option, + stderr: Option, + primary: io::Error, +) -> io::Error { + let process_cleanup = child.terminate_and_reap(); + if process_cleanup.is_ok() { + child.disarm(); + } + let deadline = Instant::now() + REAP_TIMEOUT; + let stdout_cleanup = stdout.map(|reader| reader.finish(deadline)); + let stderr_cleanup = stderr.map(|reader| reader.finish(deadline)); + let cleanup_error = process_cleanup + .err() + .or_else(|| stdout_cleanup.and_then(Result::err)) + .or_else(|| stderr_cleanup.and_then(Result::err)); + match cleanup_error { + Some(cleanup) => io::Error::new( + primary.kind(), + format!("{primary}; subprocess cleanup also failed: {cleanup}"), + ), + None => primary, + } +} + +pub(crate) fn run( + mut command: std::process::Command, + timeout: Duration, + reader_failure: Option, +) -> io::Result<(Output, bool)> { + let deadline = Instant::now().checked_add(timeout).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "subprocess timeout is too large", + ) + })?; + let spawned = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let mut child = ChildGuard::new(spawned); + + let stdout = child + .child_mut() + .stdout + .take() + .ok_or_else(|| io::Error::other("isolated subprocess stdout was not captured"))?; + let stderr = child + .child_mut() + .stderr + .take() + .ok_or_else(|| io::Error::other("isolated subprocess stderr was not captured"))?; + + let stdout = match start_reader(stdout, "stdout", reader_failure, ReaderStartFailure::First) { + Ok(reader) => reader, + Err(error) => { + drop(stderr); + return Err(preserve_after_cleanup(&mut child, None, None, error)); + } + }; + let stderr = match start_reader(stderr, "stderr", reader_failure, ReaderStartFailure::Second) { + Ok(reader) => reader, + Err(error) => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + None, + error, + )); + } + }; + + loop { + let status = match child.child_mut().try_wait() { + Ok(status) => status, + Err(error) => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + error, + )); + } + }; + if let Some(status) = status { + let output = finish_readers(status, stdout, stderr)?; + child.disarm(); + return Ok((output, false)); + } + if Instant::now() >= deadline { + let status = match child.terminate_and_reap() { + Ok(status) => status, + Err(error) => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + error, + )); + } + }; + let output = finish_readers(status, stdout, stderr)?; + child.disarm(); + return Ok((output, true)); + } + thread::sleep(POLL_INTERVAL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reader_start_failures_terminate_reap_and_join_before_returning() { + const MARKER_ENV: &str = "PROCESS_WRAP_READER_SETUP_MARKER"; + + for failure in [ReaderStartFailure::First, ReaderStartFailure::Second] { + let directory = tempfile::tempdir().expect("create setup-failure directory"); + let marker = directory.path().join("delayed-marker"); + + #[cfg(unix)] + let mut command = { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 0.3; : > \"$PROCESS_WRAP_READER_SETUP_MARKER\""]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = std::process::Command::new("cmd.exe"); + command.args([ + "/D", + "/S", + "/C", + "ping 127.0.0.1 -n 2 >NUL & type NUL > \"%PROCESS_WRAP_READER_SETUP_MARKER%\"", + ]); + command + }; + command.env(MARKER_ENV, &marker); + + let error = run(command, REAP_TIMEOUT, Some(failure)) + .expect_err("reader setup must fail at the injected position"); + let ordinal = match failure { + ReaderStartFailure::First => "first", + ReaderStartFailure::Second => "second", + }; + assert_eq!( + error.to_string(), + format!("injected {ordinal} reader setup failure"), + "the injected error is returned only after cleanup succeeds" + ); + #[cfg(unix)] + thread::sleep(Duration::from_millis(750)); + #[cfg(windows)] + thread::sleep(Duration::from_millis(1_500)); + assert!( + !marker.exists(), + "a setup failure must terminate the child before its delayed marker" + ); + } + } +} From 0c9f35d05bd80bd8e07feb714abaaa80c12903ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 12:25:40 +1300 Subject: [PATCH 064/114] fix(windows): keep minimal Tokio build warning-free Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index d968301..3f2dfb4 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -142,7 +142,7 @@ impl CommandWrapper for JobObject { &mut self, _inner: &mut dyn ChildWrapper, prepared: Option>, - core: &CommandWrap, + _core: &CommandWrap, ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); assert!( @@ -150,7 +150,7 @@ impl CommandWrapper for JobObject { "JobObject prepared state retains its concrete type" ); #[cfg(feature = "kill-on-drop")] - let final_kill_on_drop = core.has_wrap::(); + let final_kill_on_drop = _core.has_wrap::(); #[cfg(not(feature = "kill-on-drop"))] let final_kill_on_drop = false; Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( From 79ef81a08d1e444633faa886acd283b66829e576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 13:39:47 +1300 Subject: [PATCH 065/114] test(windows): cover stable prepared custody Co-Authored-By: Claude Code --- src/std/job_object.rs | 153 +++++++++++++++++++++++++++++++-- src/tokio/job_object.rs | 152 +++++++++++++++++++++++++++++++-- src/windows.rs | 22 +++++ tests/spawn_provider.rs | 184 +++++++++++++++++++++++++++++++++------- 4 files changed, 468 insertions(+), 43 deletions(-) diff --git a/src/std/job_object.rs b/src/std/job_object.rs index a6fd496..68c33f3 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -294,6 +294,8 @@ impl ChildWrapper for JobObjectChild { let final_kill_on_drop = self.final_kill_on_drop; self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; + #[cfg(test)] + crate::windows::test_support::record_owner_event("owner-disarmed"); Ok(()) } @@ -373,8 +375,12 @@ mod tests { use std::{ cell::{Cell, RefCell}, os::windows::{io::AsHandle, process::CommandExt, process::ExitStatusExt}, - panic::{AssertUnwindSafe, catch_unwind}, - sync::{Arc, atomic::Ordering, mpsc}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, thread, }; @@ -383,9 +389,9 @@ mod tests { use crate::std::{ProviderProduct, SpawnProvider}; use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_failure, - assert_tree_terminated, clear_extra_prepared_owners, clear_owner_failure, - observe_descendant, publish_process_guards, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, + assert_tree_terminated, clear_extra_prepared_owners, clear_owner_events, + clear_owner_failure, observe_descendant, publish_process_guards, record_owner_event, }; use super::*; @@ -469,6 +475,78 @@ mod tests { } } + #[derive(Debug)] + struct PostOwnerAccessorLayer { + inner: Option>, + prepared: Option, + slot_calls: Arc, + panic_on: usize, + } + + impl ChildWrapperLayer for PostOwnerAccessorLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + let call = self.slot_calls.fetch_add(1, Ordering::SeqCst) + 1; + record_owner_event("slot-accessor"); + if call == self.panic_on { + panic_any("slot accessor ran after final owner disarm"); + } + ChildWrapperSlots::new(&mut self.inner).with_prepared::<()>(&mut self.prepared) + } + } + + impl ChildWrapper for PostOwnerAccessorLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed accessor layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed accessor layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed accessor layer owns its child") + } + } + + #[derive(Debug)] + struct PostOwnerAccessor { + slot_calls: Arc, + panic_on: usize, + } + + impl CommandWrapper for PostOwnerAccessor { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result>> { + self.slot_calls.store(0, Ordering::SeqCst); + Ok(Some(Box::new(()))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::<()>)); + Ok(Some(PendingChildWrapper::new(PostOwnerAccessorLayer { + inner: None, + prepared: None, + slot_calls: Arc::clone(&self.slot_calls), + panic_on: self.panic_on, + }))) + } + } + fn primary_owner_failure_preserved( outcome: std::thread::Result>>, identity: &Arc<()>, @@ -585,6 +663,71 @@ mod tests { Ok(()) } + #[test] + fn no_slot_accessor_runs_after_final_owner_disarm() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let slot_calls = Arc::new(AtomicUsize::new(0)); + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut command = topology_command(provider, &paths, &state)?; + command.wrap(PostOwnerAccessor { + slot_calls: Arc::clone(&slot_calls), + panic_on: if provider { 4 } else { 3 }, + }); + + for attempt in 0..2 { + events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + arm_owner_events(Arc::clone(&events)); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let mut succeeded = false; + match outcome { + Ok(Ok(mut child)) => { + child.start_kill()?; + let _ = child.wait()?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = disposal + .expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + succeeded = true; + } + Ok(Err(_)) => {} + Err(payload) => std::mem::forget(payload), + } + assert!( + clear_owner_events(), + "the owner event barrier remained armed" + ); + let tree_result = assert_tree_terminated(&paths, &state); + assert!( + succeeded, + "a post-owner-only accessor panic must remain unreachable" + ); + assert_eq!(slot_calls.load(Ordering::SeqCst), 1); + assert_eq!( + *events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ["slot-accessor", "owner-disarmed"], + "the final owner transition follows the last caller-defined accessor" + ); + tree_result?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + } + Ok(()) + } + #[test] fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> { for was_panic in [false, true] { diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 3f2dfb4..134c389 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -296,6 +296,8 @@ impl ChildWrapper for JobObjectChild { let final_kill_on_drop = self.final_kill_on_drop; self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; + #[cfg(test)] + crate::windows::test_support::record_owner_event("owner-disarmed"); Ok(()) } @@ -380,8 +382,11 @@ impl ChildWrapper for JobObjectChild { mod tests { use std::{ os::windows::{io::BorrowedHandle, process::CommandExt}, - panic::{AssertUnwindSafe, catch_unwind}, - sync::{Arc, atomic::Ordering}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; use windows::Win32::System::Threading::CREATE_SUSPENDED; @@ -389,9 +394,9 @@ mod tests { use crate::tokio::{ProviderProduct, SpawnProvider}; use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_failure, - assert_tree_terminated, clear_extra_prepared_owners, clear_owner_failure, - observe_descendant, publish_process_guards, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, + assert_tree_terminated, clear_extra_prepared_owners, clear_owner_events, + clear_owner_failure, observe_descendant, publish_process_guards, record_owner_event, }; use super::*; @@ -481,6 +486,78 @@ mod tests { } } + #[derive(Debug)] + struct PostOwnerAccessorLayer { + inner: Option>, + prepared: Option, + slot_calls: Arc, + panic_on: usize, + } + + impl ChildWrapperLayer for PostOwnerAccessorLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + let call = self.slot_calls.fetch_add(1, Ordering::SeqCst) + 1; + record_owner_event("slot-accessor"); + if call == self.panic_on { + panic_any("slot accessor ran after final owner disarm"); + } + ChildWrapperSlots::new(&mut self.inner).with_prepared::<()>(&mut self.prepared) + } + } + + impl ChildWrapper for PostOwnerAccessorLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed accessor layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed accessor layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed accessor layer owns its child") + } + } + + #[derive(Debug)] + struct PostOwnerAccessor { + slot_calls: Arc, + panic_on: usize, + } + + impl CommandWrapper for PostOwnerAccessor { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result>> { + self.slot_calls.store(0, Ordering::SeqCst); + Ok(Some(Box::new(()))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::<()>)); + Ok(Some(PendingChildWrapper::new(PostOwnerAccessorLayer { + inner: None, + prepared: None, + slot_calls: Arc::clone(&self.slot_calls), + panic_on: self.panic_on, + }))) + } + } + fn primary_owner_failure_preserved( outcome: std::thread::Result>>, identity: &Arc<()>, @@ -598,6 +675,71 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn no_slot_accessor_runs_after_final_owner_disarm() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let slot_calls = Arc::new(AtomicUsize::new(0)); + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut command = topology_command(provider, &paths, &state)?; + command.wrap(PostOwnerAccessor { + slot_calls: Arc::clone(&slot_calls), + panic_on: if provider { 4 } else { 3 }, + }); + + for attempt in 0..2 { + events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + arm_owner_events(Arc::clone(&events)); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let mut succeeded = false; + match outcome { + Ok(Ok(mut child)) => { + child.start_kill()?; + let _ = child.wait().await?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = disposal + .expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + succeeded = true; + } + Ok(Err(_)) => {} + Err(payload) => std::mem::forget(payload), + } + assert!( + clear_owner_events(), + "the owner event barrier remained armed" + ); + let tree_result = assert_tree_terminated(&paths, &state); + assert!( + succeeded, + "a post-owner-only accessor panic must remain unreachable" + ); + assert_eq!(slot_calls.load(Ordering::SeqCst), 1); + assert_eq!( + *events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ["slot-accessor", "owner-disarmed"], + "the final owner transition follows the last caller-defined accessor" + ); + tree_result?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> { diff --git a/src/windows.rs b/src/windows.rs index 6f3ed63..757765a 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -196,6 +196,7 @@ pub(crate) mod test_support { thread_local! { static OWNER_FAILURE: RefCell> = const { RefCell::new(None) }; + static OWNER_EVENTS: RefCell>>>> = const { RefCell::new(None) }; static EXTRA_PREPARED_OWNER: RefCell = RefCell::new(ExtraPreparedOwner::default()); } @@ -250,6 +251,27 @@ pub(crate) mod test_support { OWNER_FAILURE.with(|slot| slot.borrow_mut().take().is_some()) } + pub fn arm_owner_events(events: Arc>>) { + OWNER_EVENTS.with(|slot| { + assert!(slot.borrow_mut().replace(events).is_none()); + }); + } + + pub fn record_owner_event(event: &'static str) { + OWNER_EVENTS.with(|slot| { + if let Some(events) = slot.borrow().as_ref() { + events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(event); + } + }); + } + + pub fn clear_owner_events() -> bool { + OWNER_EVENTS.with(|slot| slot.borrow_mut().take().is_some()) + } + #[derive(Debug)] pub struct ProcessGuard { handle: OwnedHandle, diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index ecd210c..e4421bf 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -568,13 +568,13 @@ macro_rules! spawn_provider_tests { #[cfg(windows)] #[derive(Debug)] - struct StealInstalledPrepared { + struct RetainInstalledPrepared { steal_once: bool, escaped: Option, } #[cfg(windows)] - impl CommandWrapper for StealInstalledPrepared { + impl CommandWrapper for RetainInstalledPrepared { fn wrap_prepared_child( &mut self, child: &mut dyn ChildWrapper, @@ -594,6 +594,91 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[derive(Debug)] + struct AlternatingPreparedLayer { + inner: Option>, + first: Option, + second: Option, + slot_calls: Arc, + } + + #[cfg(windows)] + impl ChildWrapperLayer for AlternatingPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + let call = self.slot_calls.fetch_add(1, Ordering::SeqCst); + let prepared = if call == 0 { + &mut self.first + } else { + if self.second.is_none() { + self.second = self.first.take(); + } + &mut self.second + }; + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(prepared) + } + } + + #[cfg(windows)] + impl ChildWrapper for AlternatingPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed alternating layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed alternating layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed alternating layer owns its child") + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct AlternatingPrepared { + drops: Arc, + slot_calls: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for AlternatingPrepared { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(Some(Box::new(PreparedGuard(Arc::clone(&self.drops))))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!( + prepared + .as_ref() + .is_some_and(PreparedChildRef::is::) + ); + Ok(Some(PendingChildWrapper::new(AlternatingPreparedLayer { + inner: None, + first: None, + second: None, + slot_calls: Arc::clone(&self.slot_calls), + }))) + } + } + #[cfg(windows)] #[derive(Clone, Copy, Debug)] enum MalformedPreparedCase { @@ -2163,42 +2248,75 @@ macro_rules! spawn_provider_tests { #[cfg(windows)] #[test] - fn installed_prepared_owner_must_remain_in_its_declared_layer() { + fn retained_installed_token_cannot_extend_prepared_state_liveness() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - let shared = Arc::new(Shared::default()); - let drops = Arc::new(AtomicUsize::new(0)); - let mut command = provider_command(Arc::clone(&shared), "provider"); - command - .wrap(OptionPrepared { + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: None, + }); + + let child = command + .spawn() + .expect("a retained installed token is non-owning"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + if provider { + assert_eq!(shared.events(), successful_events("provider")); + shared.clear_events(); + } + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + if provider { + assert_eq!(shared.events(), successful_events("provider")); + } + } + } + + #[cfg(windows)] + #[test] + fn prepared_slot_accessors_are_not_reinvoked_after_installation() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let slot_calls = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command.wrap(AlternatingPrepared { drops: Arc::clone(&drops), - }) - .wrap(StealInstalledPrepared { - steal_once: true, - escaped: None, + slot_calls: Arc::clone(&slot_calls), }); - let error = command - .spawn() - .expect_err("moving the installed owner out of its layer must fail"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert_eq!( - error.to_string(), - "prepared child state has an unexpected custody topology" - ); - assert_eq!(drops.load(Ordering::SeqCst), 1); - let events = shared.events(); - assert!(!events.contains(&Event::Commit)); - assert_eq!( - events.iter().filter(|event| **event == Event::Rollback).count(), - 1 - ); - - shared.clear_events(); - let child = command.spawn().expect("the command remains reusable"); - drop(child); - assert_eq!(drops.load(Ordering::SeqCst), 2); - assert_eq!(shared.events(), successful_events("provider")); + let child = command.spawn().expect("spawn with an alternating slot layer"); + assert_eq!( + slot_calls.load(Ordering::SeqCst), + 1, + "the slot accessor is used only for initial installation" + ); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } } #[cfg(windows)] From 3d54e71582855ba417853bc7d5b7730833303fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 13:39:57 +1300 Subject: [PATCH 066/114] fix(windows): retain stable prepared custody Co-Authored-By: Claude Code --- README.md | 19 +- src/command.rs | 12 +- src/generic_wrap.rs | 693 +++++++++++++++++--------- src/lib.rs | 19 +- src/std/core.rs | 80 ++- src/std/job_object.rs | 109 ++-- src/tokio/core.rs | 106 +++- src/tokio/job_object.rs | 53 +- tests/spawn_provider.rs | 145 ++++++ tests/std_windows/process_handle.rs | 40 +- tests/tokio_windows/process_handle.rs | 40 +- 11 files changed, 983 insertions(+), 333 deletions(-) diff --git a/README.md b/README.md index 9e2b013..db17325 100644 --- a/README.md +++ b/README.md @@ -382,9 +382,10 @@ Callbacks run in this order: 7. provider `spawn` 8. every `post_spawn` hook 9. every child wrapper -10. transaction `commit` -11. on Windows, the sole JobObject cleanup owner -12. committed transaction-residue transfer to the returned child +10. private return-sidecar allocation +11. transaction `commit` +12. committed transaction-residue installation +13. on Windows, the sole JobObject cleanup owner Validation must reject unsupported portable policy before allocating operating-system resources. `spawn` returns a child satisfying the frontend's complete `ChildWrapper` contract together with a @@ -393,12 +394,12 @@ later hook, wrapper, pre-commit child step, or commit error or unwinding panic c rollback while preserving the original failure. After capturing that failure, process-wrap first resolves transaction cleanup, then disposes any detached child layer, the child chain, and each Windows prepared value independently. Secondary cleanup panic payloads are quarantined without -inspection or destruction. Successful commit ends failed-spawn rollback. On Windows, the sole -JobObject owner remains authoritative until it disarms after commit. Process-wrap then transfers the -committed transaction residue in a private transparent layer with the returned child. On a successful -spawn, arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue must -retain no armed cleanup or independent process, terminal, controller, handle, pseudoconsole, or other -liveness resource. +inspection or destruction. Before commit, process-wrap preallocates the private return sidecar. +Successful commit ends failed-spawn rollback, and its residue is installed in that sidecar. On Windows, +the sole JobObject owner remains authoritative until it disarms after commit; no caller callback or +allocation-dependent work follows that transition. On a successful spawn, arbitrary residue destruction +occurs outside the spawn lifecycle. Committed residue must retain no armed cleanup or independent +process, terminal, controller, handle, pseudoconsole, or other liveness resource. Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic containment apply only to unwinding panics. With `panic=abort`, the process terminates before those diff --git a/src/command.rs b/src/command.rs index 5945477..b76e12a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -551,13 +551,15 @@ impl fmt::Debug for CommandState { /// the original failure is preserved. Transaction destruction after rollback is contained separately, /// and secondary panic payloads are quarantined without being inspected or destroyed. /// -/// Successful commit ends failed-spawn rollback. Process-wrap retains the committed transaction -/// residue until the sole JobObject owner, if any, disarms successfully, then transfers that residue -/// in a private transparent layer with the returned child. On a successful spawn, arbitrary +/// Before commit, process-wrap preallocates a private transparent return sidecar. Successful commit +/// ends failed-spawn rollback, and process-wrap installs the committed transaction residue in that +/// sidecar before the sole JobObject owner, if any, disarms. After that owner succeeds, no caller +/// callback or allocation-dependent work runs before return. On a successful spawn, arbitrary /// transaction destruction therefore occurs only after the child has left the spawn lifecycle. A /// committed transaction must retain no armed cleanup or independent process, terminal, controller, -/// handle, pseudoconsole, or other liveness resource. If the final owner fails after commit, its still-armed cleanup remains -/// authoritative while process-wrap contains disposal of the committed residue as secondary cleanup. +/// handle, pseudoconsole, or other liveness resource. If the final owner fails after commit, its +/// still-armed cleanup remains authoritative while process-wrap contains disposal of the committed +/// residue as secondary cleanup. /// /// On non-Windows targets there is no hidden child-finalization phase, so commit follows the public /// hooks directly. These panic guarantees require unwinding; `panic=abort` terminates the process diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 39a4f07..12d5d28 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -4,7 +4,15 @@ )] macro_rules! Wrap { - ($backend:ty, $command:ty, $child:ty, $childer:ident, $first_child_wrapper:expr) => { + ( + $backend:ty, + $command:ty, + $child:ty, + $childer:ident, + $first_child_wrapper:expr, + $frontend:literal, + $prepared_owner_contract:ident + ) => { trait ErasedCommandWrapper: ::std::fmt::Debug + Send + Sync { fn as_command_wrapper(&self) -> &dyn CommandWrapper; fn as_command_wrapper_mut(&mut self) -> &mut dyn CommandWrapper; @@ -61,10 +69,10 @@ macro_rules! Wrap { /// any platform capabilities required by registered wrappers. The transaction must be fresh, /// armed, independently owned from the child chain, and able to undo this specific spawn until /// process-wrap commits it. On Windows, that includes every ordinary child-finalization and - /// cleanup-disarm hook plus JobObject owner validation and non-owner disarming. Only the sole - /// JobObject owner hook, if present, runs after commit. After that owner disarms, process-wrap - /// transfers the committed transaction residue in a private transparent layer with the returned - /// child. + /// cleanup-disarm hook plus JobObject owner validation and non-owner disarming. Process-wrap + /// preallocates the private return sidecar before commit, installs committed residue before the + /// sole JobObject owner hook, and performs only infallible private moves after that owner + /// disarms. #[derive(Debug)] pub struct ProviderProduct { child: Box, @@ -101,23 +109,13 @@ macro_rules! Wrap { } #[cfg(windows)] - struct PreparedLayerOwner { - layer_identity: usize, - retains: fn( - &mut dyn $childer, - usize, - &::std::sync::Arc, - ) -> bool, - } - - #[cfg(windows)] - struct PendingPreparedChild { + struct PreparedChildOwner { state: ::std::sync::Arc, - layer_owner: Option, + installed_layer: Option<(::std::any::TypeId, usize)>, } #[cfg(windows)] - impl PendingPreparedChild { + impl PreparedChildOwner { fn new(value: Box) -> Self { let type_id = value.as_ref().type_id(); Self { @@ -125,7 +123,7 @@ macro_rules! Wrap { value: ::std::sync::Mutex::new(Some(value)), type_id, }), - layer_owner: None, + installed_layer: None, } } @@ -133,9 +131,9 @@ macro_rules! Wrap { PreparedChildRef { state: &self.state } } - fn layer_owner(&self) -> PreparedChild { + fn installed_token(&self) -> PreparedChild { PreparedChild { - state: ::std::sync::Arc::clone(&self.state), + state: ::std::sync::Arc::downgrade(&self.state), } } @@ -147,11 +145,20 @@ macro_rules! Wrap { .take() } - fn has_exact_layer_owner(&self, child: &mut dyn $childer) -> bool { - ::std::sync::Arc::strong_count(&self.state) == 2 - && self.layer_owner.as_ref().is_some_and(|owner| { - (owner.retains)(child, owner.layer_identity, &self.state) - }) + fn has_exclusive_custody(&self) -> bool { + ::std::sync::Arc::strong_count(&self.state) == 1 + } + + fn has_value(&self) -> bool { + self.state + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .is_some() + } + + fn installed_in(&self, layer: (::std::any::TypeId, usize)) -> bool { + self.installed_layer == Some(layer) } } @@ -182,35 +189,38 @@ macro_rules! Wrap { } } - /// Prepared state installed in a matching child layer by process-wrap. - /// - /// This handle is deliberately not `Clone`, and arbitrary mutable access is not public. A - /// layer may inspect its installed value immutably with [`PreparedChild::with`]. Resource - /// types which themselves expose interior mutation or independently clonable native owners - /// remain responsible for those capabilities; this handle never exposes process-wrap's - /// pending custody owner. - /// - /// A detached layer cannot manufacture an installed handle: - /// - /// ```compile_fail - /// # use process_wrap::std::PreparedChild; - /// fn retain_extra_owner(prepared: &PreparedChild) { - /// let _escaped: PreparedChild = prepared.clone(); - /// } - /// ``` - /// - /// Nor can downstream code move fields out through arbitrary mutable access: - /// - /// ```compile_fail - /// # use process_wrap::std::PreparedChild; - /// fn take_guard(prepared: &PreparedChild) { - /// let _guard = prepared.with_mut::, _>(Option::take); - /// } - /// ``` + #[doc = concat!( + "Prepared state installed in a matching child layer by process-wrap.\n\n", + "This token is deliberately non-owning and not `Clone`, and arbitrary mutable access is ", + "not public. A layer may inspect its installed value immutably with ", + "[`PreparedChild::with`] while the returned child retains process-wrap's private owner. ", + "Moving or retaining this token cannot prolong the prepared value's lifetime. Resource ", + "types which themselves expose interior mutation or independently clonable native owners ", + "remain responsible for those capabilities.\n\n", + "On native Windows success, a private outer sidecar retains the strong prepared storage. ", + "It delegates the full child contract, exposes the immediate application layer through ", + "`inner` and `inner_mut`, and consumes that application layer through `into_inner`. Its ", + "private concrete type is the returned trait object's top-level `Any` identity while ", + "prepared storage remains installed.\n\n", + "A detached layer cannot manufacture an additional token:\n\n", + "```compile_fail,E0308\n", + "# use process_wrap::", $frontend, "::PreparedChild;\n", + "fn retain_extra_token(prepared: &PreparedChild) {\n", + " let _escaped: PreparedChild = prepared.clone();\n", + "}\n", + "```\n\n", + "Nor can downstream code move fields out through process-wrap's private extraction API:\n\n", + "```compile_fail\n", + "# use process_wrap::", $frontend, "::PreparedChild;\n", + "fn take_guard(prepared: &PreparedChild) {\n", + " let _guard = prepared.take_prepared_value::>();\n", + "}\n", + "```" + )] #[cfg(windows)] #[doc(hidden)] pub struct PreparedChild { - state: ::std::sync::Arc, + state: ::std::sync::Weak, } #[cfg(windows)] @@ -222,27 +232,32 @@ macro_rules! Wrap { #[cfg(windows)] impl PreparedChild { - /// Inspect installed prepared state immutably. + /// Inspect installed prepared state immutably while its private owner remains live. #[doc(hidden)] pub fn with(&self, inspect: impl FnOnce(&T) -> R) -> Option { - let value = self - .state + let state = self.state.upgrade()?; + let value = state .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); value.as_deref()?.downcast_ref::().map(inspect) } - pub(crate) fn with_mut( - &self, - mutate: impl FnOnce(&mut T) -> R, - ) -> Option { - let mut value = self - .state + #[cfg(feature = "job-object")] + pub(crate) fn take_prepared_value(&self) -> Option { + let state = self.state.upgrade()?; + let mut slot = state .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); - value.as_deref_mut()?.downcast_mut::().map(mutate) + let value = slot.take()?; + match value.downcast::() { + Ok(value) => Some(*value), + Err(value) => { + *slot = Some(value); + None + } + } } } @@ -274,8 +289,9 @@ macro_rules! Wrap { /// Describe an empty prepared-state slot paired with concrete state type `T`. /// - /// Process-wrap verifies the pending value's concrete type and installs the only layer - /// custody handle after the wrapping callback returns successfully. + /// Process-wrap verifies the pending value's concrete type and installs a non-owning token + /// after the wrapping callback returns successfully. The private strong owner remains in + /// process-wrap storage. #[cfg(windows)] #[doc(hidden)] pub fn with_prepared( @@ -295,12 +311,10 @@ macro_rules! Wrap { /// Implement this trait for a layer returned through [`PendingChildWrapper::new`]. The layer's /// child slot must be empty until process-wrap installs the current child. pub trait ChildWrapperLayer: $childer { - /// Expose this layer's stable ownership slots. + /// Expose this detached layer's ownership slots. /// - /// The slots are empty on the first call, while the layer is detached. Process-wrap may call - /// this method again after installation to verify that the exact layer still owns the child - /// and prepared-state handles it installed. Every call must return the same logical slots - /// without moving their contents or performing unrelated work. + /// Process-wrap calls this method once during installation. The child slot and any declared + /// prepared-state token slot must be empty at that point. fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_>; } @@ -313,7 +327,7 @@ macro_rules! Wrap { install: fn( &mut dyn $childer, &mut Option>, - #[cfg(windows)] Option<&mut PendingPreparedChild>, + #[cfg(windows)] Option<&mut PreparedChildOwner>, ) -> ::std::io::Result<()>, } @@ -338,50 +352,10 @@ macro_rules! Wrap { } } - #[cfg(windows)] - fn layer_retains_prepared( - mut child: &mut dyn $childer, - layer_identity: usize, - state: &::std::sync::Arc, - ) -> bool - where - L: ChildWrapperLayer + 'static, - { - loop { - if (&*child as &dyn ::std::any::Any).type_id() - == ::std::any::TypeId::of::() - { - let layer = (child as &mut dyn ::std::any::Any) - .downcast_mut::() - .expect("a matching child layer retains its concrete type"); - let identity = ::std::ptr::from_mut(layer).cast::<()>() as usize; - if identity == layer_identity { - let slots = layer.child_wrapper_slots(); - return slots.prepared.is_some_and(|slot| { - slot.expected_type == state.type_id - && slot.value.as_ref().is_some_and(|prepared| { - ::std::sync::Arc::ptr_eq(&prepared.state, state) - }) - }); - } - } - - let child_type = (&*child as &dyn ::std::any::Any).type_id(); - let child_ptr = ::std::ptr::from_mut(child); - let next = child.inner_mut(); - if ::std::ptr::addr_eq(child_ptr, ::std::ptr::from_mut(next)) - && child_type == (&*next as &dyn ::std::any::Any).type_id() - { - return false; - } - child = next; - } - } - fn install_layer( layer: &mut dyn $childer, child: &mut Option>, - #[cfg(windows)] prepared: Option<&mut PendingPreparedChild>, + #[cfg(windows)] prepared: Option<&mut PreparedChildOwner>, ) -> ::std::io::Result<()> where L: ChildWrapperLayer + 'static, @@ -390,7 +364,10 @@ macro_rules! Wrap { .downcast_mut::() .expect("a pending child layer retains its concrete type"); #[cfg(windows)] - let layer_identity = ::std::ptr::from_mut(&mut *layer).cast::<()>() as usize; + let installed_layer = ( + ::std::any::TypeId::of::(), + ::std::ptr::from_mut(&mut *layer).cast::<()>() as usize, + ); let slots = layer.child_wrapper_slots(); if slots.child.is_some() { return Err(::std::io::Error::new( @@ -407,16 +384,12 @@ macro_rules! Wrap { "prepared child state does not match the layer slot type", )); } - debug_assert!(prepared.layer_owner.is_none()); - prepared.layer_owner = Some(PreparedLayerOwner { - layer_identity, - retains: Self::layer_retains_prepared::, - }); - *slot.value = Some(prepared.layer_owner()); + prepared.installed_layer = Some(installed_layer); + *slot.value = Some(prepared.installed_token()); #[cfg(test)] if crate::windows::test_support::take_extra_prepared_owner_injection() { crate::windows::test_support::retain_extra_prepared_owner(Box::new( - prepared.layer_owner(), + ::std::sync::Arc::clone(&prepared.state), )); } } @@ -447,7 +420,7 @@ macro_rules! Wrap { fn install( &mut self, child: &mut Option>, - #[cfg(windows)] prepared: Option<&mut PendingPreparedChild>, + #[cfg(windows)] prepared: Option<&mut PreparedChildOwner>, ) -> ::std::io::Result<()> { (self.install)( self.layer.as_mut(), @@ -508,63 +481,243 @@ macro_rules! Wrap { } } - fn transfer( - &mut self, - child: Box, - #[cfg(windows)] prepared: Vec>, - ) -> Box { + fn transfer_into(&mut self, sidecar: &mut CommittedProviderChild) { let Self::Committed(transaction) = ::std::mem::replace(self, Self::Transferred) else { unreachable!("only committed transaction residue can transfer to a child"); }; - Box::new(CommittedProviderChild { - child, - residue: ::std::sync::Arc::new(::std::sync::Mutex::new(transaction)), - #[cfg(windows)] + sidecar.install_residue(transaction); + } + } + + #[cfg(windows)] + struct PreparedOwnerChild { + // Field order is intentional: the complete child chain drops before prepared storage. + child: Option>, + prepared: Vec>, + } + + #[cfg(windows)] + impl PreparedOwnerChild { + fn new( + child: Box, + prepared: Vec>, + ) -> Box { + debug_assert!(prepared.iter().any(Option::is_some)); + Box::new(Self { + child: Some(child), prepared, }) } + + fn child_ref(&self) -> &dyn $childer { + self.child + .as_deref() + .expect("a prepared-owner sidecar retains its child") + } + + fn child_mut(&mut self) -> &mut dyn $childer { + self.child + .as_deref_mut() + .expect("a prepared-owner sidecar retains its child") + } + + fn take_child(&mut self) -> Option> { + self.child.take() + } + } + + #[cfg(windows)] + impl ::std::fmt::Debug for PreparedOwnerChild { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.child_ref().fmt(formatter) + } + } + + #[cfg(windows)] + impl $childer for PreparedOwnerChild { + fn inner(&self) -> &dyn $childer { + self.child_ref() + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self.child_mut() + } + + fn into_inner(mut self: Box) -> Box { + let removed_layer = ( + (self.child_ref() as &dyn ::std::any::Any).type_id(), + ::std::ptr::from_mut(self.child_mut()).cast::<()>() as usize, + ); + #[cfg(feature = "job-object")] + self.child_mut().retain_prepared_after_sidecar_removal(); + let child = self + .take_child() + .expect("a prepared-owner sidecar retains its child"); + let inner = child.into_inner(); + self.prepared.retain(|prepared| { + prepared.as_ref().is_some_and(|prepared| { + prepared.has_value() && !prepared.installed_in(removed_layer) + }) + }); + if self.prepared.is_empty() { + inner + } else { + self.child = Some(inner); + self + } + } + + fn try_clone(&self) -> Option> { + None + } + + #[cfg(windows)] + fn process_handle(&self) -> Option<::std::os::windows::io::BorrowedHandle<'_>> { + self.child_ref().process_handle() + } + + #[cfg(windows)] + fn resume_after_job_assignment(&mut self) -> Option<::std::io::Result<()>> { + self.child_mut().resume_after_job_assignment() + } + + $prepared_owner_contract!(); + } + + #[cfg(windows)] + enum NativeSuccessChild { + Plain(Box), + Prepared(Box), + } + + #[cfg(windows)] + impl NativeSuccessChild { + fn new( + child: Box, + prepared: Vec>, + ) -> Self { + if prepared.iter().any(Option::is_some) { + Self::Prepared(PreparedOwnerChild::new(child, prepared)) + } else { + Self::Plain(child) + } + } + + fn child_mut(&mut self) -> &mut dyn $childer { + match self { + Self::Plain(child) => child.as_mut(), + Self::Prepared(child) => child.as_mut(), + } + } + + fn into_child(self) -> Box { + match self { + Self::Plain(child) => child, + Self::Prepared(child) => child, + } + } } struct CommittedProviderChild { - child: Box, - residue: ::std::sync::Arc<::std::sync::Mutex>>, + // Field order is intentional: child layers drop before transaction and prepared residue. + child: Option>, + residue: ::std::sync::Arc< + ::std::sync::Mutex>>, + >, #[cfg(windows)] - prepared: Vec>, + prepared: Vec>, + } + + impl CommittedProviderChild { + fn new( + child: Box, + #[cfg(windows)] prepared: Vec>, + ) -> Box { + Box::new(Self { + child: Some(child), + residue: ::std::sync::Arc::new(::std::sync::Mutex::new(None)), + #[cfg(windows)] + prepared, + }) + } + + fn child_ref(&self) -> &dyn $childer { + self.child + .as_deref() + .expect("a committed-provider sidecar retains its child") + } + + fn child_mut(&mut self) -> &mut dyn $childer { + self.child + .as_deref_mut() + .expect("a committed-provider sidecar retains its child") + } + + fn take_child(&mut self) -> Option> { + self.child.take() + } + + fn install_residue(&mut self, transaction: Box) { + let mut residue = self + .residue + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + debug_assert!(residue.is_none()); + *residue = Some(transaction); + } + + fn take_residue(&mut self) -> Option> { + self.residue + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + } } impl ::std::fmt::Debug for CommittedProviderChild { fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - self.child.fmt(formatter) + self.child_ref().fmt(formatter) } } impl $childer for CommittedProviderChild { fn inner(&self) -> &dyn $childer { - self.child.as_ref() + self.child_ref() } fn inner_mut(&mut self) -> &mut dyn $childer { - self.child.as_mut() + self.child_mut() } - fn into_inner(self: Box) -> Box { - let Self { - child, - residue: _, - #[cfg(windows)] - prepared: _, - } = *self; + fn into_inner(mut self: Box) -> Box { + #[cfg(all(windows, feature = "job-object"))] + self.child_mut().retain_prepared_after_sidecar_removal(); + let child = self + .take_child() + .expect("a committed-provider sidecar retains its child"); + #[cfg(windows)] + { + let mut prepared = ::std::mem::take(&mut self.prepared); + prepared.retain(|prepared| { + prepared + .as_ref() + .is_some_and(PreparedChildOwner::has_value) + }); + if !prepared.is_empty() { + return PreparedOwnerChild::new(child, prepared); + } + } child } #[cfg(windows)] fn process_handle(&self) -> Option<::std::os::windows::io::BorrowedHandle<'_>> { - self.child.process_handle() + self.child_ref().process_handle() } #[cfg(windows)] fn resume_after_job_assignment(&mut self) -> Option<::std::io::Result<()>> { - self.child.resume_after_job_assignment() + self.child_mut().resume_after_job_assignment() } fn try_clone(&self) -> Option> { @@ -572,9 +725,9 @@ macro_rules! Wrap { if self.prepared.iter().any(Option::is_some) { return None; } - self.child.try_clone().map(|child| { + self.child_ref().try_clone().map(|child| { Box::new(Self { - child, + child: Some(child), residue: ::std::sync::Arc::clone(&self.residue), #[cfg(windows)] prepared: self.prepared.iter().map(|_| None).collect(), @@ -593,13 +746,13 @@ macro_rules! Wrap { /// Process-wrap invokes provider callbacks in this order: `check_available`, native-only base /// rejection, `validate_command`, every `pre_spawn` hook in registration order, native-only /// attempt rejection, `validate_attempt`, and `spawn`. After `spawn` returns a product, every - /// `post_spawn` and child-wrapping hook runs in registration order. On Windows, process-wrap then - /// completes the pre-commit child phase while provider rollback remains armed, commits the - /// transaction, and disarms the sole JobObject cleanup owner. Successful commit ends failed-spawn - /// rollback. After the final owner succeeds, process-wrap transfers the committed transaction - /// residue in a private transparent layer with the returned child. On the successful path, - /// arbitrary residue destruction occurs outside the spawn lifecycle. `spawn_with` and - /// `spawn_with_child` reject a registered provider instead of bypassing it. + /// `post_spawn` and child-wrapping hook runs in registration order. Process-wrap then completes + /// the pre-commit child phase while provider rollback remains armed and preallocates the private + /// return sidecar. It commits the transaction, installs the committed residue in that sidecar, and, + /// on Windows, disarms the sole JobObject cleanup owner. Successful commit ends failed-spawn + /// rollback. After the final owner succeeds, only infallible private ownership moves remain before + /// return. Arbitrary residue destruction occurs outside the successful spawn lifecycle. `spawn_with` + /// and `spawn_with_child` reject a registered provider instead of bypassing it. /// /// A committed transaction residue must retain no armed cleanup or independent process, /// terminal, controller, handle, pseudoconsole, or other liveness resource. @@ -810,7 +963,7 @@ macro_rules! Wrap { &mut self, attempt: &mut SpawnAttempt, child: &mut dyn $childer, - prepared: &mut Vec>, + prepared: &mut Vec>, ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { @@ -829,7 +982,7 @@ macro_rules! Wrap { wrapper.prepare_child(attempt, child, command) }) })?; - prepared.push(value.map(PendingPreparedChild::new)); + prepared.push(value.map(PreparedChildOwner::new)); } Ok(()) @@ -868,7 +1021,7 @@ macro_rules! Wrap { &mut self, child: &mut Option>, pending: &mut Option, - #[cfg(windows)] prepared: &mut [Option], + #[cfg(windows)] prepared: &mut [Option], ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { @@ -891,7 +1044,7 @@ macro_rules! Wrap { { wrapper.wrap_prepared_child( child, - prepared[index].as_ref().map(PendingPreparedChild::view), + prepared[index].as_ref().map(PreparedChildOwner::view), command, ) } @@ -931,12 +1084,11 @@ macro_rules! Wrap { } #[cfg(windows)] - fn validate_prepared_topology( - child: &mut dyn $childer, - prepared: &[Option], + fn validate_prepared_custody( + prepared: &[Option], ) -> ::std::io::Result<()> { for prepared in prepared.iter().flatten() { - if !prepared.has_exact_layer_owner(child) { + if !prepared.has_exclusive_custody() { return Err(::std::io::Error::new( ::std::io::ErrorKind::InvalidInput, "prepared child state has an unexpected custody topology", @@ -946,21 +1098,6 @@ macro_rules! Wrap { Ok(()) } - #[cfg(windows)] - fn capture_prepared_topology( - child: &mut Option>, - prepared: &[Option], - ) -> ::std::result::Result<(), SpawnFailure> { - Self::capture_io(|| { - Self::validate_prepared_topology( - child - .as_deref_mut() - .expect("the lifecycle retains child custody during topology checks"), - prepared, - ) - }) - } - fn finish_spawn( &mut self, attempt: &mut SpawnAttempt, @@ -1010,7 +1147,7 @@ macro_rules! Wrap { } }; - let result: ::std::result::Result<(), SpawnFailure> = (|| { + let before_owner: ::std::result::Result<(), SpawnFailure> = (|| { #[cfg(windows)] self.run_prepare_child( attempt, @@ -1032,44 +1169,63 @@ macro_rules! Wrap { &mut prepared, )?; #[cfg(windows)] - Self::capture_prepared_topology(&mut child, &prepared)?; - #[cfg(windows)] - { - let final_owner = Self::capture_io(|| { - child - .as_deref_mut() - .expect("the native lifecycle retains child custody") - .finalize_spawn_before_commit() - })?; - Self::capture_io(|| { - child - .as_deref_mut() - .expect("the native lifecycle retains child custody") - .finalize_spawn_final_owner(final_owner) - })?; - Self::capture_prepared_topology(&mut child, &prepared)?; - } + Self::capture_io(|| Self::validate_prepared_custody(&prepared))?; Ok(()) })(); - match result { - Ok(()) => { - #[cfg(windows)] - if let Some(cleanup) = cleanup.as_mut() { - cleanup.disarm(); + if let Err(failure) = before_owner { + #[cfg(windows)] + drop(cleanup.take()); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + #[cfg(windows)] + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); + } + + #[cfg(not(windows))] + return Ok(child + .take() + .expect("the completed native lifecycle retains its child")); + + #[cfg(windows)] + { + let child = child + .take() + .expect("the native lifecycle retains its child before finalization"); + let mut success = NativeSuccessChild::new( + child, + ::std::mem::take(&mut prepared), + ); + let final_owner = match Self::capture_io(|| { + success.child_mut().finalize_spawn_before_commit() + }) { + Ok(owner) => owner, + Err(failure) => { + drop(cleanup.take()); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_native_success(success); + return failure.finish::>(); + } + }; + let final_result = Self::capture_io(|| { + success + .child_mut() + .finalize_spawn_final_owner(final_owner) + }); + match final_result { + Ok(()) => { + if let Some(cleanup) = cleanup.as_mut() { + cleanup.disarm(); + } + Ok(success.into_child()) + } + Err(failure) => { + drop(cleanup.take()); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_native_success(success); + failure.finish::>() } - Ok(child - .take() - .expect("the completed native lifecycle retains its child")) - } - Err(failure) => { - #[cfg(windows)] - drop(cleanup.take()); - Self::cleanup_pending_child(&mut pending); - Self::cleanup_child(&mut child); - #[cfg(windows)] - Self::cleanup_prepared(&mut prepared); - failure.finish::>() } } } @@ -1100,7 +1256,7 @@ macro_rules! Wrap { } #[cfg(windows)] - fn cleanup_prepared(prepared: &mut [Option]) { + fn cleanup_prepared(prepared: &mut [Option]) { for prepared in prepared.iter_mut().filter_map(Option::take) { if let Some(value) = prepared.take() { Self::dispose_value(value); @@ -1108,6 +1264,18 @@ macro_rules! Wrap { } } + #[cfg(windows)] + fn cleanup_native_success(success: NativeSuccessChild) { + match success { + NativeSuccessChild::Plain(child) => Self::dispose_value(child), + NativeSuccessChild::Prepared(mut sidecar) => { + Self::cleanup_child(&mut sidecar.child); + Self::cleanup_prepared(&mut sidecar.prepared); + Self::dispose_value(sidecar); + } + } + } + fn dispose_transaction(transaction: Box) { Self::dispose_value(transaction); } @@ -1131,6 +1299,18 @@ macro_rules! Wrap { } } + fn cleanup_provider_residue(sidecar: &mut CommittedProviderChild) { + if let Some(transaction) = sidecar.take_residue() { + Self::dispose_transaction(transaction); + } + } + + fn cleanup_provider_child(sidecar: &mut CommittedProviderChild) { + Self::cleanup_child(&mut sidecar.child); + #[cfg(windows)] + Self::cleanup_prepared(&mut sidecar.prepared); + } + fn finish_provider_spawn( &mut self, attempt: &mut SpawnAttempt, @@ -1142,7 +1322,7 @@ macro_rules! Wrap { let mut transaction = ProviderTransactionState::new(transaction); #[cfg(windows)] let mut prepared = Vec::with_capacity(self.wrapper_registry().wrappers.len()); - let result: ::std::result::Result<(), SpawnFailure> = (|| { + let before_commit: ::std::result::Result<(), SpawnFailure> = (|| { #[cfg(windows)] self.run_prepare_child( attempt, @@ -1164,49 +1344,70 @@ macro_rules! Wrap { &mut prepared, )?; #[cfg(windows)] - Self::capture_prepared_topology(&mut child, &prepared)?; - #[cfg(windows)] - let final_owner = Self::capture_io(|| { - child - .as_deref_mut() - .expect("the provider lifecycle retains child custody") - .finalize_spawn_before_commit() - })?; - #[cfg(windows)] - Self::capture_prepared_topology(&mut child, &prepared)?; - Self::capture_io(|| transaction.commit())?; - #[cfg(windows)] - Self::capture_io(|| { - child - .as_deref_mut() - .expect("the provider lifecycle retains child custody") - .finalize_spawn_final_owner(final_owner) - })?; - #[cfg(windows)] - Self::capture_prepared_topology(&mut child, &prepared)?; + Self::capture_io(|| Self::validate_prepared_custody(&prepared))?; Ok(()) })(); - match result { - Ok(()) => { - let child = child - .take() - .expect("the completed provider lifecycle retains its child"); - Ok(transaction.transfer( - child, - #[cfg(windows)] - prepared, - )) - } + if let Err(failure) = before_commit { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + #[cfg(windows)] + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); + } + + #[cfg(windows)] + let final_owner = match Self::capture_io(|| { + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody") + .finalize_spawn_before_commit() + }) { + Ok(owner) => owner, Err(failure) => { Self::cleanup_failed_transaction(&mut transaction); Self::cleanup_pending_child(&mut pending); Self::cleanup_child(&mut child); - #[cfg(windows)] Self::cleanup_prepared(&mut prepared); - failure.finish::>() + return failure.finish::>(); + } + }; + + let child = child + .take() + .expect("the provider lifecycle retains its child before commit"); + let mut sidecar = CommittedProviderChild::new( + child, + #[cfg(windows)] + ::std::mem::take(&mut prepared), + ); + if let Err(failure) = Self::capture_io(|| transaction.commit()) { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_provider_residue(&mut sidecar); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_provider_child(&mut sidecar); + return failure.finish::>(); + } + transaction.transfer_into(&mut sidecar); + + #[cfg(windows)] + { + let final_result = Self::capture_io(|| { + sidecar + .child_mut() + .finalize_spawn_final_owner(final_owner) + }); + if let Err(failure) = final_result { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_provider_residue(&mut sidecar); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_provider_child(&mut sidecar); + return failure.finish::>(); } } + + Ok(sidecar) } fn spawn_with_provider( diff --git a/src/lib.rs b/src/lib.rs index 08e2e60..e858d6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -277,9 +277,10 @@ //! 7. provider `spawn` //! 8. every `post_spawn` hook //! 9. every child wrapper -//! 10. transaction `commit` -//! 11. on Windows, the sole JobObject cleanup owner -//! 12. committed transaction-residue transfer to the returned child +//! 10. private return-sidecar allocation +//! 11. transaction `commit` +//! 12. committed transaction-residue installation +//! 13. on Windows, the sole JobObject cleanup owner //! //! Validation rejects unsupported portable policy before operating-system allocation. `spawn` //! returns a child satisfying the frontend's complete `ChildWrapper` contract and a fresh, armed @@ -288,12 +289,12 @@ //! while preserving the original failure. After capturing that failure, process-wrap first resolves //! transaction cleanup, then disposes any detached child layer, the child chain, and each Windows //! prepared value independently. Secondary cleanup panic payloads are quarantined without inspection -//! or destruction. Successful commit ends failed-spawn rollback. On Windows, the sole JobObject owner -//! remains authoritative until it disarms after commit. Process-wrap then transfers the committed -//! transaction residue in a private transparent layer with the returned child. On a successful spawn, -//! arbitrary residue destruction occurs outside the spawn lifecycle. Committed residue must retain no -//! armed cleanup or independent process, terminal, controller, handle, pseudoconsole, or other -//! liveness resource. +//! or destruction. Before commit, process-wrap preallocates the private return sidecar. Successful +//! commit ends failed-spawn rollback, and its residue is installed in that sidecar. On Windows, the +//! sole JobObject owner remains authoritative until it disarms after commit; no caller callback or +//! allocation-dependent work follows that transition. On a successful spawn, arbitrary residue +//! destruction occurs outside the spawn lifecycle. Committed residue must retain no armed cleanup or +//! independent process, terminal, controller, handle, pseudoconsole, or other liveness resource. //! //! Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic //! containment apply only to unwinding panics. With `panic=abort`, the process terminates before diff --git a/src/std/core.rs b/src/std/core.rs index 13b2edc..440d958 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -15,12 +15,65 @@ use nix::{ unistd::Pid, }; +#[cfg(windows)] +macro_rules! prepared_owner_child_contract { + () => { + fn stdin(&mut self) -> &mut Option { + self.child_mut().stdin() + } + + fn stdout(&mut self) -> &mut Option { + self.child_mut().stdout() + } + + fn stderr(&mut self) -> &mut Option { + self.child_mut().stderr() + } + + fn id(&self) -> u32 { + self.child_ref().id() + } + + fn kill(&mut self) -> Result<()> { + self.child_mut().kill() + } + + fn start_kill(&mut self) -> Result<()> { + self.child_mut().start_kill() + } + + fn try_wait(&mut self) -> Result> { + self.child_mut().try_wait() + } + + fn wait(&mut self) -> Result { + self.child_mut().wait() + } + + fn wait_with_output(mut self: Box) -> Result { + let child = self + .take_child() + .expect("a prepared-owner sidecar retains its child"); + let output = child.wait_with_output(); + drop(self); + output + } + + #[cfg(unix)] + fn signal(&self, sig: i32) -> Result<()> { + self.child_ref().signal(sig) + } + }; +} + crate::generic_wrap::Wrap!( crate::Blocking, NativeCommand, Child, ChildWrapper, - |child| child + |child| child, + "std", + prepared_owner_child_contract ); /// Wrapper for `std::process::Child`. @@ -157,6 +210,14 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { Ok(()) } + /// Move built-in prepared state into this layer before its private owner sidecar is removed. + /// + /// This hook is used only during caller-initiated, post-transfer consuming extraction. Custom + /// layers cannot obtain process-wrap's strong prepared owner through it. + #[doc(hidden)] + #[cfg(all(windows, feature = "job-object"))] + fn retain_prepared_after_sidecar_removal_layer(&mut self) {} + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -506,6 +567,23 @@ impl dyn ChildWrapper + '_ { } } + #[cfg(all(windows, feature = "job-object"))] + pub(crate) fn retain_prepared_after_sidecar_removal(&mut self) { + let mut inner = self; + loop { + inner.retain_prepared_after_sidecar_removal_layer(); + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return; + } + inner = next; + } + } + /// Try to obtain a reference to the underlying native [`Child`]. /// /// Returns `None` if the wrapper chain terminates in a non-native child. diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 68c33f3..004b44e 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -159,6 +159,7 @@ impl CommandWrapper for JobObject { pub struct JobObjectChild { inner: Option>, prepared: Option, + extracted: Option, exit_status: ChildExitStatus, job_drained: bool, final_kill_on_drop: bool, @@ -170,6 +171,7 @@ impl JobObjectChild { Self { inner: None, prepared: None, + extracted: None, exit_status: ChildExitStatus::Running, job_drained: false, final_kill_on_drop, @@ -196,6 +198,14 @@ impl JobObjectChild { } fn with_job_port(&self, inspect: impl FnOnce(&JobPort) -> R) -> R { + if let Some(prepared) = self.extracted.as_ref() { + return inspect( + prepared + .job_port + .as_ref() + .expect("the extracted JobObject layer retains its job handles"), + ); + } self.prepared() .with::(|prepared| { inspect( @@ -207,6 +217,15 @@ impl JobObjectChild { }) .expect("JobObject prepared state retains its concrete type") } + + fn take_prepared(&mut self) -> PreparedJobObject { + if let Some(prepared) = self.extracted.take() { + return prepared; + } + self.prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type") + } } impl ChildWrapperLayer for JobObjectChild { @@ -263,25 +282,31 @@ impl ChildWrapper for JobObjectChild { .take() .expect("an installed JobObject layer owns its child"); if self.spawn_finalized && self.final_kill_on_drop { - self.prepared() - .with_mut::(|prepared| { - let job_port = prepared - .job_port - .take() - .expect("the installed JobObject layer retains its job handles"); - // Manually close the completion port while retaining the job handle. Closing a - // kill-on-close job here would make the extracted child unusable. - let job_port = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); - }) - .expect("JobObject prepared state retains its concrete type"); + let mut prepared = self.take_prepared(); + let job_port = prepared + .job_port + .take() + .expect("the installed JobObject layer retains its job handles"); + // Manually close the completion port while retaining the job handle. Closing a + // kill-on-close job here would make the extracted child unusable. + let job_port = std::mem::ManuallyDrop::new(job_port); + // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. + unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); } // Before spawn finalization, dropping the still-armed prepared job instead guarantees that // removing this layer cannot let descendants escape a later lifecycle failure. inner } + fn retain_prepared_after_sidecar_removal_layer(&mut self) { + if self.extracted.is_none() { + let prepared = self + .prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type"); + self.extracted = Some(prepared); + } + } fn process_handle(&self) -> Option> { self.inner_ref().try_process_handle() } @@ -309,35 +334,47 @@ impl ChildWrapper for JobObjectChild { let Self { inner, prepared, + extracted, exit_status, job_drained, .. } = self; - let prepared = prepared - .as_ref() - .expect("an installed JobObject layer owns its prepared state"); let inner = inner .as_deref_mut() .expect("an installed JobObject layer owns its child"); - wait_for_exit_and_job_drain_with( - exit_status, - job_drained, - || inner.wait(), - |timeout| { - prepared - .with::(|prepared| { - let job_port = prepared - .job_port - .as_ref() - .expect("the installed JobObject layer retains its job handles"); - poll_job_drain(job_port.job, job_port.completion_port.as_handle(), timeout) - }) - .expect("JobObject prepared state retains its concrete type") - }, - Instant::now, - std::thread::sleep, - |_| {}, - ) + let mut wait_with = |job_port: &JobPort| { + wait_for_exit_and_job_drain_with( + exit_status, + job_drained, + || inner.wait(), + |timeout| { + poll_job_drain(job_port.job, job_port.completion_port.as_handle(), timeout) + }, + Instant::now, + std::thread::sleep, + |_| {}, + ) + }; + if let Some(extracted) = extracted.as_ref() { + return wait_with( + extracted + .job_port + .as_ref() + .expect("the extracted JobObject layer retains its job handles"), + ); + } + prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state") + .with::(|prepared| { + wait_with( + prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"), + ) + }) + .expect("JobObject prepared state retains its concrete type") } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -670,7 +707,7 @@ mod tests { let paths = TreePaths::new(directory.path()); let state = Arc::new(LifecycleState::default()); let slot_calls = Arc::new(AtomicUsize::new(0)); - let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let events = Arc::new(std::sync::Mutex::new(Vec::with_capacity(2))); let mut command = topology_command(provider, &paths, &state)?; command.wrap(PostOwnerAccessor { slot_calls: Arc::clone(&slot_calls), diff --git a/src/tokio/core.rs b/src/tokio/core.rs index bc795a7..70984eb 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -20,9 +20,84 @@ use tokio::{ process::{Child, ChildStderr, ChildStdin, ChildStdout, Command as NativeCommand}, }; -crate::generic_wrap::Wrap!(crate::Tokio1, NativeCommand, Child, ChildWrapper, |child| { - child -}); +#[cfg(windows)] +macro_rules! prepared_owner_child_contract { + () => { + #[cfg(all( + unix, + feature = "pty", + any(feature = "process-group", feature = "process-session") + ))] + fn spawned_id_layer(&self) -> Option { + self.child_ref().spawned_id_layer() + } + + #[cfg(feature = "pty")] + fn take_pty_controller_layer(&mut self) -> Option { + self.child_mut().take_pty_controller_layer() + } + + fn stdin(&mut self) -> &mut Option { + self.child_mut().stdin() + } + + fn stdout(&mut self) -> &mut Option { + self.child_mut().stdout() + } + + fn stderr(&mut self) -> &mut Option { + self.child_mut().stderr() + } + + fn id(&self) -> Option { + self.child_ref().id() + } + + fn kill(&mut self) -> Box> + Send + '_> { + self.child_mut().kill() + } + + fn start_kill(&mut self) -> Result<()> { + self.child_mut().start_kill() + } + + fn try_wait(&mut self) -> Result> { + self.child_mut().try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + self.child_mut().wait() + } + + fn wait_with_output( + mut self: Box, + ) -> Box> + Send> { + let child = self + .take_child() + .expect("a prepared-owner sidecar retains its child"); + Box::new(async move { + let output = Box::into_pin(child.wait_with_output()).await; + drop(self); + output + }) + } + + #[cfg(unix)] + fn signal(&self, sig: i32) -> Result<()> { + self.child_ref().signal(sig) + } + }; +} + +crate::generic_wrap::Wrap!( + crate::Tokio1, + NativeCommand, + Child, + ChildWrapper, + |child| { child }, + "tokio", + prepared_owner_child_contract +); /// Wrapper for `tokio::process::Child`. /// @@ -180,6 +255,14 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { Ok(()) } + /// Move built-in prepared state into this layer before its private owner sidecar is removed. + /// + /// This hook is used only during caller-initiated, post-transfer consuming extraction. Custom + /// layers cannot obtain process-wrap's strong prepared owner through it. + #[doc(hidden)] + #[cfg(all(windows, feature = "job-object"))] + fn retain_prepared_after_sidecar_removal_layer(&mut self) {} + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -572,6 +655,23 @@ impl dyn ChildWrapper + '_ { } } + #[cfg(all(windows, feature = "job-object"))] + pub(crate) fn retain_prepared_after_sidecar_removal(&mut self) { + let mut inner = self; + loop { + inner.retain_prepared_after_sidecar_removal_layer(); + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return; + } + inner = next; + } + } + /// Try to obtain a reference to the underlying native [`Child`]. /// /// Returns `None` if the wrapper chain terminates in a non-native child. diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 134c389..5488662 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -164,6 +164,7 @@ impl CommandWrapper for JobObject { pub struct JobObjectChild { inner: Option>, prepared: Option, + extracted: Option, exit_status: ChildExitStatus, job_drained: bool, poll_cadence: Option>, @@ -176,6 +177,7 @@ impl JobObjectChild { Self { inner: None, prepared: None, + extracted: None, exit_status: ChildExitStatus::Running, job_drained: false, poll_cadence: None, @@ -203,6 +205,14 @@ impl JobObjectChild { } fn with_job_port(&self, inspect: impl FnOnce(&JobPort) -> R) -> R { + if let Some(prepared) = self.extracted.as_ref() { + return inspect( + prepared + .job_port + .as_ref() + .expect("the extracted JobObject layer retains its job handles"), + ); + } self.prepared() .with::(|prepared| { inspect( @@ -215,6 +225,15 @@ impl JobObjectChild { .expect("JobObject prepared state retains its concrete type") } + fn take_prepared(&mut self) -> PreparedJobObject { + if let Some(prepared) = self.extracted.take() { + return prepared; + } + self.prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type") + } + fn arm_poll_cadence(&mut self, elapsed: Duration) { debug_assert!(self.poll_cadence.is_none()); let remaining = JOB_POLL_INTERVAL.saturating_sub(elapsed); @@ -265,25 +284,31 @@ impl ChildWrapper for JobObjectChild { .take() .expect("an installed JobObject layer owns its child"); if self.spawn_finalized && self.final_kill_on_drop { - self.prepared() - .with_mut::(|prepared| { - let job_port = prepared - .job_port - .take() - .expect("the installed JobObject layer retains its job handles"); - // Manually close the completion port while retaining the job handle. Closing a - // kill-on-close job here would make the extracted child unusable. - let job_port = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); - }) - .expect("JobObject prepared state retains its concrete type"); + let mut prepared = self.take_prepared(); + let job_port = prepared + .job_port + .take() + .expect("the installed JobObject layer retains its job handles"); + // Manually close the completion port while retaining the job handle. Closing a + // kill-on-close job here would make the extracted child unusable. + let job_port = std::mem::ManuallyDrop::new(job_port); + // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. + unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); } // Before spawn finalization, dropping the still-armed prepared job instead guarantees that // removing this layer cannot let descendants escape a later lifecycle failure. inner } + fn retain_prepared_after_sidecar_removal_layer(&mut self) { + if self.extracted.is_none() { + let prepared = self + .prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type"); + self.extracted = Some(prepared); + } + } fn process_handle(&self) -> Option> { self.inner_ref().try_process_handle() } @@ -682,7 +707,7 @@ mod tests { let paths = TreePaths::new(directory.path()); let state = Arc::new(LifecycleState::default()); let slot_calls = Arc::new(AtomicUsize::new(0)); - let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let events = Arc::new(std::sync::Mutex::new(Vec::with_capacity(2))); let mut command = topology_command(provider, &paths, &state)?; command.wrap(PostOwnerAccessor { slot_calls: Arc::clone(&slot_calls), diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index e4421bf..da11501 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -485,6 +485,97 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[derive(Debug)] + struct OrderedPrepared { + events: Arc>>, + } + + #[cfg(windows)] + impl Drop for OrderedPrepared { + fn drop(&mut self) { + self.events.lock().unwrap().push("prepared"); + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct OrderedPreparedLayer { + inner: Option>, + prepared: Option, + events: Arc>>, + } + + #[cfg(windows)] + impl ChildWrapperLayer for OrderedPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) + } + } + + #[cfg(windows)] + impl ChildWrapper for OrderedPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed ordered layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed ordered layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed ordered layer owns its child") + } + } + + #[cfg(windows)] + impl Drop for OrderedPreparedLayer { + fn drop(&mut self) { + self.events.lock().unwrap().push("layer"); + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct OrderedPreparedWrapper { + events: Arc>>, + } + + #[cfg(windows)] + impl CommandWrapper for OrderedPreparedWrapper { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(Some(Box::new(OrderedPrepared { + events: Arc::clone(&self.events), + }))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::)); + Ok(Some(PendingChildWrapper::new(OrderedPreparedLayer { + inner: None, + prepared: None, + events: Arc::clone(&self.events), + }))) + } + } + #[cfg(windows)] #[derive(Debug)] struct OptionPreparedLayer { @@ -2246,6 +2337,60 @@ macro_rules! spawn_provider_tests { assert_eq!(drops.load(Ordering::SeqCst), 2); } + #[cfg(windows)] + #[test] + fn returned_child_drops_layers_before_stable_prepared_storage() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let events = Arc::new(Mutex::new(Vec::new())); + let mut command = if provider { + provider_command(shared, "provider") + } else { + command() + }; + command.wrap(OrderedPreparedWrapper { + events: Arc::clone(&events), + }); + + let child = command.spawn().expect("spawn with ordered prepared state"); + assert!(events.lock().unwrap().is_empty()); + drop(child); + assert_eq!(*events.lock().unwrap(), ["layer", "prepared"]); + } + } + + #[cfg(windows)] + #[test] + fn native_prepared_sidecar_preserves_traversal_and_consuming_extraction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let events = Arc::new(Mutex::new(Vec::new())); + let mut command = command(); + command.wrap(OrderedPreparedWrapper { + events: Arc::clone(&events), + }); + + let child = command.spawn().expect("spawn with a native prepared sidecar"); + assert_ne!( + child.type_id(), + TypeId::of::(), + "the private owner sidecar has its own top-level Any identity" + ); + assert_eq!( + child.inner().type_id(), + TypeId::of::(), + "the immediate application layer remains traversable" + ); + assert!(child.try_inner_child().is_some()); + + let inner = child.into_inner(); + assert!(inner.try_inner_child().is_some()); + assert_eq!(*events.lock().unwrap(), ["layer", "prepared"]); + drop(inner); + } + #[cfg(windows)] #[test] fn retained_installed_token_cannot_extend_prepared_state_liveness() { diff --git a/tests/std_windows/process_handle.rs b/tests/std_windows/process_handle.rs index b26f083..961e7c1 100644 --- a/tests/std_windows/process_handle.rs +++ b/tests/std_windows/process_handle.rs @@ -436,11 +436,13 @@ fn job_object_falls_back_through_a_legacy_transparent_child() -> Result<()> { command.wrap(LegacyTransparent).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait()?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -499,17 +501,43 @@ fn provider_job_assignment_precedes_commit_in_both_orders() -> Result<()> { Ok(()) } +#[test] +fn provider_sidecar_removal_preserves_job_object_consuming_extraction() -> Result<()> { + let resumes = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ProviderWrapper(ProcessProvider { + resumes: Arc::clone(&resumes), + commits: Arc::clone(&commits), + })); + + let child = command.spawn()?; + let mut job = child.into_inner(); + assert_eq!(job.as_ref().type_id(), TypeId::of::()); + job.start_kill()?; + let status = job.wait()?; + let mut child = job.into_inner(); + assert_eq!(child.wait()?, status); + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert_eq!(commits.load(Ordering::SeqCst), 1); + Ok(()) +} + #[test] fn job_object_falls_back_through_a_legacy_inline_child() -> Result<()> { let mut command = sleeping_command_wrap(); command.wrap(LegacyInline).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait()?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -522,8 +550,9 @@ fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Result<( let mut child = command.spawn()?; let outer_has_handle = child.process_handle().is_some(); - let direct_type = child.inner().type_id(); - let direct_mut_type = child.inner_mut().type_id(); + let prepared_layer_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); + let prepared_layer_mut_type = child.inner_mut().type_id(); let mut direct = child.into_inner(); let consumed_type = direct.as_ref().type_id(); let consumed_has_handle = direct.process_handle().is_some(); @@ -532,8 +561,9 @@ fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Result<( let _ = direct.wait()?; assert!(outer_has_handle); + assert_eq!(prepared_layer_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); - assert_eq!(direct_mut_type, TypeId::of::()); + assert_eq!(prepared_layer_mut_type, TypeId::of::()); assert_eq!(consumed_type, TypeId::of::()); assert!(consumed_has_handle); Ok(()) diff --git a/tests/tokio_windows/process_handle.rs b/tests/tokio_windows/process_handle.rs index 92d6a91..bb134da 100644 --- a/tests/tokio_windows/process_handle.rs +++ b/tests/tokio_windows/process_handle.rs @@ -425,11 +425,13 @@ async fn job_object_falls_back_through_a_legacy_transparent_child() -> Result<() command.wrap(LegacyTransparent).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait().await?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -488,17 +490,43 @@ async fn provider_job_assignment_precedes_commit_in_both_orders() -> Result<()> Ok(()) } +#[tokio::test] +async fn provider_sidecar_removal_preserves_job_object_consuming_extraction() -> Result<()> { + let resumes = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ProviderWrapper(ProcessProvider { + resumes: Arc::clone(&resumes), + commits: Arc::clone(&commits), + })); + + let child = command.spawn()?; + let mut job = child.into_inner(); + assert_eq!(job.as_ref().type_id(), TypeId::of::()); + job.start_kill()?; + let status = job.wait().await?; + let mut child = job.into_inner(); + assert_eq!(child.wait().await?, status); + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert_eq!(commits.load(Ordering::SeqCst), 1); + Ok(()) +} + #[tokio::test] async fn job_object_falls_back_through_a_legacy_inline_child() -> Result<()> { let mut command = sleeping_command_wrap(); command.wrap(LegacyInline).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait().await?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -511,8 +539,9 @@ async fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Re let mut child = command.spawn()?; let outer_has_handle = child.process_handle().is_some(); - let direct_type = child.inner().type_id(); - let direct_mut_type = child.inner_mut().type_id(); + let prepared_layer_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); + let prepared_layer_mut_type = child.inner_mut().type_id(); let mut direct = child.into_inner(); let consumed_type = direct.as_ref().type_id(); let consumed_has_handle = direct.process_handle().is_some(); @@ -521,8 +550,9 @@ async fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Re let _ = direct.wait().await?; assert!(outer_has_handle); + assert_eq!(prepared_layer_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); - assert_eq!(direct_mut_type, TypeId::of::()); + assert_eq!(prepared_layer_mut_type, TypeId::of::()); assert_eq!(consumed_type, TypeId::of::()); assert!(consumed_has_handle); Ok(()) From f2a87f487357452e79afa5e3c09538218dc5acb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 14:35:26 +1300 Subject: [PATCH 067/114] fix(tokio): order prepared output-wait cleanup Co-Authored-By: Claude Code --- src/tokio/core.rs | 49 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 70984eb..985aaa2 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -75,11 +75,7 @@ macro_rules! prepared_owner_child_contract { let child = self .take_child() .expect("a prepared-owner sidecar retains its child"); - Box::new(async move { - let output = Box::into_pin(child.wait_with_output()).await; - drop(self); - output - }) + prepared_owner_wait_with_output(child, self) } #[cfg(unix)] @@ -99,6 +95,49 @@ crate::generic_wrap::Wrap!( prepared_owner_child_contract ); +#[cfg(windows)] +struct PreparedOwnerWaitWithOutput { + // Field order is intentional: cancellation drops the child future before prepared storage. + child: Option> + Send>>>, + owner: Option>, +} + +#[cfg(windows)] +impl Future for PreparedOwnerWaitWithOutput { + type Output = Result; + + fn poll( + self: Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.get_mut(); + let result = match this + .child + .as_mut() + .expect("a prepared wait retains its child future") + .as_mut() + .poll(context) + { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(result) => result, + }; + drop(this.child.take()); + drop(this.owner.take()); + std::task::Poll::Ready(result) + } +} + +#[cfg(windows)] +fn prepared_owner_wait_with_output( + child: Box, + owner: Box, +) -> Box> + Send> { + Box::new(PreparedOwnerWaitWithOutput { + child: Some(Box::into_pin(child.wait_with_output())), + owner: Some(owner), + }) +} + /// Wrapper for `tokio::process::Child`. /// /// This trait exposes most of the functionality of the underlying [`Child`]. It is implemented for From dc7a851d9d68a568802b197fc7412c99c898b618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 15:44:54 +1300 Subject: [PATCH 068/114] fix(windows): revoke prepared state on owner drop Co-Authored-By: Claude Code --- src/generic_wrap.rs | 22 ++++ src/tokio/core.rs | 220 +++++++++++++++++++++++++++++++++++ tests/spawn_provider.rs | 250 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 488 insertions(+), 4 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 12d5d28..70b2edb 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -106,6 +106,7 @@ macro_rules! Wrap { struct PreparedChildState { value: ::std::sync::Mutex>>, type_id: ::std::any::TypeId, + revoked: ::std::sync::atomic::AtomicBool, } #[cfg(windows)] @@ -122,6 +123,7 @@ macro_rules! Wrap { state: ::std::sync::Arc::new(PreparedChildState { value: ::std::sync::Mutex::new(Some(value)), type_id, + revoked: ::std::sync::atomic::AtomicBool::new(false), }), installed_layer: None, } @@ -162,6 +164,20 @@ macro_rules! Wrap { } } + #[cfg(windows)] + impl Drop for PreparedChildOwner { + fn drop(&mut self) { + // Publish revocation before contending for the value lock. An inspection which already + // borrowed the value retains that lock, while every later locker observes revocation. + self.state + .revoked + .store(true, ::std::sync::atomic::Ordering::Release); + // `take` releases the lock before the value's destructor runs. + let value = self.take(); + drop(value); + } + } + /// Immutable type metadata for state awaiting child-layer installation. /// /// This view cannot clone process-wrap's custody owner or access the prepared value. Use @@ -240,6 +256,9 @@ macro_rules! Wrap { .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); + if state.revoked.load(::std::sync::atomic::Ordering::Acquire) { + return None; + } value.as_deref()?.downcast_ref::().map(inspect) } @@ -250,6 +269,9 @@ macro_rules! Wrap { .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); + if state.revoked.load(::std::sync::atomic::Ordering::Acquire) { + return None; + } let value = slot.take()?; match value.downcast::() { Ok(value) => Some(*value), diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 985aaa2..f878043 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -792,3 +792,223 @@ const _: () = { const fn assert_sync() {} assert_sync::(); }; + +#[cfg(all(test, windows))] +mod prepared_output_wait_tests { + use std::{ + future::Future, + io, + pin::Pin, + process::{ExitStatus, Output}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + task::{Context, Poll}, + thread, + time::Duration, + }; + + use futures::task::noop_waker; + + use super::*; + + const TIMEOUT: Duration = Duration::from_secs(5); + + #[derive(Debug)] + struct PreparedDrop(Arc); + + impl Drop for PreparedDrop { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[derive(Debug)] + struct OutputChild { + future_drops: Arc, + ready: bool, + } + + impl ChildWrapper for OutputChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn wait_with_output( + self: Box, + ) -> Box> + Send> { + Box::new(OutputFuture { + drops: Arc::clone(&self.future_drops), + ready: self.ready, + }) + } + } + + struct OutputFuture { + drops: Arc, + ready: bool, + } + + impl Future for OutputFuture { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll { + if self.ready { + use std::os::windows::process::ExitStatusExt; + Poll::Ready(Ok(Output { + status: ExitStatus::from_raw(0), + stdout: Vec::new(), + stderr: Vec::new(), + })) + } else { + Poll::Pending + } + } + } + + impl Drop for OutputFuture { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + fn assert_output_wait_drops_child_before_blocking_on_owner(complete: bool) { + let prepared_drops = Arc::new(AtomicUsize::new(0)); + let future_drops = Arc::new(AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedDrop(Arc::clone(&prepared_drops)))); + let state = Arc::clone(&owner.state); + let token = Arc::new(owner.installed_token()); + let child = PreparedOwnerChild::new( + Box::new(OutputChild { + future_drops: Arc::clone(&future_drops), + ready: complete, + }), + vec![Some(owner)], + ); + let output = child.wait_with_output(); + + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (inspection_tx, inspection_rx) = mpsc::channel(); + let inspection_token = Arc::clone(&token); + let worker = thread::spawn(move || { + let completed = inspection_token.with::(|_| { + entered_tx + .send(()) + .expect("the test receives the active-access rendezvous"); + release_rx.recv_timeout(TIMEOUT).is_ok() + }) == Some(true); + let _ = inspection_tx.send(completed); + }); + entered_rx + .recv_timeout(TIMEOUT) + .expect("prepared inspection entered after upgrading and locking state"); + + let (started_tx, started_rx) = mpsc::channel(); + let (finished_tx, finished_rx) = mpsc::channel(); + let output_worker = thread::spawn(move || { + started_tx + .send(()) + .expect("the test observes output-wait disposal starting"); + if complete { + let mut output = Box::into_pin(output); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + assert!(matches!( + output.as_mut().poll(&mut context), + Poll::Ready(Ok(_)) + )); + } else { + drop(output); + } + let _ = finished_tx.send(()); + }); + started_rx + .recv_timeout(TIMEOUT) + .expect("output-wait disposal thread started"); + + let deadline = std::time::Instant::now() + TIMEOUT; + while !state.revoked.load(std::sync::atomic::Ordering::Acquire) + && std::time::Instant::now() < deadline + { + thread::yield_now(); + } + assert!( + state.revoked.load(std::sync::atomic::Ordering::Acquire), + "prepared owner began revocation" + ); + let (racing_tx, racing_rx) = mpsc::channel(); + let racing_token = Arc::clone(&token); + let racing_worker = thread::spawn(move || { + let _ = racing_tx.send(racing_token.with::(|_| ()).is_some()); + }); + + let deadline = std::time::Instant::now() + TIMEOUT; + while future_drops.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline { + thread::yield_now(); + } + assert_eq!( + future_drops.load(Ordering::SeqCst), + 1, + "the child future drops before prepared-owner revocation" + ); + assert_eq!( + finished_rx.recv_timeout(Duration::from_millis(250)), + Err(mpsc::RecvTimeoutError::Timeout), + "output-wait disposal returned during immutable prepared access" + ); + assert_eq!(prepared_drops.load(Ordering::SeqCst), 0); + + release_tx + .send(()) + .expect("release the finite prepared inspection"); + finished_rx + .recv_timeout(TIMEOUT) + .expect("output-wait disposal completes after prepared inspection release"); + assert_eq!(prepared_drops.load(Ordering::SeqCst), 1); + assert!( + !racing_rx + .recv_timeout(TIMEOUT) + .expect("racing prepared access completed"), + "an access which raced with revocation reacquired the prepared value" + ); + assert!( + token.with::(|_| ()).is_none(), + "a retained token accessed prepared state after owner destruction" + ); + assert!( + inspection_rx + .recv_timeout(TIMEOUT) + .expect("prepared inspection worker completed") + ); + worker + .join() + .expect("prepared inspection worker did not panic"); + racing_worker + .join() + .expect("racing prepared inspection worker did not panic"); + output_worker + .join() + .expect("output-wait disposal worker did not panic"); + } + + #[test] + fn cancellation_drops_child_future_before_revoking_prepared_owner() { + assert_output_wait_drops_child_before_blocking_on_owner(false); + } + + #[test] + fn completion_drops_child_future_before_revoking_prepared_owner() { + assert_output_wait_drops_child_before_blocking_on_owner(true); + } +} diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index da11501..c7b174f 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -33,6 +33,9 @@ macro_rules! spawn_provider_tests { time::{Duration, Instant}, }; + #[cfg(windows)] + use std::{sync::mpsc, thread}; + use super::bounded_process; use process_wrap::{CommandArg, SpawnTransaction}; use $child_wrapper as ChildWrapper; @@ -661,7 +664,7 @@ macro_rules! spawn_provider_tests { #[derive(Debug)] struct RetainInstalledPrepared { steal_once: bool, - escaped: Option, + escaped: Mutex>, } #[cfg(windows)] @@ -678,8 +681,12 @@ macro_rules! spawn_provider_tests { let layer = (child as &mut dyn std::any::Any) .downcast_mut::() .expect("the prepared layer is immediately inside this wrapper"); - self.escaped = layer.prepared.take(); - assert!(self.escaped.is_some()); + let escaped = layer.prepared.take(); + assert!(escaped.is_some()); + *self + .escaped + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = escaped; } Ok(None) } @@ -1457,6 +1464,98 @@ macro_rules! spawn_provider_tests { command } + #[cfg(windows)] + fn take_retained_prepared(command: &CommandWrap) -> PreparedChild { + command + .get_wrap::() + .expect("the retaining wrapper remains registered") + .escaped + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("the retaining wrapper captured the installed token") + } + + #[cfg(windows)] + fn assert_active_prepared_access_blocks_destruction( + token: PreparedChild, + drops: Arc, + destroy: impl FnOnce() + Send + 'static, + ) { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (retry_tx, retry_rx) = mpsc::channel(); + let (inspection_tx, inspection_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let completed = token + .with::, _>(|prepared| { + assert!(prepared.is_some()); + entered_tx + .send(()) + .expect("the test receives the active-access rendezvous"); + release_rx.recv_timeout(EXIT_TIMEOUT).is_ok() + }) + == Some(true); + let retry_requested = retry_rx.recv_timeout(EXIT_TIMEOUT).is_ok(); + let accessible_after_destruction = retry_requested + && token + .with::, _>(|prepared| prepared.is_some()) + .is_some(); + let _ = inspection_tx.send((completed, accessible_after_destruction)); + }); + + entered_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("prepared inspection entered after upgrading and locking state"); + let (started_tx, started_rx) = mpsc::channel(); + let (destroyed_tx, destroyed_rx) = mpsc::channel(); + let destroyer = thread::spawn(move || { + started_tx + .send(()) + .expect("the test observes destruction starting"); + destroy(); + let _ = destroyed_tx.send(()); + }); + started_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("destruction thread started"); + assert_eq!( + destroyed_rx.recv_timeout(Duration::from_millis(250)), + Err(mpsc::RecvTimeoutError::Timeout), + "authoritative destruction returned during immutable prepared access" + ); + assert_eq!( + drops.load(Ordering::SeqCst), + 0, + "prepared state dropped while immutably borrowed" + ); + + release_tx + .send(()) + .expect("release the finite prepared inspection"); + destroyed_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("destruction completes after prepared inspection release"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + retry_tx + .send(()) + .expect("request a post-destruction token access"); + let (completed, accessible_after_destruction) = inspection_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("prepared inspection worker completed"); + assert!(completed, "the finite prepared inspection completed"); + assert!( + !accessible_after_destruction, + "a retained token reacquired prepared state after owner destruction" + ); + worker + .join() + .expect("prepared inspection worker did not panic"); + destroyer + .join() + .expect("authoritative destruction did not panic"); + } + fn provider_command_with_primary_failure( shared: Arc, failure: PrimaryFailure, @@ -2410,7 +2509,7 @@ macro_rules! spawn_provider_tests { }) .wrap(RetainInstalledPrepared { steal_once: true, - escaped: None, + escaped: Mutex::new(None), }); let child = command @@ -2433,6 +2532,149 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[test] + fn active_prepared_access_blocks_returned_child_destruction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("spawn a child with externally inspected prepared state"); + let token = take_retained_prepared(&command); + assert_active_prepared_access_blocks_destruction( + token, + Arc::clone(&drops), + move || drop(child), + ); + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn active_prepared_access_blocks_consuming_extraction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("spawn a child for consuming extraction"); + let token = take_retained_prepared(&command); + assert_active_prepared_access_blocks_destruction( + token, + Arc::clone(&drops), + move || { + let child = child.into_inner(); + if provider { + drop(child.into_inner()); + } else { + drop(child); + } + }, + ); + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn poisoned_prepared_access_preserves_exact_cleanup_and_extraction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + for extract in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("spawn a child whose prepared mutex will be poisoned"); + let token = take_retained_prepared(&command); + let payload = catch_unwind(AssertUnwindSafe(|| { + let _ = token.with::, ()>(|_| { + panic_any("poison prepared access") + }); + })) + .expect_err("the prepared accessor poisons its private mutex"); + std::mem::forget(payload); + + if extract { + let child = child.into_inner(); + if provider { + drop(child.into_inner()); + } else { + drop(child); + } + } else { + drop(child); + } + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!( + token + .with::, _>(|prepared| prepared.is_some()) + .is_none(), + "poison recovery must not leave the prepared value accessible" + ); + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + } + #[cfg(windows)] #[test] fn prepared_slot_accessors_are_not_reinvoked_after_installation() { From 1ad8e69a199987cc5436a23bb1194be4397d4142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 16:02:47 +1300 Subject: [PATCH 069/114] fix(windows): make final JobObject disarm terminal Co-Authored-By: Claude Code --- src/command.rs | 390 +++++++++++++++++++++------------------- src/generic_wrap.rs | 28 +++ src/lib.rs | 101 +++++++++++ src/std/core.rs | 52 ++---- src/std/job_object.rs | 60 +++++-- src/tokio/core.rs | 52 ++---- src/tokio/job_object.rs | 60 +++++-- src/windows.rs | 49 ++++- 8 files changed, 490 insertions(+), 302 deletions(-) diff --git a/src/command.rs b/src/command.rs index b76e12a..9e52200 100644 --- a/src/command.rs +++ b/src/command.rs @@ -468,60 +468,36 @@ impl NativeCommandView { } } -struct NativeOnlyCommand { - command: Option, +struct NativeOnlyCommand { view: NativeCommandView, } -impl NativeOnlyCommand { - fn new(command: N) -> Self { +impl NativeOnlyCommand { + fn new(command: &N) -> Self { Self { - view: NativeCommandView::capture(&command), - command: Some(command), + view: NativeCommandView::capture(command), } } - fn command_mut(&mut self) -> &mut N { - self.command - .as_mut() - .expect("native command access cannot occur while a spawn lifecycle is active") - } - - fn take(&mut self) -> N { - let command = self - .command - .take() - .expect("a native-only command is present when its spawn lifecycle begins"); - self.view = NativeCommandView::capture(&command); - command - } - - fn restore(&mut self, command: N) { - debug_assert!(self.command.is_none()); - self.command = Some(command); - } - - fn into_command(self) -> N { - self.command - .expect("a command cannot be consumed while its spawn lifecycle is active") + fn refresh(&mut self, command: &N) { + self.view = NativeCommandView::capture(command); } } -impl fmt::Debug for NativeOnlyCommand { +impl fmt::Debug for NativeOnlyCommand { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("NativeOnly") - .field("command", &self.command) .field("view", &self.view) .finish() } } -enum CommandState { +enum CommandState { Tracked(CommandIntent), - NativeOnly(NativeOnlyCommand), + NativeOnly(NativeOnlyCommand), } -impl fmt::Debug for CommandState { +impl fmt::Debug for CommandState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Tracked(intent) => f.debug_tuple("Tracked").field(intent).finish(), @@ -730,23 +706,16 @@ impl Drop for WindowsSpawnCleanup { } } -enum AttemptState { - Tracked { - intent: CommandIntent, - native: Option, - }, - NativeOnly(N), +enum AttemptState { + Tracked(CommandIntent), + NativeOnly, } -impl fmt::Debug for AttemptState { +impl fmt::Debug for AttemptState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Tracked { intent, native } => f - .debug_struct("Tracked") - .field("intent", intent) - .field("native", native) - .finish(), - Self::NativeOnly(command) => f.debug_tuple("NativeOnly").field(command).finish(), + Self::Tracked(intent) => f.debug_tuple("Tracked").field(intent).finish(), + Self::NativeOnly => f.write_str("NativeOnly"), } } } @@ -765,7 +734,8 @@ struct PlatformCommandState { /// Explicit native mutation makes a tracked attempt native-only, which alternate portable spawn /// providers reject rather than reconstructing or partially applying. pub struct SpawnAttempt { - state: AttemptState, + state: AttemptState, + native: Option, #[cfg_attr(not(unix), allow(dead_code))] platform: PlatformCommandState, #[cfg_attr(not(unix), allow(dead_code))] @@ -792,7 +762,8 @@ impl fmt::Debug for SpawnAttempt { /// `process_wrap::tokio::Command`. Command construction and configuration are shared; spawning and /// child behavior remain specific to the selected frontend. pub struct Command { - state: CommandState, + state: CommandState, + native: Option, wrappers: Box, platform: PlatformCommandState, backend: PhantomData B>, @@ -811,6 +782,7 @@ impl Command { pub fn new(program: impl AsRef) -> Self { Self { state: CommandState::Tracked(CommandIntent::new(program)), + native: None, wrappers: B::new_registry(), platform: PlatformCommandState::default(), backend: PhantomData, @@ -845,7 +817,11 @@ impl Command { let arg = arg.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.args.push(CommandArg::Regular(arg.to_owned())), - CommandState::NativeOnly(command) => command.command_mut().arg(arg), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .arg(arg), } self } @@ -870,7 +846,11 @@ impl Command { let arg = arg.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.args.push(CommandArg::Raw(arg.to_owned())), - CommandState::NativeOnly(command) => command.command_mut().raw_arg(arg), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .raw_arg(arg), } self } @@ -883,7 +863,11 @@ impl Command { CommandState::Tracked(intent) => intent .env .push(EnvChange::Set(key.to_owned(), value.to_owned())), - CommandState::NativeOnly(command) => command.command_mut().env(key, value), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .env(key, value), } self } @@ -906,7 +890,11 @@ impl Command { let key = key.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.env_remove(key), - CommandState::NativeOnly(command) => command.command_mut().env_remove(key), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .env_remove(key), } self } @@ -918,7 +906,11 @@ impl Command { intent.env_clear = true; intent.env.clear(); } - CommandState::NativeOnly(command) => command.command_mut().env_clear(), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .env_clear(), } self } @@ -928,7 +920,11 @@ impl Command { let dir = dir.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.current_dir = Some(dir.to_owned()), - CommandState::NativeOnly(command) => command.command_mut().current_dir(dir), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .current_dir(dir), } self } @@ -955,8 +951,8 @@ impl Command { pub fn get_program(&self) -> &OsStr { match &self.state { CommandState::Tracked(intent) => &intent.program, - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_program(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_program(), None => &command.view.program, }, } @@ -966,8 +962,8 @@ impl Command { pub fn get_args(&self) -> Box + '_> { match &self.state { CommandState::Tracked(intent) => Box::new(intent.args.iter().map(CommandArg::value)), - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_args(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_args(), None => command.view.get_args(), }, } @@ -988,8 +984,8 @@ impl Command { pub fn get_envs(&self) -> Box)> + '_> { match &self.state { CommandState::Tracked(intent) => Box::new(intent.get_envs()), - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_envs(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_envs(), None => command.view.get_envs(), }, } @@ -1010,8 +1006,8 @@ impl Command { pub fn get_current_dir(&self) -> Option<&Path> { match &self.state { CommandState::Tracked(intent) => intent.current_dir.as_deref(), - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_current_dir(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_current_dir(), None => command.view.current_dir.as_deref(), }, } @@ -1030,30 +1026,34 @@ impl Command { /// inactive callbacks; use the tracked facade methods and wrappers for reusable configuration. pub fn native_mut(&mut self) -> &mut B::NativeCommand { if let CommandState::Tracked(intent) = &self.state { - let command = intent.materialize::(); - self.state = CommandState::NativeOnly(NativeOnlyCommand::new(command)); + let native = intent.materialize::(); + let state = NativeOnlyCommand::new(&native); + self.native = Some(native); + self.state = CommandState::NativeOnly(state); } #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] self.platform.unix.invalidate(); - match &mut self.state { - CommandState::NativeOnly(command) => command.command_mut(), - CommandState::Tracked(_) => unreachable!("tracked command was materialized above"), - } + self.native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") } /// Consume this command and return the frontend's native command. pub fn into_native(self) -> B::NativeCommand { match self.state { CommandState::Tracked(intent) => intent.materialize::(), - CommandState::NativeOnly(command) => command.into_command(), + CommandState::NativeOnly(_) => self + .native + .expect("a command cannot be consumed while its spawn lifecycle is active"), } } - pub(crate) fn from_native(command: B::NativeCommand) -> Self { + pub(crate) fn from_native(native: B::NativeCommand) -> Self { Self { - state: CommandState::NativeOnly(NativeOnlyCommand::new(command)), + state: CommandState::NativeOnly(NativeOnlyCommand::new(&native)), + native: Some(native), wrappers: B::new_registry(), platform: PlatformCommandState::default(), backend: PhantomData, @@ -1082,65 +1082,41 @@ impl Command { invoke: impl FnOnce(&mut Self, &mut SpawnAttempt) -> std::io::Result, ) -> std::io::Result { let platform = self.platform.clone(); - match &mut self.state { + let (state, native, native_only_base) = match &mut self.state { CommandState::Tracked(intent) => { - let mut attempt = SpawnAttempt { - state: AttemptState::Tracked { - intent: intent.clone(), - native: None, - }, - platform, - native_only_base: false, - kill_on_drop: None, - #[cfg(windows)] - windows_policy: WindowsSpawnPolicy::default(), - #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] - unix_policy: crate::unix::SpawnPolicy::default(), - backend: PhantomData, - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - invoke(self, &mut attempt) - })); - attempt.disarm_platform(); - match result { - Ok(result) => result, - Err(payload) => std::panic::resume_unwind(payload), - } + drop(self.native.take()); + (AttemptState::Tracked(intent.clone()), None, false) } CommandState::NativeOnly(command) => { - let native = command.take(); - let mut attempt = SpawnAttempt { - state: AttemptState::NativeOnly(native), - platform, - native_only_base: true, - kill_on_drop: None, - #[cfg(windows)] - windows_policy: WindowsSpawnPolicy::default(), - #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] - unix_policy: crate::unix::SpawnPolicy::default(), - backend: PhantomData, - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - invoke(self, &mut attempt) - })); - attempt.disarm_platform(); - let native = match attempt.state { - AttemptState::NativeOnly(native) => native, - AttemptState::Tracked { .. } => { - unreachable!("a native-only spawn attempt cannot become tracked") - } - }; - match &mut self.state { - CommandState::NativeOnly(command) => command.restore(native), - CommandState::Tracked(_) => { - unreachable!("a spawn lifecycle cannot replace native-only command state") - } - } - match result { - Ok(result) => result, - Err(payload) => std::panic::resume_unwind(payload), - } + let native = self + .native + .take() + .expect("a native-only command is present when its spawn lifecycle begins"); + command.refresh(&native); + (AttemptState::NativeOnly, Some(native), true) } + }; + let mut attempt = SpawnAttempt { + state, + native, + platform, + native_only_base, + kill_on_drop: None, + #[cfg(windows)] + windows_policy: WindowsSpawnPolicy::default(), + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + unix_policy: crate::unix::SpawnPolicy::default(), + backend: PhantomData, + }; + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| invoke(self, &mut attempt))); + #[cfg(unix)] + attempt.disarm_platform(); + let native = attempt.native.take(); + self.native = if native_only_base { native } else { None }; + match result { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), } } } @@ -1150,10 +1126,12 @@ impl SpawnAttempt { pub fn arg(&mut self, arg: impl AsRef) -> &mut Self { let arg = arg.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => { - intent.args.push(CommandArg::Regular(arg.to_owned())) - } - AttemptState::NativeOnly(command) => command.arg(arg), + AttemptState::Tracked(intent) => intent.args.push(CommandArg::Regular(arg.to_owned())), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .arg(arg), } self } @@ -1177,10 +1155,12 @@ impl SpawnAttempt { pub fn raw_arg(&mut self, arg: impl AsRef) -> &mut Self { let arg = arg.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => { - intent.args.push(CommandArg::Raw(arg.to_owned())) - } - AttemptState::NativeOnly(command) => command.raw_arg(arg), + AttemptState::Tracked(intent) => intent.args.push(CommandArg::Raw(arg.to_owned())), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .raw_arg(arg), } self } @@ -1190,10 +1170,14 @@ impl SpawnAttempt { let key = key.as_ref(); let value = value.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => intent + AttemptState::Tracked(intent) => intent .env .push(EnvChange::Set(key.to_owned(), value.to_owned())), - AttemptState::NativeOnly(command) => command.env(key, value), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .env(key, value), } self } @@ -1215,8 +1199,12 @@ impl SpawnAttempt { pub fn env_remove(&mut self, key: impl AsRef) -> &mut Self { let key = key.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => intent.env_remove(key), - AttemptState::NativeOnly(command) => command.env_remove(key), + AttemptState::Tracked(intent) => intent.env_remove(key), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .env_remove(key), } self } @@ -1224,11 +1212,15 @@ impl SpawnAttempt { /// Clear configured variables and prevent inheritance for this spawn attempt. pub fn env_clear(&mut self) -> &mut Self { match &mut self.state { - AttemptState::Tracked { intent, .. } => { + AttemptState::Tracked(intent) => { intent.env_clear = true; intent.env.clear(); } - AttemptState::NativeOnly(command) => command.env_clear(), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .env_clear(), } self } @@ -1237,8 +1229,12 @@ impl SpawnAttempt { pub fn current_dir(&mut self, dir: impl AsRef) -> &mut Self { let dir = dir.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => intent.current_dir = Some(dir.to_owned()), - AttemptState::NativeOnly(command) => command.current_dir(dir), + AttemptState::Tracked(intent) => intent.current_dir = Some(dir.to_owned()), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .current_dir(dir), } self } @@ -1264,18 +1260,24 @@ impl SpawnAttempt { /// Get the configured program for this spawn attempt. pub fn get_program(&self) -> &OsStr { match &self.state { - AttemptState::Tracked { intent, .. } => &intent.program, - AttemptState::NativeOnly(command) => command.get_program(), + AttemptState::Tracked(intent) => &intent.program, + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_program(), } } /// Get the configured arguments for this spawn attempt. pub fn get_args(&self) -> Box + '_> { match &self.state { - AttemptState::Tracked { intent, .. } => { - Box::new(intent.args.iter().map(CommandArg::value)) - } - AttemptState::NativeOnly(command) => command.get_args(), + AttemptState::Tracked(intent) => Box::new(intent.args.iter().map(CommandArg::value)), + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_args(), } } @@ -1286,16 +1288,20 @@ impl SpawnAttempt { /// `validate_attempt` callback, so providers receive `Some` there. pub fn get_portable_args(&self) -> Option<&[CommandArg]> { match &self.state { - AttemptState::Tracked { intent, .. } => Some(&intent.args), - AttemptState::NativeOnly(_) => None, + AttemptState::Tracked(intent) => Some(&intent.args), + AttemptState::NativeOnly => None, } } /// Get explicitly configured environment changes for this spawn attempt. pub fn get_envs(&self) -> Box)> + '_> { match &self.state { - AttemptState::Tracked { intent, .. } => Box::new(intent.get_envs()), - AttemptState::NativeOnly(command) => command.get_envs(), + AttemptState::Tracked(intent) => Box::new(intent.get_envs()), + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_envs(), } } @@ -1306,16 +1312,20 @@ impl SpawnAttempt { /// callback, so providers receive `Some` there. pub fn inherits_environment(&self) -> Option { match &self.state { - AttemptState::Tracked { intent, .. } => Some(!intent.env_clear), - AttemptState::NativeOnly(_) => None, + AttemptState::Tracked(intent) => Some(!intent.env_clear), + AttemptState::NativeOnly => None, } } /// Get the configured current directory for this spawn attempt. pub fn get_current_dir(&self) -> Option<&Path> { match &self.state { - AttemptState::Tracked { intent, .. } => intent.current_dir.as_deref(), - AttemptState::NativeOnly(command) => command.get_current_dir(), + AttemptState::Tracked(intent) => intent.current_dir.as_deref(), + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_current_dir(), } } @@ -1418,12 +1428,10 @@ impl SpawnAttempt { #[cfg(windows)] let windows_policy = self.windows_policy; { - let command = match &mut self.state { - AttemptState::NativeOnly(command) => command, - AttemptState::Tracked { native, .. } => native - .as_mut() - .expect("the attempt is materialized before platform setup"), - }; + let command = self + .native + .as_mut() + .expect("the attempt is materialized before platform setup"); if let Some(kill_on_drop) = kill_on_drop { command.configure_kill_on_drop(kill_on_drop); } @@ -1437,48 +1445,51 @@ impl SpawnAttempt { { let policy = self.unix_policy; let native_only_base = self.native_only_base; - let command = match &mut self.state { - AttemptState::NativeOnly(command) => command, - AttemptState::Tracked { native, .. } => native - .as_mut() - .expect("the attempt is materialized before platform setup"), - }; + let command = self + .native + .as_mut() + .expect("the attempt is materialized before platform setup"); self.platform .unix .prepare(command, native_only_base, policy); } } + #[cfg(unix)] fn disarm_platform(&mut self) { #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] self.platform.unix.disarm(); } fn materialize_native(&mut self) { - if let AttemptState::Tracked { intent, native } = &mut self.state { - if native.is_none() { - *native = Some(intent.materialize::()); - } + if self.native.is_none() + && let AttemptState::Tracked(intent) = &self.state + { + self.native = Some(intent.materialize::()); } } fn make_native_only(&mut self) { + if matches!(&self.state, AttemptState::NativeOnly) { + return; + } self.materialize_native(); - let native = match &mut self.state { - AttemptState::Tracked { native, .. } => native - .take() - .expect("the tracked attempt was materialized above"), - AttemptState::NativeOnly(_) => return, - }; - self.state = AttemptState::NativeOnly(native); + self.state = AttemptState::NativeOnly; } fn native_command_mut(&mut self) -> &mut B::NativeCommand { - match &mut self.state { - AttemptState::Tracked { native, .. } => native - .as_mut() - .expect("the tracked attempt was materialized before native access"), - AttemptState::NativeOnly(command) => command, + self.native + .as_mut() + .expect("the attempt is materialized before native access") + } + + #[cfg(windows)] + pub(crate) fn prepare_for_final_owner(&mut self) { + if !self.native_only_base { + // Retire attempt-owned allocations before the sole native cleanup owner can disarm. + // A native-only base instead keeps its command for the infallible restoration move. + drop(self.native.take()); + self.state = AttemptState::NativeOnly; } } @@ -1517,11 +1528,12 @@ impl SpawnAttempt { ))] pub(crate) fn take_native_for_provider_spawn(&mut self) -> B::NativeCommand { self.materialize_native(); - match &mut self.state { - AttemptState::Tracked { native, .. } => native + match &self.state { + AttemptState::Tracked(_) => self + .native .take() .expect("the tracked provider attempt was materialized above"), - AttemptState::NativeOnly(_) => { + AttemptState::NativeOnly => { unreachable!("portable providers reject native-only attempts before spawning") } } @@ -1552,7 +1564,7 @@ impl SpawnAttempt { /// Return whether this spawn attempt contains opaque native-only state. pub fn is_native_only(&self) -> bool { - matches!(self.state, AttemptState::NativeOnly(_)) + matches!(self.state, AttemptState::NativeOnly) } } diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 70b2edb..16fd1ca 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -251,6 +251,8 @@ macro_rules! Wrap { /// Inspect installed prepared state immutably while its private owner remains live. #[doc(hidden)] pub fn with(&self, inspect: impl FnOnce(&T) -> R) -> Option { + #[cfg(test)] + crate::test_allocator::current_probe().observe_operation(); let state = self.state.upgrade()?; let value = state .value @@ -262,6 +264,30 @@ macro_rules! Wrap { value.as_deref()?.downcast_ref::().map(inspect) } + #[cfg(feature = "job-object")] + pub(crate) fn with_required( + &self, + unavailable: impl FnOnce() -> R, + inspect: impl FnOnce(&T) -> R, + ) -> R { + #[cfg(test)] + crate::test_allocator::current_probe().observe_operation(); + let Some(state) = self.state.upgrade() else { + return unavailable(); + }; + let value = state + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + if state.revoked.load(::std::sync::atomic::Ordering::Acquire) { + return unavailable(); + } + let Some(value) = value.as_deref().and_then(|value| value.downcast_ref::()) else { + return unavailable(); + }; + inspect(value) + } + #[cfg(feature = "job-object")] pub(crate) fn take_prepared_value(&self) -> Option { let state = self.state.upgrade()?; @@ -1230,6 +1256,7 @@ macro_rules! Wrap { return failure.finish::>(); } }; + attempt.prepare_for_final_owner(); let final_result = Self::capture_io(|| { success .child_mut() @@ -1415,6 +1442,7 @@ macro_rules! Wrap { #[cfg(windows)] { + attempt.prepare_for_final_owner(); let final_result = Self::capture_io(|| { sidecar .child_mut() diff --git a/src/lib.rs b/src/lib.rs index e858d6d..cd11178 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -632,6 +632,107 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] +#[cfg(all(test, windows))] +pub(crate) mod test_allocator { + use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + sync::atomic::{AtomicBool, Ordering}, + }; + + #[derive(Default)] + pub(crate) struct PostTransitionProbe { + pub(crate) active: AtomicBool, + pub(crate) transitioned: AtomicBool, + allocator_callback: AtomicBool, + operation: AtomicBool, + } + + impl PostTransitionProbe { + pub(crate) fn arm(&self) { + assert!(!self.active.swap(true, Ordering::SeqCst)); + self.transitioned.store(false, Ordering::SeqCst); + self.allocator_callback.store(false, Ordering::SeqCst); + self.operation.store(false, Ordering::SeqCst); + } + + pub(crate) fn observe_operation(&self) { + if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { + self.operation.store(true, Ordering::SeqCst); + } + } + + fn observe_allocator_callback(&self) { + if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { + self.allocator_callback.store(true, Ordering::SeqCst); + } + } + + pub(crate) fn finish(&self) -> (bool, bool, bool) { + self.active.store(false, Ordering::SeqCst); + ( + self.transitioned.load(Ordering::SeqCst), + self.allocator_callback.load(Ordering::SeqCst), + self.operation.load(Ordering::SeqCst), + ) + } + } + + thread_local! { + static POST_TRANSITION_PROBE: Cell> = const { Cell::new(None) }; + } + + pub(crate) fn current_probe() -> &'static PostTransitionProbe { + if let Some(probe) = POST_TRANSITION_PROBE.get() { + return probe; + } + let probe = Box::leak(Box::new(PostTransitionProbe::default())); + POST_TRANSITION_PROBE.set(Some(probe)); + probe + } + + fn observe_allocator_callback() { + let _ = POST_TRANSITION_PROBE.try_with(|slot| { + if let Some(probe) = slot.get() { + probe.observe_allocator_callback(); + } + }); + } + + struct TrackingAllocator; + + // SAFETY: every operation preserves `System`'s arguments and return value exactly. The probe uses + // only already-initialized thread-local and atomic state and does not alter allocator ownership. + unsafe impl GlobalAlloc for TrackingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + observe_allocator_callback(); + // SAFETY: `layout` is passed through unchanged under `GlobalAlloc::alloc`'s contract. + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + observe_allocator_callback(); + // SAFETY: `layout` is passed through unchanged under `GlobalAlloc::alloc_zeroed`'s contract. + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + observe_allocator_callback(); + // SAFETY: both arguments are passed through unchanged under `GlobalAlloc::dealloc`'s contract. + unsafe { System.dealloc(pointer, layout) } + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + observe_allocator_callback(); + // SAFETY: all arguments are passed through unchanged under `GlobalAlloc::realloc`'s contract. + unsafe { System.realloc(pointer, layout, size) } + } + } + + #[global_allocator] + static GLOBAL_ALLOCATOR: TrackingAllocator = TrackingAllocator; +} + mod command; pub(crate) mod generic_wrap; #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] diff --git a/src/std/core.rs b/src/std/core.rs index 440d958..192a40c 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -496,29 +496,6 @@ impl dyn ChildWrapper + '_ { } } - #[cfg(windows)] - fn visit_spawn_layers_until( - &mut self, - mut visit: impl FnMut(&mut dyn ChildWrapper) -> Result, - ) -> Result<()> { - let mut inner = self; - loop { - if visit(inner)? { - return Ok(()); - } - - let inner_type = (&*inner as &dyn Any).type_id(); - let inner_ptr = std::ptr::from_mut(inner); - let next = inner.inner_mut(); - if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) - && inner_type == (&*next as &dyn Any).type_id() - { - return Ok(()); - } - inner = next; - } - } - #[cfg(windows)] pub(crate) fn finalize_spawn_before_commit(&mut self) -> Result { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; @@ -548,22 +525,23 @@ impl dyn ChildWrapper + '_ { let Some(identity) = owner.identity else { return Ok(()); }; - let mut found = false; - self.visit_spawn_layers_until(|inner| { + let mut inner = self; + loop { if spawn_layer_identity(inner) == identity { - inner.disarm_job_object_layer()?; - found = true; - Ok(true) - } else { - Ok(false) + return inner.disarm_job_object_layer(); } - })?; - if found { - Ok(()) - } else { - Err(std::io::Error::other( - "the captured JobObject cleanup owner left the child chain", - )) + + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return Err(std::io::Error::other( + "the captured JobObject cleanup owner left the child chain", + )); + } + inner = next; } } diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 004b44e..a0a4933 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -18,10 +18,15 @@ use crate::{ ChildExitStatus, windows::{ JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, - resume_threads, set_job_kill_on_drop, terminate_job, + resume_threads, terminate_job, }, }; +#[cfg(not(test))] +use crate::windows::set_job_kill_on_drop; +#[cfg(test)] +use crate::windows::set_job_kill_on_drop_observed; + #[cfg(feature = "creation-flags")] use super::CreationFlags; use super::{ @@ -206,16 +211,16 @@ impl JobObjectChild { .expect("the extracted JobObject layer retains its job handles"), ); } - self.prepared() - .with::(|prepared| { - inspect( - prepared - .job_port - .as_ref() - .expect("the installed JobObject layer retains its job handles"), - ) - }) - .expect("JobObject prepared state retains its concrete type") + self.prepared().with_required::( + || panic!("JobObject prepared state retains its concrete type"), + |prepared| { + let job_port = prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"); + inspect(job_port) + }, + ) } fn take_prepared(&mut self) -> PreparedJobObject { @@ -317,10 +322,16 @@ impl ChildWrapper for JobObjectChild { #[cfg(test)] crate::windows::test_support::fail_final_owner()?; let final_kill_on_drop = self.final_kill_on_drop; + #[cfg(test)] + let transition_probe = crate::windows::test_support::owner_transition_probe(); + #[cfg(test)] + self.with_job_port(|job_port| { + crate::windows::test_support::record_owner_event("before-owner-transition"); + set_job_kill_on_drop_observed(job_port.job, final_kill_on_drop, transition_probe) + })?; + #[cfg(not(test))] self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; - #[cfg(test)] - crate::windows::test_support::record_owner_event("owner-disarmed"); Ok(()) } @@ -427,8 +438,9 @@ mod tests { use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, - assert_tree_terminated, clear_extra_prepared_owners, clear_owner_events, - clear_owner_failure, observe_descendant, publish_process_guards, record_owner_event, + arm_owner_transition_probe, assert_tree_terminated, clear_extra_prepared_owners, + clear_owner_events, clear_owner_failure, finish_owner_transition_probe, observe_descendant, + publish_process_guards, record_owner_event, }; use super::*; @@ -701,7 +713,7 @@ mod tests { } #[test] - fn no_slot_accessor_runs_after_final_owner_disarm() -> Result<()> { + fn final_owner_transition_has_no_later_test_or_caller_operation() -> Result<()> { for provider in [false, true] { let directory = tempfile::tempdir()?; let paths = TreePaths::new(directory.path()); @@ -719,8 +731,11 @@ mod tests { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clear(); + arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let (transitioned, allocator_callback, later_operation) = + finish_owner_transition_probe(); let mut succeeded = false; match outcome { Ok(Ok(mut child)) => { @@ -743,6 +758,15 @@ mod tests { clear_owner_events(), "the owner event barrier remained armed" ); + assert!(transitioned, "the native final-owner transition completed"); + assert!( + !allocator_callback, + "an allocator callback ran after the native final-owner transition" + ); + assert!( + !later_operation, + "a caller or test operation ran after the native final-owner transition" + ); let tree_result = assert_tree_terminated(&paths, &state); assert!( succeeded, @@ -753,8 +777,8 @@ mod tests { *events .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), - ["slot-accessor", "owner-disarmed"], - "the final owner transition follows the last caller-defined accessor" + ["slot-accessor", "before-owner-transition"], + "the final native transition follows the last caller-defined accessor" ); tree_result?; if attempt == 0 { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index f878043..e40f438 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -623,29 +623,6 @@ impl dyn ChildWrapper + '_ { } } - #[cfg(windows)] - fn visit_spawn_layers_until( - &mut self, - mut visit: impl FnMut(&mut dyn ChildWrapper) -> Result, - ) -> Result<()> { - let mut inner = self; - loop { - if visit(inner)? { - return Ok(()); - } - - let inner_type = (&*inner as &dyn Any).type_id(); - let inner_ptr = std::ptr::from_mut(inner); - let next = inner.inner_mut(); - if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) - && inner_type == (&*next as &dyn Any).type_id() - { - return Ok(()); - } - inner = next; - } - } - #[cfg(windows)] pub(crate) fn finalize_spawn_before_commit(&mut self) -> Result { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; @@ -675,22 +652,23 @@ impl dyn ChildWrapper + '_ { let Some(identity) = owner.identity else { return Ok(()); }; - let mut found = false; - self.visit_spawn_layers_until(|inner| { + let mut inner = self; + loop { if spawn_layer_identity(inner) == identity { - inner.disarm_job_object_layer()?; - found = true; - Ok(true) - } else { - Ok(false) + return inner.disarm_job_object_layer(); } - })?; - if found { - Ok(()) - } else { - Err(std::io::Error::other( - "the captured JobObject cleanup owner left the child chain", - )) + + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return Err(std::io::Error::other( + "the captured JobObject cleanup owner left the child chain", + )); + } + inner = next; } } diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 5488662..60726be 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -19,10 +19,15 @@ use crate::{ ChildExitStatus, windows::{ JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, - resume_threads, set_job_kill_on_drop, terminate_job, + resume_threads, terminate_job, }, }; +#[cfg(not(test))] +use crate::windows::set_job_kill_on_drop; +#[cfg(test)] +use crate::windows::set_job_kill_on_drop_observed; + #[cfg(feature = "creation-flags")] use super::CreationFlags; #[cfg(feature = "kill-on-drop")] @@ -213,16 +218,16 @@ impl JobObjectChild { .expect("the extracted JobObject layer retains its job handles"), ); } - self.prepared() - .with::(|prepared| { - inspect( - prepared - .job_port - .as_ref() - .expect("the installed JobObject layer retains its job handles"), - ) - }) - .expect("JobObject prepared state retains its concrete type") + self.prepared().with_required::( + || panic!("JobObject prepared state retains its concrete type"), + |prepared| { + let job_port = prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"); + inspect(job_port) + }, + ) } fn take_prepared(&mut self) -> PreparedJobObject { @@ -319,10 +324,16 @@ impl ChildWrapper for JobObjectChild { #[cfg(test)] crate::windows::test_support::fail_final_owner()?; let final_kill_on_drop = self.final_kill_on_drop; + #[cfg(test)] + let transition_probe = crate::windows::test_support::owner_transition_probe(); + #[cfg(test)] + self.with_job_port(|job_port| { + crate::windows::test_support::record_owner_event("before-owner-transition"); + set_job_kill_on_drop_observed(job_port.job, final_kill_on_drop, transition_probe) + })?; + #[cfg(not(test))] self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; - #[cfg(test)] - crate::windows::test_support::record_owner_event("owner-disarmed"); Ok(()) } @@ -420,8 +431,9 @@ mod tests { use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, - assert_tree_terminated, clear_extra_prepared_owners, clear_owner_events, - clear_owner_failure, observe_descendant, publish_process_guards, record_owner_event, + arm_owner_transition_probe, assert_tree_terminated, clear_extra_prepared_owners, + clear_owner_events, clear_owner_failure, finish_owner_transition_probe, observe_descendant, + publish_process_guards, record_owner_event, }; use super::*; @@ -701,7 +713,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn no_slot_accessor_runs_after_final_owner_disarm() -> Result<()> { + async fn final_owner_transition_has_no_later_test_or_caller_operation() -> Result<()> { for provider in [false, true] { let directory = tempfile::tempdir()?; let paths = TreePaths::new(directory.path()); @@ -719,8 +731,11 @@ mod tests { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clear(); + arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let (transitioned, allocator_callback, later_operation) = + finish_owner_transition_probe(); let mut succeeded = false; match outcome { Ok(Ok(mut child)) => { @@ -743,6 +758,15 @@ mod tests { clear_owner_events(), "the owner event barrier remained armed" ); + assert!(transitioned, "the native final-owner transition completed"); + assert!( + !allocator_callback, + "an allocator callback ran after the native final-owner transition" + ); + assert!( + !later_operation, + "a caller or test operation ran after the native final-owner transition" + ); let tree_result = assert_tree_terminated(&paths, &state); assert!( succeeded, @@ -753,8 +777,8 @@ mod tests { *events .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), - ["slot-accessor", "owner-disarmed"], - "the final owner transition follows the last caller-defined accessor" + ["slot-accessor", "before-owner-transition"], + "the final native transition follows the last caller-defined accessor" ); tree_result?; if attempt == 0 { diff --git a/src/windows.rs b/src/windows.rs index 757765a..400e745 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -257,7 +257,20 @@ pub(crate) mod test_support { }); } + pub fn arm_owner_transition_probe() { + crate::test_allocator::current_probe().arm(); + } + + pub fn owner_transition_probe() -> &'static crate::test_allocator::PostTransitionProbe { + crate::test_allocator::current_probe() + } + + pub fn finish_owner_transition_probe() -> (bool, bool, bool) { + crate::test_allocator::current_probe().finish() + } + pub fn record_owner_event(event: &'static str) { + crate::test_allocator::current_probe().observe_operation(); OWNER_EVENTS.with(|slot| { if let Some(events) = slot.borrow().as_ref() { events @@ -559,6 +572,28 @@ impl Drop for JobPort { /// Set whether closing a job's final handle terminates every process in the job. pub(crate) fn set_job_kill_on_drop(job: JobHandle, kill_on_drop: bool) -> Result<()> { + set_job_kill_on_drop_inner( + job, + kill_on_drop, + #[cfg(test)] + None, + ) +} + +#[cfg(test)] +pub(crate) fn set_job_kill_on_drop_observed( + job: JobHandle, + kill_on_drop: bool, + probe: &'static crate::test_allocator::PostTransitionProbe, +) -> Result<()> { + set_job_kill_on_drop_inner(job, kill_on_drop, Some(probe)) +} + +fn set_job_kill_on_drop_inner( + job: JobHandle, + kill_on_drop: bool, + #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, +) -> Result<()> { let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); if kill_on_drop { info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; @@ -573,7 +608,7 @@ pub(crate) fn set_job_kill_on_drop(job: JobHandle, kill_on_drop: bool) -> Result // No tracing or other caller-controlled callback may run after the native transition: the sole // final owner must either remain kill-on-close armed or complete disarming without unwinding. // SAFETY: `job` is live, and initialized `info` has the reported size and outlives the call. - unsafe { + let result = unsafe { SetInformationJobObject( job.0, JobObjectExtendedLimitInformation, @@ -582,8 +617,16 @@ pub(crate) fn set_job_kill_on_drop(job: JobHandle, kill_on_drop: bool) -> Result .try_into() .expect("cannot safely cast to DWORD"), ) - }?; - Ok(()) + }; + #[cfg(test)] + if result.is_ok() + && let Some(probe) = probe + { + probe + .transitioned + .store(true, std::sync::atomic::Ordering::SeqCst); + } + result.map_err(Error::other) } /// Create a JobObject and an associated completion port. From b01103636618a24ea279f5708ccf26abae099544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 17:39:02 +1300 Subject: [PATCH 070/114] fix(windows): linearize prepared access closure Co-Authored-By: Claude Code --- src/generic_wrap.rs | 386 +++++++++++++++++++++++++++++++++++++--- src/tokio/core.rs | 121 +++++++------ tests/spawn_provider.rs | 69 +++++-- 3 files changed, 488 insertions(+), 88 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 16fd1ca..5c82884 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -102,11 +102,107 @@ macro_rules! Wrap { } } + #[cfg(all(test, windows))] + struct PreparedStateTestRendezvous { + reached: ::std::sync::mpsc::Sender<()>, + release: ::std::sync::mpsc::Receiver<()>, + } + + #[cfg(all(test, windows))] + impl PreparedStateTestRendezvous { + fn wait(self) { + let _ = self.reached.send(()); + let _ = self.release.recv(); + } + } + + #[cfg(windows)] + #[derive(Default)] + struct PreparedChildAdmission { + closing: bool, + } + #[cfg(windows)] struct PreparedChildState { + // Accessors retain this lock through caller inspection. They take `admission` only long + // enough to linearize admission immediately before invoking the callback. value: ::std::sync::Mutex>>, + admission: ::std::sync::Mutex, type_id: ::std::any::TypeId, - revoked: ::std::sync::atomic::AtomicBool, + #[cfg(test)] + before_value_lock: ::std::sync::Mutex>, + #[cfg(test)] + after_closing: ::std::sync::Mutex>, + } + + #[cfg(windows)] + impl PreparedChildState { + fn admitted_value( + &self, + ) -> Option< + ::std::sync::MutexGuard<'_, Option>>, + > { + #[cfg(test)] + self.wait_before_value_lock(); + let value = self + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + let admission = self + .admission + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + if admission.closing { + drop(admission); + drop(value); + return None; + } + // Reading `closing == false` while holding `admission` is the accessor's + // linearization point. Keep `value` locked so a closer cannot take the box before or + // during the callback, but release `admission` so closing can be published. + drop(admission); + Some(value) + } + + fn close(&self) { + { + let mut admission = self + .admission + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + // The first write of `true` while holding `admission` is the revocation, + // failure-cleanup, or consuming-extraction linearization point. A later accessor + // must acquire this same mutex before inspecting the value and therefore observes + // the closed state. + admission.closing = true; + } + #[cfg(test)] + self.wait_after_closing(); + } + + #[cfg(test)] + fn wait_before_value_lock(&self) { + let rendezvous = self + .before_value_lock + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take(); + if let Some(rendezvous) = rendezvous { + rendezvous.wait(); + } + } + + #[cfg(test)] + fn wait_after_closing(&self) { + let rendezvous = self + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take(); + if let Some(rendezvous) = rendezvous { + rendezvous.wait(); + } + } } #[cfg(windows)] @@ -122,8 +218,12 @@ macro_rules! Wrap { Self { state: ::std::sync::Arc::new(PreparedChildState { value: ::std::sync::Mutex::new(Some(value)), + admission: ::std::sync::Mutex::new(PreparedChildAdmission::default()), type_id, - revoked: ::std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + before_value_lock: ::std::sync::Mutex::new(None), + #[cfg(test)] + after_closing: ::std::sync::Mutex::new(None), }), installed_layer: None, } @@ -139,7 +239,8 @@ macro_rules! Wrap { } } - fn take(&self) -> Option> { + fn close_and_take(&self) -> Option> { + self.state.close(); self.state .value .lock() @@ -167,13 +268,10 @@ macro_rules! Wrap { #[cfg(windows)] impl Drop for PreparedChildOwner { fn drop(&mut self) { - // Publish revocation before contending for the value lock. An inspection which already - // borrowed the value retains that lock, while every later locker observes revocation. - self.state - .revoked - .store(true, ::std::sync::atomic::Ordering::Release); - // `take` releases the lock before the value's destructor runs. - let value = self.take(); + // Closing is published through the admission mutex before contending for the value. + // `close_and_take` releases the value lock before returning, so caller-defined + // destruction runs without either protocol mutex held. + let value = self.close_and_take(); drop(value); } } @@ -254,13 +352,7 @@ macro_rules! Wrap { #[cfg(test)] crate::test_allocator::current_probe().observe_operation(); let state = self.state.upgrade()?; - let value = state - .value - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner); - if state.revoked.load(::std::sync::atomic::Ordering::Acquire) { - return None; - } + let value = state.admitted_value()?; value.as_deref()?.downcast_ref::().map(inspect) } @@ -275,13 +367,9 @@ macro_rules! Wrap { let Some(state) = self.state.upgrade() else { return unavailable(); }; - let value = state - .value - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner); - if state.revoked.load(::std::sync::atomic::Ordering::Acquire) { + let Some(value) = state.admitted_value() else { return unavailable(); - } + }; let Some(value) = value.as_deref().and_then(|value| value.downcast_ref::()) else { return unavailable(); }; @@ -291,13 +379,13 @@ macro_rules! Wrap { #[cfg(feature = "job-object")] pub(crate) fn take_prepared_value(&self) -> Option { let state = self.state.upgrade()?; + // Consuming extraction permanently closes public access before waiting for an active + // callback. A type mismatch restores the identical box while the state remains closed. + state.close(); let mut slot = state .value .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner); - if state.revoked.load(::std::sync::atomic::Ordering::Acquire) { - return None; - } let value = slot.take()?; match value.downcast::() { Ok(value) => Some(*value), @@ -309,6 +397,250 @@ macro_rules! Wrap { } } + #[cfg(all(test, windows))] + #[derive(Debug)] + struct PreparedRaceDrop(::std::sync::Arc<::std::sync::atomic::AtomicUsize>); + + #[cfg(all(test, windows))] + impl Drop for PreparedRaceDrop { + fn drop(&mut self) { + self.0 + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + } + } + + #[cfg(all(test, windows))] + struct PreparedRaceCleanup { + releases: Vec<::std::sync::mpsc::Sender<()>>, + workers: Vec<::std::thread::JoinHandle<()>>, + } + + #[cfg(all(test, windows))] + impl PreparedRaceCleanup { + fn new() -> Self { + Self { + releases: Vec::new(), + workers: Vec::new(), + } + } + + fn release_all(&self) { + for release in &self.releases { + let _ = release.send(()); + } + } + + fn finish(mut self) { + self.release_all(); + let mut first_panic = None; + for worker in ::std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + if first_panic.is_none() { + first_panic = Some(payload); + } else { + ::std::mem::forget(payload); + } + } + } + if let Some(payload) = first_panic { + ::std::panic::resume_unwind(payload); + } + } + } + + #[cfg(all(test, windows))] + impl Drop for PreparedRaceCleanup { + fn drop(&mut self) { + self.release_all(); + for worker in ::std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + ::std::mem::forget(payload); + } + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Clone, Copy)] + enum PreparedRaceCloser { + OwnerDrop, + ConsumingExtraction, + FailureCleanup, + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_queued_prepared_access_is_rejected(closer: PreparedRaceCloser) { + const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); + + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let callback_calls = + ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let state = ::std::sync::Arc::clone(&owner.state); + let token = ::std::sync::Arc::new(owner.installed_token()); + let mut cleanup = PreparedRaceCleanup::new(); + + let (active_entered_tx, active_entered_rx) = ::std::sync::mpsc::channel(); + let (active_release_tx, active_release_rx) = ::std::sync::mpsc::channel(); + let (active_result_tx, active_result_rx) = ::std::sync::mpsc::channel(); + cleanup.releases.push(active_release_tx.clone()); + let active_token = ::std::sync::Arc::clone(&token); + cleanup.workers.push(::std::thread::spawn(move || { + let completed = active_token.with::(|_| { + let _ = active_entered_tx.send(()); + active_release_rx.recv().is_ok() + }) == Some(true); + let _ = active_result_tx.send(completed); + })); + active_entered_rx + .recv_timeout(TIMEOUT) + .expect("the active accessor acquired the prepared value"); + + let (queued_reached_tx, queued_reached_rx) = ::std::sync::mpsc::channel(); + let (queued_release_tx, queued_release_rx) = ::std::sync::mpsc::channel(); + *state + .before_value_lock + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: queued_reached_tx, + release: queued_release_rx, + }); + cleanup.releases.push(queued_release_tx.clone()); + let (queued_result_tx, queued_result_rx) = ::std::sync::mpsc::channel(); + let queued_token = ::std::sync::Arc::clone(&token); + let queued_callback_calls = ::std::sync::Arc::clone(&callback_calls); + cleanup.workers.push(::std::thread::spawn(move || { + let accessed = queued_token + .with::(|_| { + queued_callback_calls + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + }) + .is_some(); + let _ = queued_result_tx.send(accessed); + })); + queued_reached_rx + .recv_timeout(TIMEOUT) + .expect("the queued accessor upgraded before closing"); + + let (closing_reached_tx, closing_reached_rx) = ::std::sync::mpsc::channel(); + let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); + *state + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: closing_reached_tx, + release: closing_release_rx, + }); + cleanup.releases.push(closing_release_tx.clone()); + queued_release_tx + .send(()) + .expect("release the queued accessor to contend for the value"); + let (destroyed_tx, destroyed_rx) = ::std::sync::mpsc::channel(); + let extraction_token = ::std::sync::Arc::clone(&token); + cleanup.workers.push(::std::thread::spawn(move || { + match closer { + PreparedRaceCloser::OwnerDrop => drop(owner), + PreparedRaceCloser::ConsumingExtraction => { + let extracted = extraction_token + .take_prepared_value::() + .expect("consuming extraction retains the prepared concrete type"); + drop(extracted); + drop(owner); + } + PreparedRaceCloser::FailureCleanup => { + let mut prepared = [Some(owner)]; + crate::command::Command::<$backend>::cleanup_prepared(&mut prepared); + } + } + let _ = destroyed_tx.send(()); + })); + closing_reached_rx + .recv_timeout(TIMEOUT) + .expect("the closing operation published its authoritative state"); + + assert_eq!( + destroyed_rx.try_recv(), + Err(::std::sync::mpsc::TryRecvError::Empty), + "closing returned while the active accessor retained the value" + ); + assert_eq!( + drops.load(::std::sync::atomic::Ordering::SeqCst), + 0, + "prepared state dropped during active access" + ); + active_release_tx + .send(()) + .expect("release the finite active accessor"); + assert!( + !queued_result_rx + .recv_timeout(TIMEOUT) + .expect("the queued accessor completed before the closer took the value"), + "the queued accessor invoked its callback after closing" + ); + assert_eq!( + callback_calls.load(::std::sync::atomic::Ordering::SeqCst), + 0 + ); + closing_release_tx + .send(()) + .expect("release closing to take the prepared value"); + destroyed_rx + .recv_timeout(TIMEOUT) + .expect("the closing operation completed"); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + assert!( + token.with::(|_| ()).is_none(), + "a retained token accessed state after closing" + ); + assert!( + active_result_rx + .recv_timeout(TIMEOUT) + .expect("the active accessor completed") + ); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn queued_prepared_access_is_rejected_by_owner_drop() { + assert_queued_prepared_access_is_rejected(PreparedRaceCloser::OwnerDrop); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn queued_prepared_access_is_rejected_by_consuming_extraction() { + assert_queued_prepared_access_is_rejected(PreparedRaceCloser::ConsumingExtraction); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn queued_prepared_access_is_rejected_by_failure_cleanup() { + assert_queued_prepared_access_is_rejected(PreparedRaceCloser::FailureCleanup); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn consuming_type_mismatch_restores_value_in_closed_state() { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let token = owner.installed_token(); + + assert!(token.take_prepared_value::().is_none()); + assert!(owner.has_value(), "the mismatched box remains in owner custody"); + assert!( + token.with::(|_| ()).is_none(), + "consuming extraction closes access even when the concrete type mismatches" + ); + drop(owner); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + #[cfg(windows)] struct PreparedChildSlot<'a> { value: &'a mut Option, @@ -1307,7 +1639,7 @@ macro_rules! Wrap { #[cfg(windows)] fn cleanup_prepared(prepared: &mut [Option]) { for prepared in prepared.iter_mut().filter_map(Option::take) { - if let Some(value) = prepared.take() { + if let Some(value) = prepared.close_and_take() { Self::dispose_value(value); } } diff --git a/src/tokio/core.rs b/src/tokio/core.rs index e40f438..b27cd10 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -863,6 +863,7 @@ mod prepared_output_wait_tests { fn assert_output_wait_drops_child_before_blocking_on_owner(complete: bool) { let prepared_drops = Arc::new(AtomicUsize::new(0)); let future_drops = Arc::new(AtomicUsize::new(0)); + let racing_callbacks = Arc::new(AtomicUsize::new(0)); let owner = PreparedChildOwner::new(Box::new(PreparedDrop(Arc::clone(&prepared_drops)))); let state = Arc::clone(&owner.state); let token = Arc::new(owner.installed_token()); @@ -874,30 +875,65 @@ mod prepared_output_wait_tests { vec![Some(owner)], ); let output = child.wait_with_output(); + let mut cleanup = PreparedRaceCleanup::new(); let (entered_tx, entered_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); let (inspection_tx, inspection_rx) = mpsc::channel(); + cleanup.releases.push(release_tx.clone()); let inspection_token = Arc::clone(&token); - let worker = thread::spawn(move || { + cleanup.workers.push(thread::spawn(move || { let completed = inspection_token.with::(|_| { - entered_tx - .send(()) - .expect("the test receives the active-access rendezvous"); - release_rx.recv_timeout(TIMEOUT).is_ok() + let _ = entered_tx.send(()); + release_rx.recv().is_ok() }) == Some(true); let _ = inspection_tx.send(completed); - }); + })); entered_rx .recv_timeout(TIMEOUT) .expect("prepared inspection entered after upgrading and locking state"); - let (started_tx, started_rx) = mpsc::channel(); + let (racing_reached_tx, racing_reached_rx) = mpsc::channel(); + let (racing_release_tx, racing_release_rx) = mpsc::channel(); + *state + .before_value_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { + reached: racing_reached_tx, + release: racing_release_rx, + }); + cleanup.releases.push(racing_release_tx.clone()); + let (racing_tx, racing_rx) = mpsc::channel(); + let racing_token = Arc::clone(&token); + let racing_callback_calls = Arc::clone(&racing_callbacks); + cleanup.workers.push(thread::spawn(move || { + let accessed = racing_token + .with::(|_| { + racing_callback_calls.fetch_add(1, Ordering::SeqCst); + }) + .is_some(); + let _ = racing_tx.send(accessed); + })); + racing_reached_rx + .recv_timeout(TIMEOUT) + .expect("the racing accessor upgraded before owner closing"); + + let (closing_reached_tx, closing_reached_rx) = mpsc::channel(); + let (closing_release_tx, closing_release_rx) = mpsc::channel(); + *state + .after_closing + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { + reached: closing_reached_tx, + release: closing_release_rx, + }); + cleanup.releases.push(closing_release_tx.clone()); + racing_release_tx + .send(()) + .expect("release the racing accessor to contend for the value"); + let (finished_tx, finished_rx) = mpsc::channel(); - let output_worker = thread::spawn(move || { - started_tx - .send(()) - .expect("the test observes output-wait disposal starting"); + cleanup.workers.push(thread::spawn(move || { if complete { let mut output = Box::into_pin(output); let waker = noop_waker(); @@ -910,39 +946,26 @@ mod prepared_output_wait_tests { drop(output); } let _ = finished_tx.send(()); - }); - started_rx + })); + closing_reached_rx .recv_timeout(TIMEOUT) - .expect("output-wait disposal thread started"); - - let deadline = std::time::Instant::now() + TIMEOUT; - while !state.revoked.load(std::sync::atomic::Ordering::Acquire) - && std::time::Instant::now() < deadline - { - thread::yield_now(); - } + .expect("output-wait disposal published owner closing"); assert!( - state.revoked.load(std::sync::atomic::Ordering::Acquire), - "prepared owner began revocation" + state + .admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .closing, + "the output-wait owner reached the authoritative closed state" ); - let (racing_tx, racing_rx) = mpsc::channel(); - let racing_token = Arc::clone(&token); - let racing_worker = thread::spawn(move || { - let _ = racing_tx.send(racing_token.with::(|_| ()).is_some()); - }); - - let deadline = std::time::Instant::now() + TIMEOUT; - while future_drops.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline { - thread::yield_now(); - } assert_eq!( future_drops.load(Ordering::SeqCst), 1, - "the child future drops before prepared-owner revocation" + "the child future drops before prepared-owner closing" ); assert_eq!( - finished_rx.recv_timeout(Duration::from_millis(250)), - Err(mpsc::RecvTimeoutError::Timeout), + finished_rx.try_recv(), + Err(mpsc::TryRecvError::Empty), "output-wait disposal returned during immutable prepared access" ); assert_eq!(prepared_drops.load(Ordering::SeqCst), 0); @@ -950,16 +973,20 @@ mod prepared_output_wait_tests { release_tx .send(()) .expect("release the finite prepared inspection"); - finished_rx - .recv_timeout(TIMEOUT) - .expect("output-wait disposal completes after prepared inspection release"); - assert_eq!(prepared_drops.load(Ordering::SeqCst), 1); assert!( !racing_rx .recv_timeout(TIMEOUT) - .expect("racing prepared access completed"), - "an access which raced with revocation reacquired the prepared value" + .expect("the queued prepared access completed before owner take"), + "a queued access invoked its callback after owner closing" ); + assert_eq!(racing_callbacks.load(Ordering::SeqCst), 0); + closing_release_tx + .send(()) + .expect("release output-wait owner disposal"); + finished_rx + .recv_timeout(TIMEOUT) + .expect("output-wait disposal completes after prepared inspection release"); + assert_eq!(prepared_drops.load(Ordering::SeqCst), 1); assert!( token.with::(|_| ()).is_none(), "a retained token accessed prepared state after owner destruction" @@ -969,15 +996,7 @@ mod prepared_output_wait_tests { .recv_timeout(TIMEOUT) .expect("prepared inspection worker completed") ); - worker - .join() - .expect("prepared inspection worker did not panic"); - racing_worker - .join() - .expect("racing prepared inspection worker did not panic"); - output_worker - .join() - .expect("output-wait disposal worker did not panic"); + cleanup.finish(); } #[test] diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index c7b174f..d74cc57 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -1476,17 +1476,71 @@ macro_rules! spawn_provider_tests { .expect("the retaining wrapper captured the installed token") } + #[cfg(windows)] + struct PreparedAccessCleanup { + releases: Vec>, + workers: Vec>, + } + + #[cfg(windows)] + impl PreparedAccessCleanup { + fn new() -> Self { + Self { + releases: Vec::new(), + workers: Vec::new(), + } + } + + fn release_all(&self) { + for release in &self.releases { + let _ = release.send(()); + } + } + + fn finish(mut self) { + self.release_all(); + let mut first_panic = None; + for worker in std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + if first_panic.is_none() { + first_panic = Some(payload); + } else { + std::mem::forget(payload); + } + } + } + if let Some(payload) = first_panic { + std::panic::resume_unwind(payload); + } + } + } + + #[cfg(windows)] + impl Drop for PreparedAccessCleanup { + fn drop(&mut self) { + self.release_all(); + for worker in std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + std::mem::forget(payload); + } + } + } + } + #[cfg(windows)] fn assert_active_prepared_access_blocks_destruction( token: PreparedChild, drops: Arc, destroy: impl FnOnce() + Send + 'static, ) { + let mut cleanup = PreparedAccessCleanup::new(); let (entered_tx, entered_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); let (retry_tx, retry_rx) = mpsc::channel(); let (inspection_tx, inspection_rx) = mpsc::channel(); - let worker = thread::spawn(move || { + cleanup.releases.push(release_tx.clone()); + cleanup.releases.push(retry_tx.clone()); + cleanup.workers.push(thread::spawn(move || { let completed = token .with::, _>(|prepared| { assert!(prepared.is_some()); @@ -1502,20 +1556,20 @@ macro_rules! spawn_provider_tests { .with::, _>(|prepared| prepared.is_some()) .is_some(); let _ = inspection_tx.send((completed, accessible_after_destruction)); - }); + })); entered_rx .recv_timeout(EXIT_TIMEOUT) .expect("prepared inspection entered after upgrading and locking state"); let (started_tx, started_rx) = mpsc::channel(); let (destroyed_tx, destroyed_rx) = mpsc::channel(); - let destroyer = thread::spawn(move || { + cleanup.workers.push(thread::spawn(move || { started_tx .send(()) .expect("the test observes destruction starting"); destroy(); let _ = destroyed_tx.send(()); - }); + })); started_rx .recv_timeout(EXIT_TIMEOUT) .expect("destruction thread started"); @@ -1548,12 +1602,7 @@ macro_rules! spawn_provider_tests { !accessible_after_destruction, "a retained token reacquired prepared state after owner destruction" ); - worker - .join() - .expect("prepared inspection worker did not panic"); - destroyer - .join() - .expect("authoritative destruction did not panic"); + cleanup.finish(); } fn provider_command_with_primary_failure( From 2e540f255ac9e41096d38fb1c3b0947b65cbda7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 17:53:24 +1300 Subject: [PATCH 071/114] fix(windows): retain spawn cleanup with child Co-Authored-By: Claude Code --- src/command.rs | 13 ++++-- src/generic_wrap.rs | 81 +++++++++++++++++++++++++------- src/lib.rs | 46 ++++++++++++++++++ src/std/job_object.rs | 101 ++++++++++++++++++++++++++++++++++++++-- src/tokio/job_object.rs | 101 ++++++++++++++++++++++++++++++++++++++-- src/windows.rs | 12 +++++ 6 files changed, 329 insertions(+), 25 deletions(-) diff --git a/src/command.rs b/src/command.rs index 9e52200..e8cac0d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -679,7 +679,7 @@ pub(crate) fn terminate_process_and_wait(process: BorrowedHandle<'_>) -> std::io #[cfg(windows)] #[derive(Debug)] pub(crate) struct WindowsSpawnCleanup { - process: OwnedHandle, + process: Option, armed: bool, } @@ -687,7 +687,7 @@ pub(crate) struct WindowsSpawnCleanup { impl WindowsSpawnCleanup { pub(crate) fn new(process: BorrowedHandle<'_>) -> std::io::Result { Ok(Self { - process: process.try_clone_to_owned()?, + process: Some(process.try_clone_to_owned()?), armed: true, }) } @@ -701,8 +701,15 @@ impl WindowsSpawnCleanup { impl Drop for WindowsSpawnCleanup { fn drop(&mut self) { if self.armed { - let _ = terminate_process_and_wait(self.process.as_handle()); + let process = self + .process + .as_ref() + .expect("an armed Windows spawn cleanup retains its process handle"); + let _ = terminate_process_and_wait(process.as_handle()); } + drop(self.process.take()); + #[cfg(test)] + crate::test_allocator::observe_spawn_cleanup_handle_close(); } } diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 5c82884..ba8ee3f 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -311,8 +311,9 @@ macro_rules! Wrap { "Moving or retaining this token cannot prolong the prepared value's lifetime. Resource ", "types which themselves expose interior mutation or independently clonable native owners ", "remain responsible for those capabilities.\n\n", - "On native Windows success, a private outer sidecar retains the strong prepared storage. ", - "It delegates the full child contract, exposes the immediate application layer through ", + "On native Windows success, a private outer sidecar retains the strong prepared storage and ", + "any disarmed spawn-cleanup handle until child disposal or consuming sidecar removal. It ", + "delegates the full child contract, exposes the immediate application layer through ", "`inner` and `inner_mut`, and consumes that application layer through `into_inner`. Its ", "private concrete type is the returned trait object's top-level `Any` identity while ", "prepared storage remains installed.\n\n", @@ -871,9 +872,11 @@ macro_rules! Wrap { #[cfg(windows)] struct PreparedOwnerChild { - // Field order is intentional: the complete child chain drops before prepared storage. + // Field order is intentional: the complete child chain drops before prepared storage and + // the disarmed duplicate process handle. child: Option>, prepared: Vec>, + spawn_cleanup: Option, } #[cfg(windows)] @@ -882,10 +885,19 @@ macro_rules! Wrap { child: Box, prepared: Vec>, ) -> Box { - debug_assert!(prepared.iter().any(Option::is_some)); + Self::new_with_spawn_cleanup(child, prepared, None) + } + + fn new_with_spawn_cleanup( + child: Box, + prepared: Vec>, + spawn_cleanup: Option, + ) -> Box { + debug_assert!(prepared.iter().any(Option::is_some) || spawn_cleanup.is_some()); Box::new(Self { child: Some(child), prepared, + spawn_cleanup, }) } @@ -904,6 +916,16 @@ macro_rules! Wrap { fn take_child(&mut self) -> Option> { self.child.take() } + + fn take_spawn_cleanup(&mut self) -> Option { + self.spawn_cleanup.take() + } + + fn disarm_spawn_cleanup(&mut self) { + if let Some(cleanup) = self.spawn_cleanup.as_mut() { + cleanup.disarm(); + } + } } #[cfg(windows)] @@ -940,6 +962,8 @@ macro_rules! Wrap { }) }); if self.prepared.is_empty() { + // Consuming the public sidecar ends its custody. `self` then closes any disarmed + // duplicate handle exactly once while the extracted child moves to the caller. inner } else { self.child = Some(inner); @@ -967,7 +991,7 @@ macro_rules! Wrap { #[cfg(windows)] enum NativeSuccessChild { Plain(Box), - Prepared(Box), + Sidecar(Box), } #[cfg(windows)] @@ -975,9 +999,14 @@ macro_rules! Wrap { fn new( child: Box, prepared: Vec>, + spawn_cleanup: Option, ) -> Self { - if prepared.iter().any(Option::is_some) { - Self::Prepared(PreparedOwnerChild::new(child, prepared)) + if prepared.iter().any(Option::is_some) || spawn_cleanup.is_some() { + Self::Sidecar(PreparedOwnerChild::new_with_spawn_cleanup( + child, + prepared, + spawn_cleanup, + )) } else { Self::Plain(child) } @@ -986,14 +1015,27 @@ macro_rules! Wrap { fn child_mut(&mut self) -> &mut dyn $childer { match self { Self::Plain(child) => child.as_mut(), - Self::Prepared(child) => child.as_mut(), + Self::Sidecar(child) => child.as_mut(), + } + } + + fn take_spawn_cleanup(&mut self) -> Option { + match self { + Self::Plain(_) => None, + Self::Sidecar(child) => child.take_spawn_cleanup(), + } + } + + fn disarm_spawn_cleanup(&mut self) { + if let Self::Sidecar(child) = self { + child.disarm_spawn_cleanup(); } } fn into_child(self) -> Box { match self { Self::Plain(child) => child, - Self::Prepared(child) => child, + Self::Sidecar(child) => child, } } } @@ -1576,13 +1618,14 @@ macro_rules! Wrap { let mut success = NativeSuccessChild::new( child, ::std::mem::take(&mut prepared), + cleanup, ); let final_owner = match Self::capture_io(|| { success.child_mut().finalize_spawn_before_commit() }) { Ok(owner) => owner, Err(failure) => { - drop(cleanup.take()); + Self::cleanup_native_spawn_guard(&mut success); Self::cleanup_pending_child(&mut pending); Self::cleanup_native_success(success); return failure.finish::>(); @@ -1596,13 +1639,11 @@ macro_rules! Wrap { }); match final_result { Ok(()) => { - if let Some(cleanup) = cleanup.as_mut() { - cleanup.disarm(); - } + success.disarm_spawn_cleanup(); Ok(success.into_child()) } Err(failure) => { - drop(cleanup.take()); + Self::cleanup_native_spawn_guard(&mut success); Self::cleanup_pending_child(&mut pending); Self::cleanup_native_success(success); failure.finish::>() @@ -1646,10 +1687,18 @@ macro_rules! Wrap { } #[cfg(windows)] - fn cleanup_native_success(success: NativeSuccessChild) { + fn cleanup_native_spawn_guard(success: &mut NativeSuccessChild) { + if let Some(cleanup) = success.take_spawn_cleanup() { + Self::dispose_value(cleanup); + } + } + + #[cfg(windows)] + fn cleanup_native_success(mut success: NativeSuccessChild) { + Self::cleanup_native_spawn_guard(&mut success); match success { NativeSuccessChild::Plain(child) => Self::dispose_value(child), - NativeSuccessChild::Prepared(mut sidecar) => { + NativeSuccessChild::Sidecar(mut sidecar) => { Self::cleanup_child(&mut sidecar.child); Self::cleanup_prepared(&mut sidecar.prepared); Self::dispose_value(sidecar); diff --git a/src/lib.rs b/src/lib.rs index cd11178..ea89abc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -678,8 +678,37 @@ pub(crate) mod test_allocator { } } + #[derive(Default)] + pub(crate) struct SpawnCleanupHandleProbe { + active: AtomicBool, + closes: std::sync::atomic::AtomicUsize, + } + + impl SpawnCleanupHandleProbe { + pub(crate) fn arm(&self) { + assert!(!self.active.swap(true, Ordering::SeqCst)); + self.closes.store(0, Ordering::SeqCst); + } + + fn observe_close(&self) { + if self.active.load(Ordering::SeqCst) { + self.closes.fetch_add(1, Ordering::SeqCst); + } + } + + pub(crate) fn close_count(&self) -> usize { + self.closes.load(Ordering::SeqCst) + } + + pub(crate) fn finish(&self) -> usize { + self.active.store(false, Ordering::SeqCst); + self.close_count() + } + } + thread_local! { static POST_TRANSITION_PROBE: Cell> = const { Cell::new(None) }; + static SPAWN_CLEANUP_HANDLE_PROBE: Cell> = const { Cell::new(None) }; } pub(crate) fn current_probe() -> &'static PostTransitionProbe { @@ -691,6 +720,23 @@ pub(crate) mod test_allocator { probe } + pub(crate) fn current_spawn_cleanup_handle_probe() -> &'static SpawnCleanupHandleProbe { + if let Some(probe) = SPAWN_CLEANUP_HANDLE_PROBE.get() { + return probe; + } + let probe = Box::leak(Box::new(SpawnCleanupHandleProbe::default())); + SPAWN_CLEANUP_HANDLE_PROBE.set(Some(probe)); + probe + } + + pub(crate) fn observe_spawn_cleanup_handle_close() { + let _ = SPAWN_CLEANUP_HANDLE_PROBE.try_with(|slot| { + if let Some(probe) = slot.get() { + probe.observe_close(); + } + }); + } + fn observe_allocator_callback() { let _ = POST_TRANSITION_PROBE.try_with(|slot| { if let Some(probe) = slot.get() { diff --git a/src/std/job_object.rs b/src/std/job_object.rs index a0a4933..a4a630a 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -438,9 +438,10 @@ mod tests { use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, - arm_owner_transition_probe, assert_tree_terminated, clear_extra_prepared_owners, - clear_owner_events, clear_owner_failure, finish_owner_transition_probe, observe_descendant, - publish_process_guards, record_owner_event, + arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, assert_tree_terminated, + clear_extra_prepared_owners, clear_owner_events, clear_owner_failure, + finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, observe_descendant, + publish_process_guards, record_owner_event, spawn_cleanup_handle_close_count, }; use super::*; @@ -660,6 +661,100 @@ mod tests { Ok(command) } + #[test] + fn native_spawn_cleanup_handle_closes_with_returned_child() -> Result<()> { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(false, &paths, &state)?; + + for attempt in 0..2 { + arm_spawn_cleanup_handle_probe(); + let mut child = command.spawn()?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "the exact duplicate process handle remains owned through public spawn return" + ); + child.start_kill()?; + let _ = child.wait()?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "child operations retain cleanup-handle custody" + ); + if attempt == 0 { + drop(child); + } else { + let child = child.into_inner(); + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "consuming the custody sidecar closes the exact duplicate once" + ); + drop(child); + } + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "returned-child disposal closes the exact duplicate process handle once" + ); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert_tree_terminated(&paths, &state)?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + Ok(()) + } + + #[test] + fn native_final_owner_failures_close_armed_spawn_cleanup_once() -> Result<()> { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + let mut command = topology_command(false, &paths, &state)?; + arm_owner_failure(failure); + arm_owner_transition_probe(); + arm_spawn_cleanup_handle_probe(); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let (transitioned, allocator_callback, later_operation) = + finish_owner_transition_probe(); + assert!(!transitioned, "the native policy transition must not run"); + assert!(!allocator_callback); + assert!(!later_operation); + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert!( + !failure_was_not_consumed, + "the final owner hook did not consume its injected failure" + ); + assert!( + primary_preserved, + "armed cleanup replaced the final owner failure" + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait()?; + drop(child); + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + #[test] fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() -> Result<()> { for provider in [false, true] { diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 60726be..ec2c5d4 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -431,9 +431,10 @@ mod tests { use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, - arm_owner_transition_probe, assert_tree_terminated, clear_extra_prepared_owners, - clear_owner_events, clear_owner_failure, finish_owner_transition_probe, observe_descendant, - publish_process_guards, record_owner_event, + arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, assert_tree_terminated, + clear_extra_prepared_owners, clear_owner_events, clear_owner_failure, + finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, observe_descendant, + publish_process_guards, record_owner_event, spawn_cleanup_handle_close_count, }; use super::*; @@ -659,6 +660,100 @@ mod tests { Ok(command) } + #[tokio::test(flavor = "current_thread")] + async fn native_spawn_cleanup_handle_closes_with_returned_child() -> Result<()> { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(false, &paths, &state)?; + + for attempt in 0..2 { + arm_spawn_cleanup_handle_probe(); + let mut child = command.spawn()?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "the exact duplicate process handle remains owned through public spawn return" + ); + child.start_kill()?; + let _ = child.wait().await?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "child operations retain cleanup-handle custody" + ); + if attempt == 0 { + drop(child); + } else { + let child = child.into_inner(); + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "consuming the custody sidecar closes the exact duplicate once" + ); + drop(child); + } + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "returned-child disposal closes the exact duplicate process handle once" + ); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert_tree_terminated(&paths, &state)?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn native_final_owner_failures_close_armed_spawn_cleanup_once() -> Result<()> { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + let mut command = topology_command(false, &paths, &state)?; + arm_owner_failure(failure); + arm_owner_transition_probe(); + arm_spawn_cleanup_handle_probe(); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let (transitioned, allocator_callback, later_operation) = + finish_owner_transition_probe(); + assert!(!transitioned, "the native policy transition must not run"); + assert!(!allocator_callback); + assert!(!later_operation); + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert!( + !failure_was_not_consumed, + "the final owner hook did not consume its injected failure" + ); + assert!( + primary_preserved, + "armed cleanup replaced the final owner failure" + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait().await?; + drop(child); + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() -> Result<()> { diff --git a/src/windows.rs b/src/windows.rs index 400e745..9d92d50 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -269,6 +269,18 @@ pub(crate) mod test_support { crate::test_allocator::current_probe().finish() } + pub fn arm_spawn_cleanup_handle_probe() { + crate::test_allocator::current_spawn_cleanup_handle_probe().arm(); + } + + pub fn spawn_cleanup_handle_close_count() -> usize { + crate::test_allocator::current_spawn_cleanup_handle_probe().close_count() + } + + pub fn finish_spawn_cleanup_handle_probe() -> usize { + crate::test_allocator::current_spawn_cleanup_handle_probe().finish() + } + pub fn record_owner_event(event: &'static str) { crate::test_allocator::current_probe().observe_operation(); OWNER_EVENTS.with(|slot| { From e4e9b6e19e7368c6d58de98b75315831300319bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 18:06:04 +1300 Subject: [PATCH 072/114] fix(windows): gate job test support by feature Keep Windows frontend-only test builds free of JobObject-only probe and race support. Co-Authored-By: Claude Code --- src/command.rs | 2 +- src/generic_wrap.rs | 12 ++++++------ src/lib.rs | 7 +++++++ src/tokio/core.rs | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/command.rs b/src/command.rs index e8cac0d..ee5fbae 100644 --- a/src/command.rs +++ b/src/command.rs @@ -708,7 +708,7 @@ impl Drop for WindowsSpawnCleanup { let _ = terminate_process_and_wait(process.as_handle()); } drop(self.process.take()); - #[cfg(test)] + #[cfg(all(test, feature = "job-object"))] crate::test_allocator::observe_spawn_cleanup_handle_close(); } } diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index ba8ee3f..388c304 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -398,11 +398,11 @@ macro_rules! Wrap { } } - #[cfg(all(test, windows))] + #[cfg(all(test, windows, feature = "job-object"))] #[derive(Debug)] struct PreparedRaceDrop(::std::sync::Arc<::std::sync::atomic::AtomicUsize>); - #[cfg(all(test, windows))] + #[cfg(all(test, windows, feature = "job-object"))] impl Drop for PreparedRaceDrop { fn drop(&mut self) { self.0 @@ -410,13 +410,13 @@ macro_rules! Wrap { } } - #[cfg(all(test, windows))] + #[cfg(all(test, windows, feature = "job-object"))] struct PreparedRaceCleanup { releases: Vec<::std::sync::mpsc::Sender<()>>, workers: Vec<::std::thread::JoinHandle<()>>, } - #[cfg(all(test, windows))] + #[cfg(all(test, windows, feature = "job-object"))] impl PreparedRaceCleanup { fn new() -> Self { Self { @@ -449,7 +449,7 @@ macro_rules! Wrap { } } - #[cfg(all(test, windows))] + #[cfg(all(test, windows, feature = "job-object"))] impl Drop for PreparedRaceCleanup { fn drop(&mut self) { self.release_all(); @@ -767,7 +767,7 @@ macro_rules! Wrap { } prepared.installed_layer = Some(installed_layer); *slot.value = Some(prepared.installed_token()); - #[cfg(test)] + #[cfg(all(test, feature = "job-object"))] if crate::windows::test_support::take_extra_prepared_owner_injection() { crate::windows::test_support::retain_extra_prepared_owner(Box::new( ::std::sync::Arc::clone(&prepared.state), diff --git a/src/lib.rs b/src/lib.rs index ea89abc..040462c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -649,6 +649,7 @@ pub(crate) mod test_allocator { } impl PostTransitionProbe { + #[cfg(feature = "job-object")] pub(crate) fn arm(&self) { assert!(!self.active.swap(true, Ordering::SeqCst)); self.transitioned.store(false, Ordering::SeqCst); @@ -668,6 +669,7 @@ pub(crate) mod test_allocator { } } + #[cfg(feature = "job-object")] pub(crate) fn finish(&self) -> (bool, bool, bool) { self.active.store(false, Ordering::SeqCst); ( @@ -678,12 +680,14 @@ pub(crate) mod test_allocator { } } + #[cfg(feature = "job-object")] #[derive(Default)] pub(crate) struct SpawnCleanupHandleProbe { active: AtomicBool, closes: std::sync::atomic::AtomicUsize, } + #[cfg(feature = "job-object")] impl SpawnCleanupHandleProbe { pub(crate) fn arm(&self) { assert!(!self.active.swap(true, Ordering::SeqCst)); @@ -708,6 +712,7 @@ pub(crate) mod test_allocator { thread_local! { static POST_TRANSITION_PROBE: Cell> = const { Cell::new(None) }; + #[cfg(feature = "job-object")] static SPAWN_CLEANUP_HANDLE_PROBE: Cell> = const { Cell::new(None) }; } @@ -720,6 +725,7 @@ pub(crate) mod test_allocator { probe } + #[cfg(feature = "job-object")] pub(crate) fn current_spawn_cleanup_handle_probe() -> &'static SpawnCleanupHandleProbe { if let Some(probe) = SPAWN_CLEANUP_HANDLE_PROBE.get() { return probe; @@ -729,6 +735,7 @@ pub(crate) mod test_allocator { probe } + #[cfg(feature = "job-object")] pub(crate) fn observe_spawn_cleanup_handle_close() { let _ = SPAWN_CLEANUP_HANDLE_PROBE.try_with(|slot| { if let Some(probe) = slot.get() { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index b27cd10..e579bf3 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -771,7 +771,7 @@ const _: () = { assert_sync::(); }; -#[cfg(all(test, windows))] +#[cfg(all(test, windows, feature = "job-object"))] mod prepared_output_wait_tests { use std::{ future::Future, From 4262a0d20f834d3044d611c27a6763021a782f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 18:31:56 +1300 Subject: [PATCH 073/114] fix(tests): gate wrapper lookup macro Select the generated lookup tests only when a frontend can instantiate them. Co-Authored-By: Claude Code --- tests/wrapper_lookup.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/wrapper_lookup.rs b/tests/wrapper_lookup.rs index 7cc6a4e..b5a6db9 100644 --- a/tests/wrapper_lookup.rs +++ b/tests/wrapper_lookup.rs @@ -1,3 +1,4 @@ +#[cfg(any(feature = "std", feature = "tokio1"))] macro_rules! wrapper_lookup_tests { ($module:ident, $command_wrap:path, $command_wrapper:path) => { mod $module { From aedd2a35ac45aa572ac12e55ecdfe741bdd40741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 18:58:25 +1300 Subject: [PATCH 074/114] fix(tests): isolate pipe close checks Keep PTY fixture writers out of concurrent child execs and wait boundedly for fork-time copies to close. Co-Authored-By: Claude Code --- src/tokio/pty/unix.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 142ed8f..51babcb 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -643,12 +643,12 @@ fn closed() -> io::Error { mod tests { use std::os::fd::RawFd; - use nix::unistd::pipe; + use nix::unistd::pipe2; use super::*; fn pipe_pair() -> (OwnedFd, OwnedFd) { - let (reader, writer) = pipe().unwrap(); + let (reader, writer) = pipe2(OFlag::O_CLOEXEC).unwrap(); set_nonblocking(&reader).unwrap(); (reader, writer) } @@ -659,6 +659,26 @@ mod tests { } fn assert_closed(reader: &OwnedFd) { + let mut descriptor = libc::pollfd { + fd: reader.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + loop { + // SAFETY: `descriptor` is initialized for one live descriptor and remains valid for the call. + let ready = unsafe { libc::poll(&mut descriptor, 1, 5_000) }; + if ready > 0 { + break; + } + if ready == 0 { + panic!("pipe reader timed out waiting for writer closure"); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + panic!("pipe reader failed while waiting for writer closure: {error}"); + } + } + let mut byte = 0_u8; // SAFETY: reader is a live nonblocking pipe descriptor and byte is writable for one byte. let read = @@ -726,6 +746,17 @@ mod tests { } } + #[test] + fn pipe_fixture_descriptors_are_close_on_exec() { + let (reader, writer) = pipe_pair(); + for descriptor in [&reader, &writer] { + let flags = FdFlag::from_bits_truncate( + fcntl(descriptor, FcntlArg::F_GETFD).expect("read pipe descriptor flags"), + ); + assert!(flags.contains(FdFlag::FD_CLOEXEC)); + } + } + #[test] fn winsize_preserves_character_and_pixel_dimensions() { let native = winsize(PtySize { From 67ffeb8b5908542d4e80ff501402a620b3399741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 20:17:00 +1300 Subject: [PATCH 075/114] test(windows): prove prepared closure paths Co-Authored-By: Claude Code --- src/generic_wrap.rs | 187 ++++++++++++++++++++++++++++++++++++++++++-- src/tokio/core.rs | 2 +- 2 files changed, 182 insertions(+), 7 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 388c304..2396165 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -130,7 +130,7 @@ macro_rules! Wrap { admission: ::std::sync::Mutex, type_id: ::std::any::TypeId, #[cfg(test)] - before_value_lock: ::std::sync::Mutex>, + value_lock_attempt: ::std::sync::Mutex>, #[cfg(test)] after_closing: ::std::sync::Mutex>, } @@ -143,7 +143,7 @@ macro_rules! Wrap { ::std::sync::MutexGuard<'_, Option>>, > { #[cfg(test)] - self.wait_before_value_lock(); + self.wait_for_value_lock_attempt(); let value = self .value .lock() @@ -181,13 +181,20 @@ macro_rules! Wrap { } #[cfg(test)] - fn wait_before_value_lock(&self) { + fn wait_for_value_lock_attempt(&self) { let rendezvous = self - .before_value_lock + .value_lock_attempt .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner) .take(); if let Some(rendezvous) = rendezvous { + assert!( + matches!( + self.value.try_lock(), + Err(::std::sync::TryLockError::WouldBlock) + ), + "the acquisition-attempt probe requires an active value owner" + ); rendezvous.wait(); } } @@ -221,7 +228,7 @@ macro_rules! Wrap { admission: ::std::sync::Mutex::new(PreparedChildAdmission::default()), type_id, #[cfg(test)] - before_value_lock: ::std::sync::Mutex::new(None), + value_lock_attempt: ::std::sync::Mutex::new(None), #[cfg(test)] after_closing: ::std::sync::Mutex::new(None), }), @@ -469,6 +476,45 @@ macro_rules! Wrap { FailureCleanup, } + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Clone, Copy)] + enum PreparedLifecyclePath { + NativeDrop, + ProviderDrop, + NativeExtraction, + ProviderExtraction, + FailureCleanup, + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedLifecycleChild { + prepared: Option, + retained: Option, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl $childer for PreparedLifecycleChild { + fn inner(&self) -> &dyn $childer { + self + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn retain_prepared_after_sidecar_removal_layer(&mut self) { + self.retained = self + .prepared + .as_ref() + .and_then(|prepared| prepared.take_prepared_value::()); + } + } + #[cfg(all(test, windows, feature = "job-object"))] fn assert_queued_prepared_access_is_rejected(closer: PreparedRaceCloser) { const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); @@ -502,7 +548,7 @@ macro_rules! Wrap { let (queued_reached_tx, queued_reached_rx) = ::std::sync::mpsc::channel(); let (queued_release_tx, queued_release_rx) = ::std::sync::mpsc::channel(); *state - .before_value_lock + .value_lock_attempt .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { @@ -605,6 +651,105 @@ macro_rules! Wrap { cleanup.finish(); } + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_prepared_lifecycle_path_closes(path: PreparedLifecyclePath) { + const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); + + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let state = ::std::sync::Arc::clone(&owner.state); + let token = owner.installed_token(); + let extracts = matches!( + path, + PreparedLifecyclePath::NativeExtraction + | PreparedLifecyclePath::ProviderExtraction + ); + let child: Box = Box::new(PreparedLifecycleChild { + prepared: extracts.then(|| owner.installed_token()), + retained: None, + }); + let mut cleanup = PreparedRaceCleanup::new(); + + let (closing_reached_tx, closing_reached_rx) = ::std::sync::mpsc::channel(); + let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); + *state + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: closing_reached_tx, + release: closing_release_rx, + }); + cleanup.releases.push(closing_release_tx.clone()); + let (finished_tx, finished_rx) = ::std::sync::mpsc::channel(); + cleanup.workers.push(::std::thread::spawn(move || { + match path { + PreparedLifecyclePath::NativeDrop => { + let returned: Box = + PreparedOwnerChild::new(child, vec![Some(owner)]); + drop(returned); + } + PreparedLifecyclePath::ProviderDrop => { + let returned: Box = + CommittedProviderChild::new(child, vec![Some(owner)]); + drop(returned); + } + PreparedLifecyclePath::NativeExtraction => { + let returned: Box = + PreparedOwnerChild::new(child, vec![Some(owner)]); + drop(returned.into_inner()); + } + PreparedLifecyclePath::ProviderExtraction => { + let returned: Box = + CommittedProviderChild::new(child, vec![Some(owner)]); + drop(returned.into_inner()); + } + PreparedLifecyclePath::FailureCleanup => { + drop(child); + let mut prepared = [Some(owner)]; + crate::command::Command::<$backend>::cleanup_prepared(&mut prepared); + } + } + let _ = finished_tx.send(()); + })); + + closing_reached_rx + .recv_timeout(TIMEOUT) + .expect("the lifecycle path published prepared closing"); + assert!( + state + .admission + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .closing, + "the lifecycle probe observes the actual closed state" + ); + assert_eq!( + finished_rx.try_recv(), + Err(::std::sync::mpsc::TryRecvError::Empty), + "the lifecycle path passed its paused close transition" + ); + assert_eq!( + drops.load(::std::sync::atomic::Ordering::SeqCst), + 0, + "the prepared value was disposed before the close transition resumed" + ); + closing_release_tx + .send(()) + .expect("release the lifecycle close transition"); + finished_rx + .recv_timeout(TIMEOUT) + .expect("the lifecycle path completed"); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + assert!( + token.with::(|_| ()).is_none(), + "the lifecycle path left prepared state accessible after closing" + ); + cleanup.finish(); + } + #[cfg(all(test, windows, feature = "job-object"))] #[test] fn queued_prepared_access_is_rejected_by_owner_drop() { @@ -623,6 +768,36 @@ macro_rules! Wrap { assert_queued_prepared_access_is_rejected(PreparedRaceCloser::FailureCleanup); } + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn native_returned_child_drop_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::NativeDrop); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn provider_returned_child_drop_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::ProviderDrop); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn native_consuming_extraction_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::NativeExtraction); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn provider_consuming_extraction_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::ProviderExtraction); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn failed_spawn_cleanup_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::FailureCleanup); + } + #[cfg(all(test, windows, feature = "job-object"))] #[test] fn consuming_type_mismatch_restores_value_in_closed_state() { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index e579bf3..c2002b3 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -896,7 +896,7 @@ mod prepared_output_wait_tests { let (racing_reached_tx, racing_reached_rx) = mpsc::channel(); let (racing_release_tx, racing_release_rx) = mpsc::channel(); *state - .before_value_lock + .value_lock_attempt .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { reached: racing_reached_tx, From f78720f905c1767ceca51da6cc89060d9abdf242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 20:20:28 +1300 Subject: [PATCH 076/114] fix(tests): make descriptor fixture portable Co-Authored-By: Claude Code --- src/tokio/pty/unix.rs | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 51babcb..9478c9d 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -643,12 +643,16 @@ fn closed() -> io::Error { mod tests { use std::os::fd::RawFd; + #[cfg(not(target_os = "macos"))] use nix::unistd::pipe2; use super::*; - fn pipe_pair() -> (OwnedFd, OwnedFd) { + fn descriptor_pair() -> (OwnedFd, OwnedFd) { + #[cfg(not(target_os = "macos"))] let (reader, writer) = pipe2(OFlag::O_CLOEXEC).unwrap(); + #[cfg(target_os = "macos")] + let (reader, writer) = open_pty(PtySize::default()).unwrap(); set_nonblocking(&reader).unwrap(); (reader, writer) } @@ -671,30 +675,27 @@ mod tests { break; } if ready == 0 { - panic!("pipe reader timed out waiting for writer closure"); + panic!("descriptor reader timed out waiting for writer closure"); } let error = io::Error::last_os_error(); if error.kind() != io::ErrorKind::Interrupted { - panic!("pipe reader failed while waiting for writer closure: {error}"); + panic!("descriptor reader failed while waiting for writer closure: {error}"); } } - let mut byte = 0_u8; - // SAFETY: reader is a live nonblocking pipe descriptor and byte is writable for one byte. - let read = - unsafe { libc::read(reader.as_raw_fd(), std::ptr::from_mut(&mut byte).cast(), 1) }; - if read != 0 { - panic!( - "pipe reader did not observe writer closure: {}", - io::Error::last_os_error() - ); + let mut byte = [0_u8]; + match read(reader, &mut byte) { + Ok(0) => {} + Ok(read) => panic!("descriptor reader observed {read} bytes instead of closure"), + Err(error) if is_eof(&error) => {} + Err(error) => panic!("descriptor reader failed while confirming closure: {error}"), } } fn descriptors() -> (OwnedFd, OwnedFd, OwnedFd, [OwnedFd; 3], [RawFd; 3]) { - let (stdin_reader, stdin) = pipe_pair(); - let (stdout_reader, stdout) = pipe_pair(); - let (stderr_reader, stderr) = pipe_pair(); + let (stdin_reader, stdin) = descriptor_pair(); + let (stdout_reader, stdout) = descriptor_pair(); + let (stderr_reader, stderr) = descriptor_pair(); let raw = [stdin.as_raw_fd(), stdout.as_raw_fd(), stderr.as_raw_fd()]; ( stdin, @@ -747,11 +748,11 @@ mod tests { } #[test] - fn pipe_fixture_descriptors_are_close_on_exec() { - let (reader, writer) = pipe_pair(); + fn descriptor_fixture_descriptors_are_close_on_exec() { + let (reader, writer) = descriptor_pair(); for descriptor in [&reader, &writer] { let flags = FdFlag::from_bits_truncate( - fcntl(descriptor, FcntlArg::F_GETFD).expect("read pipe descriptor flags"), + fcntl(descriptor, FcntlArg::F_GETFD).expect("read fixture descriptor flags"), ); assert!(flags.contains(FdFlag::FD_CLOEXEC)); } From 55cd40eb2ef7438dbf71707c0d4013ebac2d7458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 20:24:47 +1300 Subject: [PATCH 077/114] fix(tests): bound descriptor closure wait Co-Authored-By: Claude Code --- src/tokio/pty/unix.rs | 122 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 13 deletions(-) diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 9478c9d..b8d5a1e 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -641,7 +641,10 @@ fn closed() -> io::Error { #[cfg(test)] mod tests { - use std::os::fd::RawFd; + use std::{ + os::fd::RawFd, + time::{Duration, Instant}, + }; #[cfg(not(target_os = "macos"))] use nix::unistd::pipe2; @@ -662,26 +665,59 @@ mod tests { assert_ne!(unsafe { libc::fcntl(fd, libc::F_GETFD) }, -1); } + fn wait_for_descriptor_readiness( + timeout: Duration, + mut now: impl FnMut() -> Instant, + mut poll: impl FnMut(i32) -> io::Result, + ) -> io::Result<()> { + let deadline = now().checked_add(timeout).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "descriptor wait deadline overflowed", + ) + })?; + loop { + let remaining = deadline + .checked_duration_since(now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "timed out waiting for writer closure", + ) + })?; + let timeout_millis = remaining + .as_nanos() + .div_ceil(1_000_000) + .min(i32::MAX as u128); + let timeout_millis = i32::try_from(timeout_millis).map_err(io::Error::other)?; + match poll(timeout_millis) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + } + fn assert_closed(reader: &OwnedFd) { let mut descriptor = libc::pollfd { fd: reader.as_raw_fd(), events: libc::POLLIN, revents: 0, }; - loop { + wait_for_descriptor_readiness(Duration::from_secs(5), Instant::now, |timeout| { // SAFETY: `descriptor` is initialized for one live descriptor and remains valid for the call. - let ready = unsafe { libc::poll(&mut descriptor, 1, 5_000) }; - if ready > 0 { - break; - } - if ready == 0 { - panic!("descriptor reader timed out waiting for writer closure"); - } - let error = io::Error::last_os_error(); - if error.kind() != io::ErrorKind::Interrupted { - panic!("descriptor reader failed while waiting for writer closure: {error}"); + let ready = unsafe { libc::poll(&mut descriptor, 1, timeout) }; + if ready == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(ready > 0) } - } + }) + .unwrap_or_else(|error| { + panic!("descriptor reader failed while waiting for writer closure: {error}") + }); let mut byte = [0_u8]; match read(reader, &mut byte) { @@ -758,6 +794,66 @@ mod tests { } } + #[test] + fn interrupted_descriptor_wait_reuses_one_total_deadline() { + let start = Instant::now(); + let mut times = [ + start, + start + Duration::from_secs(1), + start + Duration::from_secs(3), + start + Duration::from_micros(4_999_500), + ] + .into_iter(); + let mut outcomes = [ + Err(io::Error::from(io::ErrorKind::Interrupted)), + Err(io::Error::from(io::ErrorKind::Interrupted)), + Ok(true), + ] + .into_iter(); + let mut timeouts = Vec::new(); + + wait_for_descriptor_readiness( + Duration::from_secs(5), + || times.next().expect("the clock has a value for every check"), + |timeout| { + timeouts.push(timeout); + outcomes.next().expect("the poll model has an outcome") + }, + ) + .expect("readiness arrives before the total deadline"); + + assert_eq!(timeouts, [4_000, 2_000, 1]); + } + + #[test] + fn interrupted_descriptor_wait_expires_before_another_poll() { + let start = Instant::now(); + let mut times = [ + start, + start + Duration::from_secs(4), + start + Duration::from_secs(5), + ] + .into_iter(); + let mut polls = 0; + + let error = wait_for_descriptor_readiness( + Duration::from_secs(5), + || times.next().expect("the clock has a value for every check"), + |_| { + polls += 1; + if polls == 1 { + Err(io::Error::from(io::ErrorKind::Interrupted)) + } else { + panic!("poll was called after the total deadline") + } + }, + ) + .expect_err("the interrupted wait reaches its total deadline"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert_eq!(polls, 1); + } + #[test] fn winsize_preserves_character_and_pixel_dimensions() { let native = winsize(PtySize { From c1ff2b7e9ef0698ff68fae36f0bc0920bda1ac51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 21:29:54 +1300 Subject: [PATCH 078/114] test(windows): prove prepared extraction transfer Co-Authored-By: Claude Code --- src/generic_wrap.rs | 70 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 2396165..8172506 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -486,11 +486,41 @@ macro_rules! Wrap { FailureCleanup, } + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedExtractionPhase<'a>(&'a ::std::sync::atomic::AtomicBool); + + #[cfg(all(test, windows, feature = "job-object"))] + impl<'a> PreparedExtractionPhase<'a> { + fn enter(active: &'a ::std::sync::atomic::AtomicBool) -> Self { + assert!( + active + .compare_exchange( + false, + true, + ::std::sync::atomic::Ordering::SeqCst, + ::std::sync::atomic::Ordering::SeqCst, + ) + .is_ok(), + "prepared extraction phase entered more than once" + ); + Self(active) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedExtractionPhase<'_> { + fn drop(&mut self) { + self.0 + .store(false, ::std::sync::atomic::Ordering::SeqCst); + } + } + #[cfg(all(test, windows, feature = "job-object"))] #[derive(Debug)] struct PreparedLifecycleChild { prepared: Option, retained: Option, + extraction_phase: ::std::sync::Arc<::std::sync::atomic::AtomicBool>, } #[cfg(all(test, windows, feature = "job-object"))] @@ -508,10 +538,17 @@ macro_rules! Wrap { } fn retain_prepared_after_sidecar_removal_layer(&mut self) { - self.retained = self + let prepared = self .prepared .as_ref() - .and_then(|prepared| prepared.take_prepared_value::()); + .expect("the extraction fixture retains its prepared token"); + let retained = { + let _phase = PreparedExtractionPhase::enter(&self.extraction_phase); + prepared + .take_prepared_value::() + .expect("consuming extraction transfers the prepared concrete value") + }; + self.retained = Some(retained); } } @@ -666,9 +703,12 @@ macro_rules! Wrap { PreparedLifecyclePath::NativeExtraction | PreparedLifecyclePath::ProviderExtraction ); + let extraction_phase = + ::std::sync::Arc::new(::std::sync::atomic::AtomicBool::new(false)); let child: Box = Box::new(PreparedLifecycleChild { prepared: extracts.then(|| owner.installed_token()), retained: None, + extraction_phase: ::std::sync::Arc::clone(&extraction_phase), }); let mut cleanup = PreparedRaceCleanup::new(); @@ -718,6 +758,12 @@ macro_rules! Wrap { closing_reached_rx .recv_timeout(TIMEOUT) .expect("the lifecycle path published prepared closing"); + if extracts { + assert!( + extraction_phase.load(::std::sync::atomic::Ordering::SeqCst), + "consuming extraction must publish closing inside the prepared transfer" + ); + } assert!( state .admission @@ -742,6 +788,12 @@ macro_rules! Wrap { finished_rx .recv_timeout(TIMEOUT) .expect("the lifecycle path completed"); + if extracts { + assert!( + !extraction_phase.load(::std::sync::atomic::Ordering::SeqCst), + "prepared extraction phase remained active after transfer" + ); + } assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); assert!( token.with::(|_| ()).is_none(), @@ -798,6 +850,20 @@ macro_rules! Wrap { assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::FailureCleanup); } + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn prepared_extraction_phase_clears_during_unwind() { + let active = ::std::sync::atomic::AtomicBool::new(false); + let panic = ::std::panic::catch_unwind(|| { + let _phase = PreparedExtractionPhase::enter(&active); + assert!(active.load(::std::sync::atomic::Ordering::SeqCst)); + panic!("injected prepared extraction failure"); + }); + + assert!(panic.is_err()); + assert!(!active.load(::std::sync::atomic::Ordering::SeqCst)); + } + #[cfg(all(test, windows, feature = "job-object"))] #[test] fn consuming_type_mismatch_restores_value_in_closed_state() { From 1024ae78d6752f18880ec634812e5dcf18f6d206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 22:04:21 +1300 Subject: [PATCH 079/114] test(miri): ignore native cleanup fixture Co-Authored-By: Claude Code --- tests/support/bounded_process.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/support/bounded_process.rs b/tests/support/bounded_process.rs index 3c23b20..2b5113a 100644 --- a/tests/support/bounded_process.rs +++ b/tests/support/bounded_process.rs @@ -284,6 +284,7 @@ mod tests { use super::*; #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn reader_start_failures_terminate_reap_and_join_before_returning() { const MARKER_ENV: &str = "PROCESS_WRAP_READER_SETUP_MARKER"; From a2a67dee46bdd0b9d493ed7596e026b9d324edd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 22:48:08 +1300 Subject: [PATCH 080/114] test(miri): ignore native subprocess regressions Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index d74cc57..f3d32df 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -2287,6 +2287,7 @@ macro_rules! spawn_provider_tests { } #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn bounded_subprocess_reaps_the_timeout_path() { const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TIMEOUT_HELPER"; let child_value = stringify!($module); @@ -2305,6 +2306,7 @@ macro_rules! spawn_provider_tests { } #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn rollback_and_transaction_unwinds_are_separated() { const CHILD_ENV: &str = "PROCESS_WRAP_SEPARATE_PROVIDER_UNWINDS"; let child_value = stringify!($module); @@ -2383,6 +2385,7 @@ macro_rules! spawn_provider_tests { } #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn child_destructor_panics_do_not_replace_post_spawn_or_commit_panics() { const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_CHILD_PANIC"; let module = stringify!($module); From 81757605851ad893d25c7e5025b01b5c77669cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Sun, 27 Sep 2026 22:58:04 +1300 Subject: [PATCH 081/114] test(miri): make panic quarantine payloads leak-free Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 94 +++++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index f3d32df..691b02a 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -85,16 +85,45 @@ macro_rules! spawn_provider_tests { Panic(&'static str), } + #[cfg(miri)] + #[derive(Debug)] + struct MiriRollbackPanicPayload; + + #[cfg(miri)] + impl Drop for MiriRollbackPanicPayload { + fn drop(&mut self) { + panic_any(()); + } + } + #[derive(Debug)] struct CommittedDropPayload(Arc<()>); #[derive(Debug)] struct PanickingDropPayload { + #[cfg(not(miri))] drops: Arc, } + impl PanickingDropPayload { + fn new(drops: &Arc) -> Self { + #[cfg(miri)] + { + let _ = drops; + Self {} + } + #[cfg(not(miri))] + { + Self { + drops: Arc::clone(drops), + } + } + } + } + impl Drop for PanickingDropPayload { fn drop(&mut self) { + #[cfg(not(miri))] self.drops.fetch_add(1, Ordering::SeqCst); panic_any("a secondary panic payload was dropped"); } @@ -249,7 +278,14 @@ macro_rules! spawn_provider_tests { match failure { Some(Failure::Error(kind, message)) => Err(io::Error::new(kind, message)), - Some(Failure::Panic(message)) => panic_any(message), + Some(Failure::Panic(message)) => { + #[cfg(miri)] + if point == Point::Rollback { + let _ = message; + panic_any(MiriRollbackPanicPayload); + } + panic_any(message); + } None => Ok(()), } } @@ -2231,9 +2267,9 @@ macro_rules! spawn_provider_tests { for was_panic in [false, true] { let shared = Arc::new(Shared::default()); let secondary_drops = Arc::new(AtomicUsize::new(0)); - shared.set_rollback_behavior(RollbackBehavior::Panic(PanickingDropPayload { - drops: Arc::clone(&secondary_drops), - })); + shared.set_rollback_behavior(RollbackBehavior::Panic( + PanickingDropPayload::new(&secondary_drops), + )); let identity = Arc::new(()); let failure = if was_panic { PrimaryFailure::Panic(Arc::clone(&identity)) @@ -2262,9 +2298,9 @@ macro_rules! spawn_provider_tests { let shared = Arc::new(Shared::default()); let secondary_drops = Arc::new(AtomicUsize::new(0)); shared.set_transaction_drop_behavior( - TransactionDropBehavior::PanicSecondary(PanickingDropPayload { - drops: Arc::clone(&secondary_drops), - }), + TransactionDropBehavior::PanicSecondary(PanickingDropPayload::new( + &secondary_drops, + )), ); let identity = Arc::new(()); let failure = if was_panic { @@ -2331,13 +2367,11 @@ macro_rules! spawn_provider_tests { let shared = Arc::new(Shared::default()); let rollback_payload_drops = Arc::new(AtomicUsize::new(0)); let transaction_payload_drops = Arc::new(AtomicUsize::new(0)); - shared.set_rollback_behavior(RollbackBehavior::Panic(PanickingDropPayload { - drops: Arc::clone(&rollback_payload_drops), - })); + shared.set_rollback_behavior(RollbackBehavior::Panic( + PanickingDropPayload::new(&rollback_payload_drops), + )); shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( - PanickingDropPayload { - drops: Arc::clone(&transaction_payload_drops), - }, + PanickingDropPayload::new(&transaction_payload_drops), )); let identity = Arc::new(()); let mut command = provider_command_with_primary_failure( @@ -2359,9 +2393,7 @@ macro_rules! spawn_provider_tests { for fail_commit in [false, true] { let shared = Arc::new(Shared::default()); let secondary_drops = Arc::new(AtomicUsize::new(0)); - shared.set_child_drop_payload(PanickingDropPayload { - drops: Arc::clone(&secondary_drops), - }); + shared.set_child_drop_payload(PanickingDropPayload::new(&secondary_drops)); let identity = Arc::new(()); let mut command = if fail_commit { shared.set_commit_primary_failure(PrimaryFailure::Error(Arc::clone(&identity))); @@ -2420,9 +2452,7 @@ macro_rules! spawn_provider_tests { .ends_with(":commit"); let shared = Arc::new(Shared::default()); let secondary_drops = Arc::new(AtomicUsize::new(0)); - shared.set_child_drop_payload(PanickingDropPayload { - drops: Arc::clone(&secondary_drops), - }); + shared.set_child_drop_payload(PanickingDropPayload::new(&secondary_drops)); let identity = Arc::new(()); let mut command = if fail_commit { shared.set_commit_primary_failure(PrimaryFailure::Panic(Arc::clone(&identity))); @@ -2889,15 +2919,11 @@ macro_rules! spawn_provider_tests { command .wrap(FirstPrepared { shared: Arc::clone(&shared), - payload: Mutex::new(Some(PanickingDropPayload { - drops: Arc::clone(&first_drops), - })), + payload: Mutex::new(Some(PanickingDropPayload::new(&first_drops))), }) .wrap(SecondPrepared { shared: Arc::clone(&shared), - payload: Mutex::new(Some(PanickingDropPayload { - drops: Arc::clone(&second_drops), - })), + payload: Mutex::new(Some(PanickingDropPayload::new(&second_drops))), }) .wrap(FailPrepare(Mutex::new(Some(failure)))); @@ -3068,9 +3094,9 @@ macro_rules! spawn_provider_tests { shared.set_finalization_layers(layers.clone()); shared.fail_once(Point::DisarmOwner, failure); shared.set_transaction_drop_behavior( - TransactionDropBehavior::PanicSecondary(PanickingDropPayload { - drops: Arc::clone(&secondary_drops), - }), + TransactionDropBehavior::PanicSecondary(PanickingDropPayload::new( + &secondary_drops, + )), ); let mut command = provider_command(Arc::clone(&shared), "provider"); @@ -3128,13 +3154,9 @@ macro_rules! spawn_provider_tests { let child_payload_drops = Arc::new(AtomicUsize::new(0)); let prepared_payload_drops = Arc::new(AtomicUsize::new(0)); let residue_payload_drops = Arc::new(AtomicUsize::new(0)); - shared.set_child_drop_payload(PanickingDropPayload { - drops: Arc::clone(&child_payload_drops), - }); + shared.set_child_drop_payload(PanickingDropPayload::new(&child_payload_drops)); shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( - PanickingDropPayload { - drops: Arc::clone(&residue_payload_drops), - }, + PanickingDropPayload::new(&residue_payload_drops), )); let layers = vec![finalization_layer("owner", true)]; shared.set_finalization_layers(layers.clone()); @@ -3153,9 +3175,7 @@ macro_rules! spawn_provider_tests { let mut command = provider_command(Arc::clone(&shared), "provider"); command.wrap(FirstPrepared { shared: Arc::clone(&shared), - payload: Mutex::new(Some(PanickingDropPayload { - drops: Arc::clone(&prepared_payload_drops), - })), + payload: Mutex::new(Some(PanickingDropPayload::new(&prepared_payload_drops))), }); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); From 3c2cea4f36ab888d83dcf2f6a85e3aa174233588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 00:57:23 +1300 Subject: [PATCH 082/114] fix(lifecycle): contain tracing subscriber panics Capture generic lifecycle events with their callbacks so provider rollback retains transaction and child custody. Quarantine ConPTY diagnostic subscriber payloads before retry bookkeeping continues. Co-Authored-By: Claude Code --- src/generic_wrap.rs | 60 ++--- src/tokio/pty/windows/spawn.rs | 101 ++++++++- tests/spawn_provider.rs | 387 +++++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+), 41 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 8172506..628c828 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -1630,17 +1630,17 @@ macro_rules! Wrap { ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { - #[cfg(feature = "tracing")] - { - let id = self - .wrapper_registry() - .wrappers - .get_index(index) - .expect("wrapper indices cannot disappear during ordered hook traversal") - .0; - ::tracing::debug!(?id, "prepare_child"); - } let value = Self::capture_io(|| { + #[cfg(feature = "tracing")] + { + let id = self + .wrapper_registry() + .wrappers + .get_index(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .0; + ::tracing::debug!(?id, "prepare_child"); + } self.with_wrapper_at(index, |wrapper, command| { wrapper.prepare_child(attempt, child, command) }) @@ -1659,17 +1659,17 @@ macro_rules! Wrap { ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { - #[cfg(feature = "tracing")] - { - let id = self - .wrapper_registry() - .wrappers - .get_index(index) - .expect("wrapper indices cannot disappear during ordered hook traversal") - .0; - ::tracing::debug!(?id, "post_spawn"); - } Self::capture_io(|| { + #[cfg(feature = "tracing")] + { + let id = self + .wrapper_registry() + .wrappers + .get_index(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .0; + ::tracing::debug!(?id, "post_spawn"); + } self.with_wrapper_at(index, |wrapper, command| { wrapper.post_spawn(attempt, child, command) }) @@ -1688,17 +1688,17 @@ macro_rules! Wrap { ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { - #[cfg(feature = "tracing")] - { - let id = self - .wrapper_registry() - .wrappers - .get_index(index) - .expect("wrapper indices cannot disappear during ordered hook traversal") - .0; - ::tracing::debug!(?id, "wrap_child"); - } let layer = Self::capture_io(|| { + #[cfg(feature = "tracing")] + { + let id = self + .wrapper_registry() + .wrappers + .get_index(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .0; + ::tracing::debug!(?id, "wrap_child"); + } self.with_wrapper_at(index, |wrapper, command| { let child = child .as_deref_mut() diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs index aec455a..353b9e9 100644 --- a/src/tokio/pty/windows/spawn.rs +++ b/src/tokio/pty/windows/spawn.rs @@ -400,8 +400,9 @@ impl PendingDiagnostics { ); }); })); - if result.is_ok() { - emitted.push(diagnostic.id); + match result { + Ok(()) => emitted.push(diagnostic.id), + Err(payload) => std::mem::forget(payload), } } let mut state = self @@ -431,11 +432,16 @@ impl PendingDiagnostics { #[cfg(all(test, feature = "tracing"))] fn len(&self) -> usize { - self.state + self.snapshot().2 + } + + #[cfg(all(test, feature = "tracing"))] + fn snapshot(&self) -> (bool, bool, usize) { + let state = self + .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .pending - .len() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.emitting, state.queued, state.pending.len()) } } @@ -964,13 +970,29 @@ mod tests { } #[cfg(feature = "tracing")] - #[derive(Clone, Copy, Debug)] + #[derive(Debug)] enum SubscriberBehavior { Block, Panic, + PanicDrop { + fired: std::sync::atomic::AtomicBool, + drops: Arc, + }, Record, } + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct PanickingDiagnosticPayload(Arc); + + #[cfg(feature = "tracing")] + impl Drop for PanickingDiagnosticPayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a caught diagnostic panic payload was dropped"); + } + } + #[cfg(feature = "tracing")] #[derive(Debug, Default)] struct SubscriberGate { @@ -1063,13 +1085,18 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner); gate.events += 1; self.state.changed.notify_all(); - match self.state.behavior { + match &self.state.behavior { SubscriberBehavior::Block => { while !gate.released { gate = self.state.changed.wait(gate).unwrap(); } } SubscriberBehavior::Panic => panic!("injected cleanup diagnostic panic"), + SubscriberBehavior::PanicDrop { fired, drops } => { + if !fired.swap(true, Ordering::SeqCst) { + panic_any(PanickingDiagnosticPayload(Arc::clone(drops))); + } + } SubscriberBehavior::Record => {} } } @@ -1114,6 +1141,14 @@ mod tests { waiters: usize, } + struct ActiveDiagnosticThread<'a>(&'a AtomicUsize); + + impl Drop for ActiveDiagnosticThread<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } + } + struct TestState { terminate_error: Option, probes: Mutex>, @@ -1329,13 +1364,11 @@ mod tests { .active_diagnostic_threads .fetch_add(1, Ordering::SeqCst) + 1; + let _active = ActiveDiagnosticThread(&state.active_diagnostic_threads); state .max_active_diagnostic_threads .fetch_max(active, Ordering::SeqCst); task(); - state - .active_diagnostic_threads - .fetch_sub(1, Ordering::SeqCst); }) .map(drop) } @@ -1631,6 +1664,52 @@ mod tests { assert_eq!(subscriber.event_count(), 1); } + #[cfg(feature = "tracing")] + #[test] + fn panicking_drop_diagnostic_payload_is_quarantined_and_retried() { + let operations = TestOperations::new(None, [WaitPlan::Timeout, WaitPlan::Signaled], 1); + let payload_drops = Arc::new(AtomicUsize::new(0)); + let subscriber = SubscriberState::new(SubscriberBehavior::PanicDrop { + fired: std::sync::atomic::AtomicBool::new(false), + drops: Arc::clone(&payload_drops), + }); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + let diagnostics = process.pending_diagnostics(); + + let error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .expect_err("the first reaper start failure remains the primary result"); + assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); + subscriber.wait_for_event(); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(diagnostics.snapshot(), (false, true, 1)); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + let (next, next_raw, next_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(next, operations.clone()) + }) + .expect("the later cleanup retains its own primary result"); + operations.wait_for_reaper(); + subscriber.wait_for_events(2); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(diagnostics.snapshot(), (false, false, 0)); + assert_eq!(operations.queued_diagnostics_len(), 0); + assert_eq!(operations.quarantine_len(), 0); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_released(&next_released, next_raw); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + } + #[cfg(feature = "tracing")] #[test] fn panicking_diagnostic_subscriber_cannot_replace_the_primary_error() { diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 691b02a..ad30ace 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -3,6 +3,113 @@ #[path = "support/bounded_process.rs"] mod bounded_process; +#[cfg(feature = "tracing")] +mod lifecycle_tracing { + use std::{ + fmt, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + use tracing::{ + Event, Metadata, Subscriber, + field::{Field, Visit}, + span::{Attributes, Id, Record}, + }; + + #[derive(Debug)] + pub struct LifecyclePanic(pub Arc<()>); + + #[derive(Debug)] + pub struct PanicOnLifecycleEvent { + message: &'static str, + match_count: AtomicUsize, + panic_on_match: usize, + identity: Arc<()>, + } + + impl PanicOnLifecycleEvent { + pub fn dispatch( + message: &'static str, + panic_on_match: usize, + identity: Arc<()>, + ) -> tracing::Dispatch { + tracing::Dispatch::new(Self { + message, + match_count: AtomicUsize::new(0), + panic_on_match, + identity, + }) + } + } + + pub fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn with_dispatch(dispatch: &tracing::Dispatch, invoke: impl FnOnce() -> R) -> R { + tracing::dispatcher::with_default(dispatch, || { + tracing::callsite::rebuild_interest_cache(); + invoke() + }) + } + + #[derive(Default)] + struct MessageVisitor { + message: Option, + } + + impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + self.message = Some(format!("{value:?}")); + } + } + } + + impl Subscriber for PanicOnLifecycleEvent { + fn register_callsite( + &self, + _metadata: &'static Metadata<'static>, + ) -> tracing::subscriber::Interest { + tracing::subscriber::Interest::always() + } + + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + if visitor.message.as_deref() != Some(self.message) { + return; + } + let matched = self.match_count.fetch_add(1, Ordering::SeqCst) + 1; + if matched == self.panic_on_match { + std::panic::panic_any(LifecyclePanic(Arc::clone(&self.identity))); + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} + } +} + macro_rules! spawn_provider_tests { ( $module:ident, @@ -2207,6 +2314,203 @@ macro_rules! spawn_provider_tests { } } + #[cfg(feature = "tracing")] + #[test] + fn lifecycle_event_panics_roll_back_exactly_once_and_allow_reuse() { + let _tracing_guard = super::lifecycle_tracing::serial(); + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for event in ["post_spawn", "wrap_child"] { + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = match outcome { + Err(payload) => payload, + Ok(result) => { + panic!("the {event} lifecycle event must resume its panic: {result:?}") + } + }; + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!( + shared + .events() + .iter() + .filter(|candidate| **candidate == Event::Rollback) + .count(), + 1, + "the armed transaction rolls back exactly once" + ); + + shared.clear_events(); + let child = super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the one-shot subscriber leaves the command reusable"); + drop(child); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[cfg(feature = "tracing")] + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn lifecycle_event_panics_quarantine_child_and_transaction_destructors() { + let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TRACE_CLEANUP_PANICS"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for event in ["post_spawn", "wrap_child"] { + let child_value = format!("{module}:{event}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::lifecycle_event_panics_quarantine_child_and_transaction_destructors" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "lifecycle tracing cleanup exceeded its deadline"); + assert!( + output.status.success(), + "{event} tracing cleanup did not preserve containment:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let event = if selected + .as_deref() + .expect("the subprocess selects a lifecycle event") + .ends_with(":post_spawn") + { + "post_spawn" + } else { + "wrap_child" + }; + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let child_payload_drops = Arc::new(AtomicUsize::new(0)); + let transaction_payload_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload::new(&child_payload_drops)); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( + PanickingDropPayload::new(&transaction_payload_drops), + )); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = outcome.expect_err("the lifecycle event must resume its panic"); + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(child_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(transaction_payload_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().contains(&Event::Rollback)); + assert!(shared.events().contains(&Event::TransactionDrop)); + assert!(shared.events().contains(&Event::ChildDrop)); + + shared.clear_events(); + drop( + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the command remains reusable"), + ); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[cfg(all(windows, feature = "tracing"))] + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn prepare_event_panic_quarantines_prepared_destructor_and_allows_reuse() { + let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_PREPARE_TRACE_PANIC"; + let module = stringify!($module); + if std::env::var_os(CHILD_ENV).as_deref() != Some(OsStr::new(module)) { + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::prepare_event_panic_quarantines_prepared_destructor_and_allows_reuse" + ), + (CHILD_ENV, module), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "prepare tracing cleanup exceeded its deadline"); + assert!( + output.status.success(), + "prepare tracing cleanup did not preserve containment:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let prepared_payload_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + "prepare_child", + 4, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(FirstPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload::new( + &prepared_payload_drops, + ))), + }) + .wrap(FailPrepare(Mutex::new(None))); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = outcome.expect_err("the prepare event must resume its panic"); + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(prepared_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!( + shared + .events() + .iter() + .filter(|candidate| **candidate == Event::Rollback) + .count(), + 1 + ); + + shared.clear_events(); + drop( + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the command remains reusable"), + ); + assert_eq!(shared.events(), successful_events("provider")); + } + #[test] fn rollback_failures_preserve_the_original_failure() { let runtime = runtime(); @@ -3753,6 +4057,7 @@ macro_rules! real_provider_tests { paths: Mutex>, last_child: Mutex>>>>, drop_behavior: Mutex>, + wait_for_ready_before_return: std::sync::atomic::AtomicBool, } impl Shared { @@ -3922,6 +4227,13 @@ macro_rules! real_provider_tests { let child = command.spawn()?; let child = Arc::new(Mutex::new(Box::new(child) as Box)); *self.0.last_child.lock().unwrap() = Some(Arc::clone(&child)); + if self + .0 + .wait_for_ready_before_return + .swap(false, Ordering::SeqCst) + { + wait_for_path(&paths.ready); + } Ok(ProviderProduct::new( Box::new(SharedProcessChild { child: Arc::clone(&child), @@ -4113,6 +4425,81 @@ macro_rules! real_provider_tests { ); } + #[cfg(feature = "tracing")] + #[test] + #[cfg_attr(miri, ignore = "requires native child processes")] + fn lifecycle_event_panic_rolls_back_and_reaps_a_live_child() { + let _tracing_guard = super::lifecycle_tracing::serial(); + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for event in ["post_spawn", "wrap_child"] { + let directory = tempfile::tempdir().unwrap(); + let paths = MarkerPaths::new(directory.path()); + let shared = Arc::new(Shared::default()); + shared.set_paths(paths.clone()); + shared + .wait_for_ready_before_return + .store(true, Ordering::SeqCst); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = command(Arc::clone(&shared)); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = match outcome { + Err(payload) => payload, + Ok(result) => { + panic!("the {event} lifecycle event must resume its panic: {result:?}") + } + }; + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(shared.events(), [Event::Rollback]); + assert!( + shared + .child() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + .unwrap() + .is_some(), + "rollback reaps the live provider child before resuming the panic" + ); + fs::write(&paths.go, b"go").unwrap(); + sleep(Duration::from_millis(900)); + assert!( + !paths.marker.exists(), + "the rolled-back child wrote its delayed marker" + ); + shared.clear_child(); + + let reused_directory = tempfile::tempdir().unwrap(); + let reused_paths = MarkerPaths::new(reused_directory.path()); + shared.set_paths(reused_paths.clone()); + shared + .wait_for_ready_before_return + .store(true, Ordering::SeqCst); + shared.events.lock().unwrap().clear(); + let mut child = + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the command remains reusable"); + child + .start_kill() + .expect("terminate the reused provider child"); + $wait_for_child!(runtime, child).expect("reap the reused provider child"); + drop(child); + shared.clear_child(); + assert_eq!(shared.events(), [Event::Commit]); + } + } + #[test] #[cfg_attr(miri, ignore = "requires native child processes")] fn rollback_reaps_the_child_before_transaction_disposal_panics() { From 710e164105e1a5642bbd19e18245ba6bf56cea06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 01:07:18 +1300 Subject: [PATCH 083/114] fix(windows): validate prepared custody before publication Reject escaped strong prepared owners while a detached layer is still private, then permit documented temporary reader leases after publication without reclassifying them as custody owners. Co-Authored-By: Claude Code --- src/generic_wrap.rs | 25 +--- tests/spawn_provider.rs | 269 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 19 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 628c828..fb8dbb4 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -1014,6 +1014,12 @@ macro_rules! Wrap { ::std::sync::Arc::clone(&prepared.state), )); } + if !prepared.has_exclusive_custody() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state has an unexpected custody topology", + )); + } } (Some(_), _) => { return Err(::std::io::Error::new( @@ -1746,21 +1752,6 @@ macro_rules! Wrap { Ok(()) } - #[cfg(windows)] - fn validate_prepared_custody( - prepared: &[Option], - ) -> ::std::io::Result<()> { - for prepared in prepared.iter().flatten() { - if !prepared.has_exclusive_custody() { - return Err(::std::io::Error::new( - ::std::io::ErrorKind::InvalidInput, - "prepared child state has an unexpected custody topology", - )); - } - } - Ok(()) - } - fn finish_spawn( &mut self, attempt: &mut SpawnAttempt, @@ -1831,8 +1822,6 @@ macro_rules! Wrap { #[cfg(windows)] &mut prepared, )?; - #[cfg(windows)] - Self::capture_io(|| Self::validate_prepared_custody(&prepared))?; Ok(()) })(); @@ -2014,8 +2003,6 @@ macro_rules! Wrap { #[cfg(windows)] &mut prepared, )?; - #[cfg(windows)] - Self::capture_io(|| Self::validate_prepared_custody(&prepared))?; Ok(()) })(); diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index ad30ace..a82d207 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -835,6 +835,147 @@ macro_rules! spawn_provider_tests { } } + + #[cfg(windows)] + #[derive(Debug)] + struct ActivePreparedReader { + entered: mpsc::Sender<()>, + release: Mutex>>, + worker: Mutex>>, + } + + #[cfg(windows)] + impl ActivePreparedReader { + fn new() -> (Arc, mpsc::Receiver<()>) { + let (entered, observe_entered) = mpsc::channel(); + ( + Arc::new(Self { + entered, + release: Mutex::new(None), + worker: Mutex::new(None), + }), + observe_entered, + ) + } + + fn start(&self, token: PreparedChild) { + let (active_tx, active_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + token.with::, _>(|prepared| { + assert!(prepared.is_some()); + active_tx + .send(()) + .expect("the wrapping callback observes active prepared access"); + release_rx.recv_timeout(EXIT_TIMEOUT).is_ok() + }) == Some(true) + }); + active_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("the prepared reader enters before wrapping returns"); + *self + .release + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(release_tx); + *self + .worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(worker); + let _ = self.entered.send(()); + } + + fn finish(&self) -> bool { + if let Some(release) = self + .release + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + self.worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .is_none_or(|worker| worker.join().unwrap_or(false)) + } + } + + #[cfg(windows)] + impl Drop for ActivePreparedReader { + fn drop(&mut self) { + if let Some(release) = self + .release + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + if let Some(worker) = self + .worker + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + && let Err(payload) = worker.join() + { + std::mem::forget(payload); + } + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct AccessInstalledPrepared { + start_once: bool, + reader: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for AccessInstalledPrepared { + fn wrap_prepared_child( + &mut self, + child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!(prepared.is_none()); + if self.start_once { + self.start_once = false; + let layer = (child as &mut dyn std::any::Any) + .downcast_mut::() + .expect("the prepared layer is immediately inside this wrapper"); + let token = layer + .prepared + .take() + .expect("the earlier layer exposes its installed token"); + self.reader.start(token); + } + Ok(None) + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct FailAfterPreparedAccess { + fail_once: bool, + } + + #[cfg(windows)] + impl CommandWrapper for FailAfterPreparedAccess { + fn wrap_child( + &mut self, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result> { + if std::mem::take(&mut self.fail_once) { + Err(io::Error::other("later wrapping failed")) + } else { + Ok(None) + } + } + } + #[cfg(windows)] #[derive(Debug)] struct AlternatingPreparedLayer { @@ -2918,6 +3059,134 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[test] + fn active_reader_during_later_wrapping_is_not_an_escaped_owner() { + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let (reader, entered) = ActivePreparedReader::new(); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(AccessInstalledPrepared { + start_once: true, + reader: Arc::clone(&reader), + }); + let (spawned, observe_spawn) = mpsc::channel(); + let spawn_worker = thread::spawn(move || { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let result = command.spawn(); + let _ = spawned.send((command, result)); + }); + + entered + .recv_timeout(EXIT_TIMEOUT) + .expect("the later wrapper starts an admitted prepared reader"); + let outcome = observe_spawn.recv_timeout(Duration::from_secs(2)); + if outcome.is_err() { + let _ = reader.finish(); + } + let (mut command, result) = outcome.unwrap_or_else(|_| { + observe_spawn + .recv_timeout(EXIT_TIMEOUT) + .expect("spawn returns after emergency reader release") + }); + spawn_worker.join().expect("the spawn worker completes"); + let child = result.expect( + "a sanctioned prepared reader may remain active across completed wrapping", + ); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(reader.finish(), "the admitted prepared callback completes"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn later_failure_waits_for_active_reader_and_preserves_primary_error() { + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let (reader, entered) = ActivePreparedReader::new(); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(AccessInstalledPrepared { + start_once: true, + reader: Arc::clone(&reader), + }) + .wrap(FailAfterPreparedAccess { fail_once: true }); + let (finished, observe_finish) = mpsc::channel(); + let spawn_worker = thread::spawn(move || { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let result = command.spawn(); + let _ = finished.send((command, result)); + }); + + entered + .recv_timeout(EXIT_TIMEOUT) + .expect("the later wrapper starts an admitted prepared reader"); + let early = observe_finish.recv_timeout(Duration::from_millis(100)); + let remained_pending = matches!(&early, Err(mpsc::RecvTimeoutError::Timeout)); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(reader.finish(), "the admitted prepared callback completes"); + assert!( + remained_pending, + "failure cleanup returned while prepared inspection remained active" + ); + let (mut command, result) = match early { + Ok(outcome) => outcome, + Err(mpsc::RecvTimeoutError::Timeout) => observe_finish + .recv_timeout(EXIT_TIMEOUT) + .expect("failure cleanup completes after reader release"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the spawn worker disconnected before reporting its result") + } + }; + spawn_worker.join().expect("the spawn worker completes"); + let error = result.expect_err("the later wrapping callback fails"); + assert_eq!(error.to_string(), "later wrapping failed"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + if provider { + assert_eq!( + shared + .events() + .iter() + .filter(|event| **event == Event::Rollback) + .count(), + 1 + ); + shared.clear_events(); + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + #[cfg(windows)] #[test] fn active_prepared_access_blocks_returned_child_destruction() { From 2c098980f2907eb3361e07267c2188da6da20367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 01:30:05 +1300 Subject: [PATCH 084/114] fix(unix): separate spawned identity from live signalling Capture historical PIDs before post-spawn hooks for supervision installation, and require mutable lower-child liveness gates that linearize status refresh with direct or group signalling. Co-Authored-By: Claude Code --- src/command.rs | 14 ++ src/generic_wrap.rs | 46 ++++- src/std/core.rs | 180 ++++++++++++++++-- src/std/process_group.rs | 82 ++++++--- src/std/process_session.rs | 22 ++- src/tokio/core.rs | 197 ++++++++++++++++---- src/tokio/process_group.rs | 96 ++++++---- src/tokio/process_session.rs | 33 ++-- src/tokio/pty.rs | 20 +- tests/process_identity.rs | 343 +++++++++++++++++++++++++++++++++++ tests/std_unix/signals.rs | 37 ++++ tests/unix_attempt_policy.rs | 110 +++++++++++ 12 files changed, 1041 insertions(+), 139 deletions(-) create mode 100644 tests/process_identity.rs diff --git a/src/command.rs b/src/command.rs index ee5fbae..7c4636b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -747,6 +747,8 @@ pub struct SpawnAttempt { platform: PlatformCommandState, #[cfg_attr(not(unix), allow(dead_code))] native_only_base: bool, + #[cfg(all(unix, feature = "process-group"))] + spawned_id: Option, kill_on_drop: Option, #[cfg(windows)] windows_policy: WindowsSpawnPolicy, @@ -1108,6 +1110,8 @@ impl Command { native, platform, native_only_base, + #[cfg(all(unix, feature = "process-group"))] + spawned_id: None, kill_on_drop: None, #[cfg(windows)] windows_policy: WindowsSpawnPolicy::default(), @@ -1372,6 +1376,16 @@ impl SpawnAttempt { self.windows_policy.starts_suspended() } + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn set_spawned_id(&mut self, spawned_id: Option) { + self.spawned_id = spawned_id; + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn spawned_id(&self) -> Option { + self.spawned_id + } + /// Return the process-group setup requested for this attempt. /// /// Alternate spawn providers use this to apply process-group intent without making the attempt diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index fb8dbb4..0b7c358 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -1690,6 +1690,7 @@ macro_rules! Wrap { &mut self, child: &mut Option>, pending: &mut Option, + #[cfg(all(unix, feature = "process-group"))] spawned_id: Option, #[cfg(windows)] prepared: &mut [Option], ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); @@ -1717,7 +1718,11 @@ macro_rules! Wrap { command, ) } - #[cfg(not(windows))] + #[cfg(all(not(windows), unix, feature = "process-group"))] + { + wrapper.wrap_child_with_spawned_id(child, spawned_id, command) + } + #[cfg(all(not(windows), not(all(unix, feature = "process-group"))))] { wrapper.wrap_child(child, command) } @@ -1802,6 +1807,16 @@ macro_rules! Wrap { }; let before_owner: ::std::result::Result<(), SpawnFailure> = (|| { + #[cfg(all(unix, feature = "process-group"))] + { + let spawned_id = Self::capture_io(|| { + Ok(child + .as_deref() + .expect("the spawn lifecycle retains child custody") + .try_spawned_id()) + })?; + attempt.set_spawned_id(spawned_id); + } #[cfg(windows)] self.run_prepare_child( attempt, @@ -1819,6 +1834,8 @@ macro_rules! Wrap { self.run_wrap_child( &mut child, &mut pending, + #[cfg(all(unix, feature = "process-group"))] + attempt.spawned_id(), #[cfg(windows)] &mut prepared, )?; @@ -1983,6 +2000,16 @@ macro_rules! Wrap { #[cfg(windows)] let mut prepared = Vec::with_capacity(self.wrapper_registry().wrappers.len()); let before_commit: ::std::result::Result<(), SpawnFailure> = (|| { + #[cfg(all(unix, feature = "process-group"))] + { + let spawned_id = Self::capture_io(|| { + Ok(child + .as_deref() + .expect("the spawn lifecycle retains child custody") + .try_spawned_id()) + })?; + attempt.set_spawned_id(spawned_id); + } #[cfg(windows)] self.run_prepare_child( attempt, @@ -2000,6 +2027,8 @@ macro_rules! Wrap { self.run_wrap_child( &mut child, &mut pending, + #[cfg(all(unix, feature = "process-group"))] + attempt.spawned_id(), #[cfg(windows)] &mut prepared, )?; @@ -2345,6 +2374,21 @@ macro_rules! Wrap { Ok(None) } + /// Describe a child layer with the historical transport identity captured before post-spawn hooks. + /// + /// The identity is for process-group/session installation only. It is not evidence of current + /// liveness and must never become a post-return signal or kill target. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn wrap_child_with_spawned_id( + &mut self, + child: &mut dyn $childer, + _spawned_id: Option, + command: &Command, + ) -> ::std::io::Result> { + self.wrap_child(child, command) + } + /// Describe a child layer for state returned by `prepare_child`. /// /// `prepared` is a non-cloneable metadata view. It can establish presence and concrete type but diff --git a/src/std/core.rs b/src/std/core.rs index 192a40c..9345394 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -9,6 +9,8 @@ use std::{ #[cfg(windows)] use std::os::windows::io::{AsHandle, BorrowedHandle}; +#[cfg(all(unix, feature = "process-group"))] +use nix::sys::signal::killpg; #[cfg(unix)] use nix::{ sys::signal::{Signal, kill}, @@ -60,8 +62,8 @@ macro_rules! prepared_owner_child_contract { } #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - self.child_ref().signal(sig) + fn signal(&mut self, sig: i32) -> Result<()> { + self.child_mut().signal(sig) } }; } @@ -218,6 +220,33 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { #[cfg(all(windows, feature = "job-object"))] fn retain_prepared_after_sidecar_removal_layer(&mut self) {} + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn spawned_id_layer(&self) -> Option { + None + } + + /// Report whether this exact layer can linearize direct-child status refresh with group signalling. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + false + } + + /// Refresh direct-child status and signal `process_group` before releasing this layer's wait custody. + /// + /// `Ok(Some(status))` means the direct child was already reaped and no signal was issued; + /// `Ok(None)` means the signal was issued while the child remained unreaped. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + _process_group: i32, + _signal: i32, + ) -> Option>> { + None + } + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -345,11 +374,25 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// was introduced by command-group to abstract over the signal behaviour between process groups /// and unwrapped processes. #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - self.inner().signal(sig) + fn signal(&mut self, sig: i32) -> Result<()> { + self.inner_mut().signal(sig) } } +#[cfg(unix)] +fn signal_child_if_running_with( + child: &mut Child, + signal: Signal, + send: impl FnOnce(Pid, Signal) -> std::result::Result<(), nix::errno::Errno>, +) -> Result> { + if let Some(status) = Child::try_wait(child)? { + return Ok(Some(status)); + } + let pid = Pid::from_raw(i32::try_from(Child::id(child)).map_err(std::io::Error::other)?); + send(pid, signal).map_err(std::io::Error::from)?; + Ok(None) +} + impl ChildWrapper for Child { fn inner(&self) -> &dyn ChildWrapper { self @@ -376,10 +419,32 @@ impl ChildWrapper for Child { fn id(&self) -> u32 { Child::id(self) } + #[cfg(all(unix, feature = "process-group"))] + fn spawned_id_layer(&self) -> Option { + Some(Child::id(self)) + } + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + true + } + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let signal = match Signal::try_from(signal) { + Ok(signal) => signal, + Err(error) => return Some(Err(error.into())), + }; + Some(signal_child_if_running_with(self, signal, |_, signal| { + killpg(Pid::from_raw(process_group), signal) + })) + } fn start_kill(&mut self) -> Result<()> { #[cfg(unix)] { - self.signal(Signal::SIGKILL as _) + signal_child_if_running_with(self, Signal::SIGKILL, kill).map(drop) } #[cfg(not(unix))] @@ -394,12 +459,8 @@ impl ChildWrapper for Child { Child::wait(self) } #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - kill( - Pid::from_raw(i32::try_from(self.id()).map_err(std::io::Error::other)?), - Signal::try_from(sig)?, - ) - .map_err(std::io::Error::from) + fn signal(&mut self, sig: i32) -> Result<()> { + signal_child_if_running_with(self, Signal::try_from(sig)?, kill).map(drop) } } @@ -430,6 +491,59 @@ impl dyn ChildWrapper + '_ { self.downcast_ref::().is_some() } + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn try_spawned_id(&self) -> Option { + let mut inner = self; + loop { + if let Some(pid) = inner.spawned_id_layer() { + return Some(pid); + } + let next = inner.inner(); + if same_child(inner, next) { + return None; + } + inner = next; + } + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn has_process_group_signal_capability(&self) -> bool { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return true; + } + let next = inner.inner(); + if same_child(inner, next) { + return false; + } + inner = next; + } + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn try_signal_process_group( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return inner.signal_process_group_layer(process_group, signal); + } + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return None; + } + inner = next; + } + } + /// Find the first Windows process-handle capability in this wrapper chain. /// /// Unlike [`ChildWrapper::process_handle`], this traverses legacy transparent layers which do not @@ -736,6 +850,50 @@ fn read2( Ok(()) } +#[cfg(all(test, unix))] +mod signal_tests { + use std::{ + process::Command, + sync::atomic::{AtomicUsize, Ordering}, + }; + + use super::*; + + #[test] + fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { + let mut child = Command::new("true").spawn()?; + let status = child.wait()?; + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, Some(status)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(child.wait()?, status); + Ok(()) + } + + #[test] + fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { + let mut child = Command::new("sh").args(["-c", "sleep 30"]).spawn()?; + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + child.kill()?; + let _ = child.wait()?; + Ok(()) + } +} + const _: () = { const fn assert_sync() {} assert_sync::(); diff --git a/src/std/process_group.rs b/src/std/process_group.rs index 48579e9..743a483 100644 --- a/src/std/process_group.rs +++ b/src/std/process_group.rs @@ -3,10 +3,7 @@ use std::{ process::ExitStatus, }; -use nix::{ - sys::signal::{Signal, killpg}, - unistd::Pid, -}; +use nix::{sys::signal::Signal, unistd::Pid}; #[cfg(feature = "tracing")] use tracing::instrument; @@ -47,6 +44,37 @@ impl ProcessGroup { target: ProcessGroupTarget::AttachTo(leader), } } + + pub(super) fn detached_child( + self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + ) -> Result { + if !inner.has_process_group_signal_capability() { + return Err(Error::new( + std::io::ErrorKind::Unsupported, + "the child does not expose liveness-gated process-group signalling", + )); + } + let direct_id = spawned_id.ok_or_else(|| { + Error::new( + std::io::ErrorKind::InvalidInput, + "the spawn transport did not retain its historical process ID", + ) + })?; + let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); + let pgid = match self.target { + ProcessGroupTarget::Leader => direct_pid, + ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( + i32::try_from(pgid).expect("process group IDs are validated before spawning"), + ), + }; + let exit_status = inner.try_wait()?; + Ok(PendingChildWrapper::new(ProcessGroupChild::detached( + pgid, + exit_status, + ))) + } } /// Wrapper for `Child` which signals the process group while its direct child is live. @@ -102,29 +130,40 @@ impl CommandWrapper for ProcessGroup { inner: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> Result> { - let direct_pid = Pid::from_raw(i32::try_from(inner.id()).map_err(Error::other)?); - let pgid = match self.target { - ProcessGroupTarget::Leader => direct_pid, - ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( - i32::try_from(pgid).expect("process group IDs are validated before spawning"), - ), - }; - let exit_status = inner.try_wait()?; + let spawned_id = inner.try_spawned_id(); + self.detached_child(inner, spawned_id).map(Some) + } - Ok(Some(PendingChildWrapper::new(ProcessGroupChild::detached( - pgid, - exit_status, - )))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + self.detached_child(inner, spawned_id).map(Some) } } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn signal_imp(&self, sig: Signal) -> Result<()> { + fn signal_imp(&mut self, sig: Signal) -> Result<()> { if matches!(self.exit_status, ChildExitStatus::Exited(_)) { return Ok(()); } - killpg(self.pgid, sig).map_err(Error::from) + let pgid = self.pgid.as_raw(); + let outcome = self + .inner_mut_ref() + .try_signal_process_group(pgid, sig as i32) + .ok_or_else(|| { + Error::new( + std::io::ErrorKind::Unsupported, + "the child lost its liveness-gated process-group signalling capability", + ) + })??; + if let Some(status) = outcome { + self.exit_status = ChildExitStatus::Exited(status); + } + Ok(()) } } @@ -149,11 +188,6 @@ impl ChildWrapper for ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Running) - && let Some(status) = self.inner_mut_ref().try_wait()? - { - self.exit_status = ChildExitStatus::Exited(status); - } self.signal_imp(Signal::SIGKILL) } @@ -183,7 +217,7 @@ impl ChildWrapper for ProcessGroupChild { } } - fn signal(&self, sig: i32) -> Result<()> { + fn signal(&mut self, sig: i32) -> Result<()> { self.signal_imp(Signal::try_from(sig)?) } } diff --git a/src/std/process_session.rs b/src/std/process_session.rs index dc3e884..e11db65 100644 --- a/src/std/process_session.rs +++ b/src/std/process_session.rs @@ -1,6 +1,5 @@ -use std::io::{Error, Result}; +use std::io::Result; -use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; @@ -33,11 +32,20 @@ impl CommandWrapper for ProcessSession { inner: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> Result> { - let direct_pid = Pid::from_raw(i32::try_from(inner.id()).map_err(Error::other)?); - let exit_status = inner.try_wait()?; + let spawned_id = inner.try_spawned_id(); + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) + } - Ok(Some(PendingChildWrapper::new( - super::ProcessGroupChild::detached(direct_pid, exit_status), - ))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) } } diff --git a/src/tokio/core.rs b/src/tokio/core.rs index c2002b3..2ebd066 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -10,6 +10,8 @@ use std::{ use std::os::windows::io::BorrowedHandle; use futures::future::try_join3; +#[cfg(all(unix, feature = "process-group"))] +use nix::sys::signal::killpg; #[cfg(unix)] use nix::{ sys::signal::{Signal, kill}, @@ -23,15 +25,6 @@ use tokio::{ #[cfg(windows)] macro_rules! prepared_owner_child_contract { () => { - #[cfg(all( - unix, - feature = "pty", - any(feature = "process-group", feature = "process-session") - ))] - fn spawned_id_layer(&self) -> Option { - self.child_ref().spawned_id_layer() - } - #[cfg(feature = "pty")] fn take_pty_controller_layer(&mut self) -> Option { self.child_mut().take_pty_controller_layer() @@ -79,8 +72,8 @@ macro_rules! prepared_owner_child_contract { } #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - self.child_ref().signal(sig) + fn signal(&mut self, sig: i32) -> Result<()> { + self.child_mut().signal(sig) } }; } @@ -229,21 +222,37 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { None } - /// Return the original PID retained by this exact provider-child layer. + /// Return the original PID retained by this exact child layer. /// - /// This internal capability lets a wrapper finish installation after an earlier post-spawn hook - /// observed and reaped a fast provider child. Ordinary child operations must continue to use - /// [`ChildWrapper::id`] so they never act on a recycled PID. + /// This historical identity is consumed only while installing process-group or session supervision. + /// Ordinary child operations and signalling must continue to use live child state. #[doc(hidden)] - #[cfg(all( - unix, - feature = "pty", - any(feature = "process-group", feature = "process-session") - ))] + #[cfg(all(unix, feature = "process-group"))] fn spawned_id_layer(&self) -> Option { None } + /// Report whether this exact layer can linearize direct-child status refresh with group signalling. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + false + } + + /// Refresh direct-child status and signal `process_group` before releasing this layer's wait custody. + /// + /// `Ok(Some(status))` means the direct child was already reaped and no signal was issued; + /// `Ok(None)` means the signal was issued while the child remained unreaped. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + _process_group: i32, + _signal: i32, + ) -> Option>> { + None + } + /// Take the PTY controller owned by this exact child layer. #[doc(hidden)] #[cfg(feature = "pty")] @@ -428,11 +437,28 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// was introduced by command-group to abstract over the signal behaviour between process groups /// and unwrapped processes. #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - self.inner().signal(sig) + fn signal(&mut self, sig: i32) -> Result<()> { + self.inner_mut().signal(sig) } } +#[cfg(unix)] +fn signal_child_if_running_with( + child: &mut Child, + signal: Signal, + send: impl FnOnce(Pid, Signal) -> std::result::Result<(), nix::errno::Errno>, +) -> Result> { + if let Some(status) = Child::try_wait(child)? { + return Ok(Some(status)); + } + let id = Child::id(child).ok_or_else(|| { + std::io::Error::other("a running Tokio child did not retain its process ID") + })?; + let pid = Pid::from_raw(i32::try_from(id).map_err(std::io::Error::other)?); + send(pid, signal).map_err(std::io::Error::from)?; + Ok(None) +} + impl ChildWrapper for Child { fn inner(&self) -> &dyn ChildWrapper { self @@ -462,8 +488,37 @@ impl ChildWrapper for Child { fn id(&self) -> Option { Child::id(self) } + #[cfg(all(unix, feature = "process-group"))] + fn spawned_id_layer(&self) -> Option { + Child::id(self) + } + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + true + } + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let signal = match Signal::try_from(signal) { + Ok(signal) => signal, + Err(error) => return Some(Err(error.into())), + }; + Some(signal_child_if_running_with(self, signal, |_, signal| { + killpg(Pid::from_raw(process_group), signal) + })) + } fn start_kill(&mut self) -> Result<()> { - Child::start_kill(self) + #[cfg(unix)] + { + signal_child_if_running_with(self, Signal::SIGKILL, kill).map(drop) + } + #[cfg(not(unix))] + { + Child::start_kill(self) + } } fn try_wait(&mut self) -> Result> { Child::try_wait(self) @@ -472,16 +527,8 @@ impl ChildWrapper for Child { Box::pin(Child::wait(self)) } #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - if let Some(id) = self.id() { - kill( - Pid::from_raw(i32::try_from(id).map_err(std::io::Error::other)?), - Signal::try_from(sig)?, - ) - .map_err(std::io::Error::from) - } else { - Ok(()) - } + fn signal(&mut self, sig: i32) -> Result<()> { + signal_child_if_running_with(self, Signal::try_from(sig)?, kill).map(drop) } } @@ -512,11 +559,7 @@ impl dyn ChildWrapper + '_ { self.downcast_ref::().is_some() } - #[cfg(all( - unix, - feature = "pty", - any(feature = "process-group", feature = "process-session") - ))] + #[cfg(all(unix, feature = "process-group"))] pub(crate) fn try_spawned_id(&self) -> Option { let mut inner = self; loop { @@ -532,6 +575,44 @@ impl dyn ChildWrapper + '_ { } } + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn has_process_group_signal_capability(&self) -> bool { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return true; + } + let next = inner.inner(); + if same_child(inner, next) { + return false; + } + inner = next; + } + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn try_signal_process_group( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return inner.signal_process_group_layer(process_group, signal); + } + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return None; + } + inner = next; + } + } + /// Take the controller installed by a PTY spawn. /// /// This traverses arbitrary child-wrapper layers without removing them. The controller can be taken @@ -766,6 +847,46 @@ impl dyn ChildWrapper + '_ { } } +#[cfg(all(test, unix))] +mod signal_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[tokio::test] + async fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { + let mut child = NativeCommand::new("true").spawn()?; + let status = child.wait().await?; + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, Some(status)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(child.wait().await?, status); + Ok(()) + } + + #[tokio::test] + async fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { + let mut child = NativeCommand::new("sh").args(["-c", "sleep 30"]).spawn()?; + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + child.kill().await?; + Ok(()) + } +} + const _: () = { const fn assert_sync() {} assert_sync::(); diff --git a/src/tokio/process_group.rs b/src/tokio/process_group.rs index e21273c..131c613 100644 --- a/src/tokio/process_group.rs +++ b/src/tokio/process_group.rs @@ -5,10 +5,7 @@ use std::{ process::ExitStatus, }; -use nix::{ - sys::signal::{Signal, killpg}, - unistd::Pid, -}; +use nix::{sys::signal::Signal, unistd::Pid}; #[cfg(feature = "tracing")] use tracing::instrument; @@ -54,6 +51,37 @@ impl ProcessGroup { target: ProcessGroupTarget::AttachTo(leader), } } + + pub(super) fn detached_child( + self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + ) -> Result { + if !inner.has_process_group_signal_capability() { + return Err(Error::new( + std::io::ErrorKind::Unsupported, + "the child does not expose liveness-gated process-group signalling", + )); + } + let direct_id = spawned_id.ok_or_else(|| { + Error::new( + std::io::ErrorKind::InvalidInput, + "the spawn transport did not retain its historical process ID", + ) + })?; + let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); + let pgid = match self.target { + ProcessGroupTarget::Leader => direct_pid, + ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( + i32::try_from(pgid).expect("process group IDs are validated before spawning"), + ), + }; + let exit_status = inner.try_wait()?; + Ok(PendingChildWrapper::new(ProcessGroupChild::detached( + pgid, + exit_status, + ))) + } } /// Wrapper for `Child` which signals the process group while its direct child is live. @@ -109,41 +137,40 @@ impl CommandWrapper for ProcessGroup { inner: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> Result> { - let mut direct_id = inner.id(); - #[cfg(feature = "pty")] - if direct_id.is_none() { - direct_id = inner.try_spawned_id(); - } - let direct_id = direct_id.ok_or_else(|| { - Error::new( - std::io::ErrorKind::InvalidInput, - "the child exited before process-group supervision could retain its PID", - ) - })?; - let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); - let pgid = match self.target { - ProcessGroupTarget::Leader => direct_pid, - ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( - i32::try_from(pgid).expect("process group IDs are validated before spawning"), - ), - }; - let exit_status = inner.try_wait()?; + let spawned_id = inner.try_spawned_id(); + self.detached_child(inner, spawned_id).map(Some) + } - Ok(Some(PendingChildWrapper::new(ProcessGroupChild::detached( - pgid, - exit_status, - )))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + self.detached_child(inner, spawned_id).map(Some) } } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn signal_imp(&self, sig: Signal) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Exited(_)) || self.inner_ref().id().is_none() - { + fn signal_imp(&mut self, sig: Signal) -> Result<()> { + if matches!(self.exit_status, ChildExitStatus::Exited(_)) { return Ok(()); } - killpg(self.pgid, sig).map_err(Error::from) + let pgid = self.pgid.as_raw(); + let outcome = self + .inner_mut_ref() + .try_signal_process_group(pgid, sig as i32) + .ok_or_else(|| { + Error::new( + std::io::ErrorKind::Unsupported, + "the child lost its liveness-gated process-group signalling capability", + ) + })??; + if let Some(status) = outcome { + self.exit_status = ChildExitStatus::Exited(status); + } + Ok(()) } } @@ -168,11 +195,6 @@ impl ChildWrapper for ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Running) - && let Some(status) = self.inner_mut_ref().try_wait()? - { - self.exit_status = ChildExitStatus::Exited(status); - } self.signal_imp(Signal::SIGKILL) } @@ -204,7 +226,7 @@ impl ChildWrapper for ProcessGroupChild { } } - fn signal(&self, sig: i32) -> Result<()> { + fn signal(&mut self, sig: i32) -> Result<()> { self.signal_imp(Signal::try_from(sig)?) } } diff --git a/src/tokio/process_session.rs b/src/tokio/process_session.rs index ac19caf..f27edca 100644 --- a/src/tokio/process_session.rs +++ b/src/tokio/process_session.rs @@ -1,6 +1,5 @@ -use std::io::{Error, ErrorKind, Result}; +use std::io::Result; -use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; @@ -36,22 +35,20 @@ impl CommandWrapper for ProcessSession { inner: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> Result> { - let mut direct_id = inner.id(); - #[cfg(feature = "pty")] - if direct_id.is_none() { - direct_id = inner.try_spawned_id(); - } - let direct_id = direct_id.ok_or_else(|| { - Error::new( - ErrorKind::InvalidInput, - "the child exited before session supervision could retain its PID", - ) - })?; - let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); - let exit_status = inner.try_wait()?; + let spawned_id = inner.try_spawned_id(); + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) + } - Ok(Some(PendingChildWrapper::new( - super::ProcessGroupChild::detached(direct_pid, exit_status), - ))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) } } diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index 7dc41d2..bbcf164 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -588,15 +588,29 @@ impl ChildWrapper for PtyChild { } #[cfg(unix)] - fn signal(&self, sig: i32) -> io::Result<()> { - ChildWrapper::signal(&*self.lock_child(), sig) + fn signal(&mut self, sig: i32) -> io::Result<()> { + ChildWrapper::signal(&mut *self.lock_child(), sig) } - #[cfg(any(feature = "process-group", feature = "process-session"))] + #[cfg(feature = "process-group")] fn spawned_id_layer(&self) -> Option { Some(self.pid) } + #[cfg(feature = "process-group")] + fn has_process_group_signal_layer(&self) -> bool { + true + } + + #[cfg(feature = "process-group")] + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + ChildWrapper::signal_process_group_layer(&mut *self.lock_child(), process_group, signal) + } + fn take_pty_controller_layer(&mut self) -> Option { self.controller.take() } diff --git a/tests/process_identity.rs b/tests/process_identity.rs new file mode 100644 index 0000000..bc3cf25 --- /dev/null +++ b/tests/process_identity.rs @@ -0,0 +1,343 @@ +#![cfg(all(unix, feature = "tokio1", feature = "process-group"))] + +use std::{ + future::Future, + io, + pin::Pin, + process::ExitStatus, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::{Duration, Instant}, +}; + +use process_wrap::{ + ProcessGroupTarget, SpawnTransaction, + tokio::{ + ChildWrapper, Command, CommandWrapper, ProcessGroup, ProviderProduct, SpawnAttempt, + SpawnProvider, + }, +}; + +const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug)] +struct CompletedTransaction; + +impl SpawnTransaction for CompletedTransaction { + fn commit(&mut self) -> io::Result<()> { + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct HistoricalChild { + child: Arc>, + spawned_id: u32, +} + +impl HistoricalChild { + fn lock(&self) -> std::sync::MutexGuard<'_, tokio::process::Child> { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ChildWrapper for HistoricalChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn id(&self) -> Option { + self.lock().id() + } + + fn spawned_id_layer(&self) -> Option { + Some(self.spawned_id) + } + + fn has_process_group_signal_layer(&self) -> bool { + true + } + + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + ChildWrapper::signal_process_group_layer(&mut *self.lock(), process_group, signal) + } + + fn start_kill(&mut self) -> io::Result<()> { + ChildWrapper::start_kill(&mut *self.lock()) + } + + fn try_wait(&mut self) -> io::Result> { + self.lock().try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + let child = Arc::clone(&self.child); + Box::pin(std::future::poll_fn(move |context| { + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wait = Box::pin(child.wait()); + wait.as_mut().poll(context) + })) + } +} + +#[derive(Debug)] +struct HistoricalProvider; + +impl SpawnProvider for HistoricalProvider { + fn spawn(&self, attempt: &mut SpawnAttempt, _command: &Command) -> io::Result { + assert_eq!( + attempt.process_group_target(), + Some(ProcessGroupTarget::Leader) + ); + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "exit 29"]).process_group(0); + let mut child = command.spawn()?; + let spawned_id = child + .id() + .ok_or_else(|| io::Error::other("the provider child has no spawned PID"))?; + let deadline = Instant::now() + EXIT_TIMEOUT; + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the provider child did not exit", + )); + } + std::thread::yield_now(); + } + assert_eq!(child.id(), None); + Ok(ProviderProduct::new( + Box::new(HistoricalChild { + child: Arc::new(Mutex::new(child)), + spawned_id, + }), + Box::new(CompletedTransaction), + )) + } +} + +#[derive(Debug)] +struct ProviderWrapper; + +impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&HistoricalProvider) + } +} + +#[tokio::test] +async fn provider_historical_identity_works_without_pty_in_both_registration_orders() { + for provider_first in [false, true] { + let mut command = Command::new("provider-owned-program"); + if provider_first { + command.wrap(ProviderWrapper).wrap(ProcessGroup::leader()); + } else { + command.wrap(ProcessGroup::leader()).wrap(ProviderWrapper); + } + + let mut child = command.spawn().expect("install process-group supervision"); + assert_eq!(child.id(), None); + let first = child.wait().await.expect("read the cached provider status"); + assert_eq!(first.code(), Some(29)); + assert_eq!(child.wait().await.unwrap(), first); + assert_eq!(child.try_wait().unwrap(), Some(first)); + child.signal(nix::libc::SIGCONT).unwrap(); + } +} + +#[derive(Debug)] +struct StickyState { + child: Mutex, + group_signals: AtomicUsize, +} + +#[derive(Debug)] +struct StickyIdChild { + state: Arc, + spawned_id: u32, +} + +impl ChildWrapper for StickyIdChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn try_clone(&self) -> Option> { + Some(Box::new(Self { + state: Arc::clone(&self.state), + spawned_id: self.spawned_id, + })) + } + + fn id(&self) -> Option { + Some(self.spawned_id) + } + + fn spawned_id_layer(&self) -> Option { + Some(self.spawned_id) + } + + fn has_process_group_signal_layer(&self) -> bool { + true + } + + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let mut child = self + .state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = ChildWrapper::signal_process_group_layer(&mut *child, process_group, signal)?; + if matches!(result, Ok(None)) { + self.state.group_signals.fetch_add(1, Ordering::SeqCst); + } + Some(result) + } + + fn start_kill(&mut self) -> io::Result<()> { + ChildWrapper::start_kill( + &mut *self + .state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } + + fn try_wait(&mut self) -> io::Result> { + self.state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + let state = Arc::clone(&self.state); + Box::pin(std::future::poll_fn(move |context| { + let mut child = state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wait = Box::pin(child.wait()); + wait.as_mut().poll(context) + })) + } +} + +fn sticky_child( + child: tokio::process::Child, + publish: &Arc>>>, +) -> Box { + let spawned_id = child.id().expect("the fresh child has a PID"); + let state = Arc::new(StickyState { + child: Mutex::new(child), + group_signals: AtomicUsize::new(0), + }); + *publish + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&state)); + Box::new(StickyIdChild { state, spawned_id }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sticky_custom_id_does_not_authorize_a_signal_after_wait() { + let published = Arc::new(Mutex::new(None)); + let mut command = Command::with_new("sh", |command| { + command.args(["-c", "exit 31"]); + }); + command.wrap(ProcessGroup::leader()); + let mut child = command + .spawn_with_child({ + let published = Arc::clone(&published); + move |native| Ok(sticky_child(native.spawn()?, &published)) + }) + .unwrap(); + let state = published + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap(); + let status = child.inner_mut().wait().await.unwrap(); + assert!(child.inner().id().is_some()); + + child.signal(nix::libc::SIGCONT).unwrap(); + child.start_kill().unwrap(); + assert_eq!(state.group_signals.load(Ordering::SeqCst), 0); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shared_wait_and_group_signal_linearize_under_child_custody() { + let published = Arc::new(Mutex::new(None)); + let mut command = Command::with_new("sh", |command| { + command.args(["-c", "sleep 0.2; exit 37"]); + }); + command.wrap(ProcessGroup::leader()); + let mut child = command + .spawn_with_child({ + let published = Arc::clone(&published); + move |native| Ok(sticky_child(native.spawn()?, &published)) + }) + .unwrap(); + let state = published + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap(); + let waiter_state = Arc::clone(&state); + let (locked, observe_locked) = mpsc::channel(); + let runtime = tokio::runtime::Handle::current(); + let waiter = tokio::task::spawn_blocking(move || { + let mut child = waiter_state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + locked.send(()).unwrap(); + runtime.block_on(child.wait()) + }); + observe_locked.recv_timeout(EXIT_TIMEOUT).unwrap(); + + child.signal(nix::libc::SIGCONT).unwrap(); + let status = waiter.await.unwrap().unwrap(); + assert_eq!(status.code(), Some(37)); + assert_eq!(state.group_signals.load(Ordering::SeqCst), 0); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); +} diff --git a/tests/std_unix/signals.rs b/tests/std_unix/signals.rs index a6a69e7..a998e43 100644 --- a/tests/std_unix/signals.rs +++ b/tests/std_unix/signals.rs @@ -57,3 +57,40 @@ fn process_session() -> Result<()> { Ok(()) } + +#[test] +fn direct_wait_then_signal_and_start_kill_are_noops() -> Result<()> { + let mut child = CommandWrap::new("true").spawn()?; + let status = child.inner_mut().wait()?; + child.signal(Signal::SIGCONT as _)?; + child.start_kill()?; + assert_eq!(child.wait()?, status); + assert_eq!(child.try_wait()?, Some(status)); + Ok(()) +} + +#[cfg(feature = "process-group")] +#[test] +fn process_group_inner_wait_then_signals_are_noops() -> Result<()> { + let mut child = CommandWrap::new("true") + .wrap(ProcessGroup::leader()) + .spawn()?; + let status = child.inner_mut().wait()?; + child.signal(Signal::SIGCONT as _)?; + child.start_kill()?; + assert_eq!(child.wait()?, status); + assert_eq!(child.try_wait()?, Some(status)); + Ok(()) +} + +#[cfg(feature = "process-session")] +#[test] +fn process_session_inner_wait_then_signals_are_noops() -> Result<()> { + let mut child = CommandWrap::new("true").wrap(ProcessSession).spawn()?; + let status = child.inner_mut().wait()?; + child.signal(Signal::SIGCONT as _)?; + child.start_kill()?; + assert_eq!(child.wait()?, status); + assert_eq!(child.try_wait()?, Some(status)); + Ok(()) +} diff --git a/tests/unix_attempt_policy.rs b/tests/unix_attempt_policy.rs index b226d61..cad3302 100644 --- a/tests/unix_attempt_policy.rs +++ b/tests/unix_attempt_policy.rs @@ -127,6 +127,30 @@ macro_rules! unix_attempt_policy_tests { } } + #[derive(Debug)] + struct ReapBeforeWrapping; + + impl CommandWrapper for ReapBeforeWrapping { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the post-spawn hook could not reap the direct child", + )); + } + sleep(Duration::from_millis(5)); + } + Ok(()) + } + } + #[derive(Debug)] struct ReplaceNative; @@ -208,6 +232,92 @@ macro_rules! unix_attempt_policy_tests { } } + fn spawn_transport( + command: &mut CommandWrap, + transport: u8, + ) -> io::Result> { + match transport { + 0 => command.spawn(), + 1 => command.spawn_with(|native| native.spawn()), + 2 => command.spawn_with_child(|native| { + Ok(Box::new(native.spawn()?) as Box) + }), + _ => unreachable!("the test enumerates every transport"), + } + } + + #[test] + fn post_hook_reap_preserves_supervision_installation_identity() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for session in [false, true] { + for reaper_first in [false, true] { + for transport in 0..3 { + let mut command = command_with_exit(23); + if reaper_first { + command.wrap(ReapBeforeWrapping); + if session { + command.wrap(ProcessSession); + } else { + command.wrap(ProcessGroup::leader()); + } + } else { + if session { + command.wrap(ProcessSession); + } else { + command.wrap(ProcessGroup::leader()); + } + command.wrap(ReapBeforeWrapping); + } + + let mut child = spawn_transport(&mut command, transport).unwrap(); + let first = wait_for_exit(child.as_mut()); + assert_eq!(first.code(), Some(23)); + assert_eq!(wait_for_exit(child.as_mut()), first); + assert_eq!(child.try_wait().unwrap(), Some(first)); + } + } + } + } + + #[test] + fn direct_signal_and_start_kill_are_noops_after_reap() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + let mut child = command.spawn().unwrap(); + let status = wait_for_exit(child.as_mut()); + + child.signal(Signal::SIGCONT as i32).unwrap(); + child.start_kill().unwrap(); + assert_eq!(wait_for_exit(child.as_mut()), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); + } + + #[test] + fn lower_layer_reap_makes_group_signals_noops() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for session in [false, true] { + let mut command = command_with_exit(0); + if session { + command.wrap(ProcessSession); + } else { + command.wrap(ProcessGroup::leader()); + } + let mut child = command.spawn().unwrap(); + let status = { + let inner = child.inner_mut(); + wait_for_exit(inner) + }; + + child.signal(Signal::SIGCONT as i32).unwrap(); + child.start_kill().unwrap(); + assert_eq!(wait_for_exit(child.as_mut()), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); + } + } + #[test] fn native_only_session_command_is_reusable() { let runtime = runtime(); From bbb54dad4183486976cea637f071174c242930e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 01:40:55 +1300 Subject: [PATCH 085/114] fix(tokio): close extracted JobObject resources Close drained or successfully disarmed JobPorts during consuming extraction, and transfer unresolved live ports to one shared custodian with exact last-resort retention if worker startup fails. Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 181 ++++++++++-- src/windows.rs | 264 +++++++++++++++++- .../tokio_windows/job_object_kill_on_drop.rs | 49 ++++ 3 files changed, 475 insertions(+), 19 deletions(-) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index ec2c5d4..eabede3 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -10,16 +10,13 @@ use std::{ #[cfg(feature = "tracing")] use tracing::{debug, instrument}; -use windows::Win32::{ - Foundation::{CloseHandle, HANDLE}, - System::Threading::PROCESS_CREATION_FLAGS, -}; +use windows::Win32::{Foundation::HANDLE, System::Threading::PROCESS_CREATION_FLAGS}; use crate::{ ChildExitStatus, windows::{ JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, - resume_threads, terminate_job, + release_extracted_job_port, resume_threads, terminate_job, }, }; @@ -289,16 +286,13 @@ impl ChildWrapper for JobObjectChild { .take() .expect("an installed JobObject layer owns its child"); if self.spawn_finalized && self.final_kill_on_drop { + let job_drained = self.job_drained; let mut prepared = self.take_prepared(); let job_port = prepared .job_port .take() .expect("the installed JobObject layer retains its job handles"); - // Manually close the completion port while retaining the job handle. Closing a - // kill-on-close job here would make the extracted child unusable. - let job_port = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); + release_extracted_job_port(job_port, job_drained); } // Before spawn finalization, dropping the still-armed prepared job instead guarantees that // removing this layer cannot let descendants escape a later lifecycle failure. @@ -428,13 +422,21 @@ mod tests { use windows::Win32::System::Threading::CREATE_SUSPENDED; use crate::tokio::{ProviderProduct, SpawnProvider}; - use crate::windows::test_support::{ - LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, - ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, - arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, assert_tree_terminated, - clear_extra_prepared_owners, clear_owner_events, clear_owner_failure, - finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, observe_descendant, - publish_process_guards, record_owner_event, spawn_cleanup_handle_close_count, + use crate::windows::{ + reset_job_port_custodian_for_test, + test_support::{ + LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_job_port_close_probe, + arm_owner_events, arm_owner_failure, arm_owner_transition_probe, + arm_spawn_cleanup_handle_probe, assert_tree_terminated, clear_extra_prepared_owners, + clear_job_port_close_probe, clear_owner_events, clear_owner_failure, + finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, + job_custodian_worker_starts, job_port_last_resort_retentions, observe_descendant, + publish_process_guards, record_owner_event, reset_job_extraction_faults, + serial_job_extraction, set_job_custodian_start_failures, + set_job_extraction_disarm_failures, set_job_extraction_query_failures, + spawn_cleanup_handle_close_count, + }, }; use super::*; @@ -660,6 +662,151 @@ mod tests { Ok(command) } + struct ExtractionFaultCleanup; + + impl Drop for ExtractionFaultCleanup { + fn drop(&mut self) { + reset_job_extraction_faults(); + } + } + + fn extraction_command(exit_immediately: bool, job_first: bool) -> CommandWrap { + let mut command = if exit_immediately { + CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }) + } else { + CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "ping -n 30 127.0.0.1 >NUL"]); + }) + }; + if job_first { + command.wrap(JobObject).wrap(KillOnDrop); + } else { + command.wrap(KillOnDrop).wrap(JobObject); + } + command + } + + fn wait_for_job_port_closes( + probe: &crate::windows::test_support::JobPortCloseProbe, + expected: (usize, usize), + ) { + let deadline = Instant::now() + Duration::from_secs(5); + while probe.counts() != expected { + assert!( + Instant::now() < deadline, + "JobObject custodian did not close both handles: {:?}", + probe.counts() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly() -> Result<()> { + let _serial = serial_job_extraction(); + for job_first in [false, true] { + for _ in 0..8 { + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(true, job_first); + let mut child = command.spawn()?; + clear_job_port_close_probe(); + let status = child.wait().await?; + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (1, 1)); + assert_eq!(lower.wait().await?, status); + assert_eq!(lower.try_wait()?, Some(status)); + } + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn live_kill_on_drop_extraction_disarms_and_closes_before_returning() -> Result<()> { + let _serial = serial_job_extraction(); + for job_first in [false, true] { + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, job_first); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (1, 1)); + assert_eq!(lower.try_wait()?, None); + lower.start_kill()?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn extraction_failures_use_one_shared_custodian_then_close_every_port() -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + let worker_starts = job_custodian_worker_starts(); + set_job_extraction_query_failures(usize::MAX); + set_job_extraction_disarm_failures(usize::MAX); + let mut lowers = Vec::new(); + let mut probes = Vec::new(); + for _ in 0..2 { + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, true); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (0, 0)); + assert_eq!(lower.try_wait()?, None); + lowers.push(lower); + probes.push(probe); + } + let deadline = Instant::now() + Duration::from_secs(5); + while job_custodian_worker_starts() == worker_starts { + assert!( + Instant::now() < deadline, + "the shared custodian did not start" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job_custodian_worker_starts(), worker_starts + 1); + + reset_job_extraction_faults(); + for lower in &mut lowers { + lower.start_kill()?; + let _ = lower.wait().await?; + } + for probe in &probes { + wait_for_job_port_closes(probe, (1, 1)); + } + assert_eq!(job_custodian_worker_starts(), worker_starts + 1); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn custodian_start_failure_uses_only_exact_last_resort_retention() -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); + let retained = job_port_last_resort_retentions(); + set_job_extraction_query_failures(1); + set_job_extraction_disarm_failures(1); + set_job_custodian_start_failures(1); + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, true); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + + assert_eq!(probe.counts(), (0, 0)); + assert_eq!(job_port_last_resort_retentions(), retained + 1); + assert_eq!(lower.try_wait()?, None); + lower.start_kill()?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + assert_eq!(probe.counts(), (0, 0)); + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn native_spawn_cleanup_handle_closes_with_returned_child() -> Result<()> { let directory = tempfile::tempdir()?; diff --git a/src/windows.rs b/src/windows.rs index 9d92d50..1aa88fe 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -4,6 +4,7 @@ use std::{ io::{Error, Result}, ops::ControlFlow, os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle as StdOwnedHandle}, + sync::{Mutex, OnceLock, mpsc}, time::Duration, }; @@ -194,10 +195,32 @@ pub(crate) mod test_support { Panic(Arc<()>), } + #[derive(Debug, Default)] + pub struct JobPortCloseProbe { + pub job_closes: AtomicUsize, + pub completion_port_closes: AtomicUsize, + } + + impl JobPortCloseProbe { + pub fn counts(&self) -> (usize, usize) { + ( + self.job_closes.load(Ordering::SeqCst), + self.completion_port_closes.load(Ordering::SeqCst), + ) + } + } + + static JOB_EXTRACTION_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DISARM_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_CUSTODIAN_START_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_CUSTODIAN_WORKER_STARTS: AtomicUsize = AtomicUsize::new(0); + static JOB_PORT_LAST_RESORT_RETENTIONS: AtomicUsize = AtomicUsize::new(0); + thread_local! { static OWNER_FAILURE: RefCell> = const { RefCell::new(None) }; static OWNER_EVENTS: RefCell>>>> = const { RefCell::new(None) }; static EXTRA_PREPARED_OWNER: RefCell = RefCell::new(ExtraPreparedOwner::default()); + static JOB_PORT_CLOSE_PROBE: RefCell>> = const { RefCell::new(None) }; } #[derive(Default)] @@ -233,6 +256,85 @@ pub(crate) mod test_support { }) } + fn take_failure(counter: &AtomicUsize) -> bool { + counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + } + + pub fn serial_job_extraction() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn arm_job_port_close_probe() -> Arc { + let probe = Arc::new(JobPortCloseProbe::default()); + JOB_PORT_CLOSE_PROBE.with(|slot| { + assert!(slot.borrow_mut().replace(Arc::clone(&probe)).is_none()); + }); + probe + } + + pub fn current_job_port_close_probe() -> Option> { + JOB_PORT_CLOSE_PROBE.with(|slot| slot.borrow().clone()) + } + + pub fn clear_job_port_close_probe() { + JOB_PORT_CLOSE_PROBE.with(|slot| { + assert!(slot.borrow_mut().take().is_some()); + }); + } + + pub fn set_job_extraction_query_failures(failures: usize) { + JOB_EXTRACTION_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_QUERY_FAILURES) + } + + pub fn set_job_extraction_disarm_failures(failures: usize) { + JOB_EXTRACTION_DISARM_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_disarm_failure() -> bool { + take_failure(&JOB_EXTRACTION_DISARM_FAILURES) + } + + pub fn set_job_custodian_start_failures(failures: usize) { + JOB_CUSTODIAN_START_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_custodian_start_failure() -> bool { + take_failure(&JOB_CUSTODIAN_START_FAILURES) + } + + pub fn observe_job_custodian_worker_start() { + JOB_CUSTODIAN_WORKER_STARTS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_custodian_worker_starts() -> usize { + JOB_CUSTODIAN_WORKER_STARTS.load(Ordering::SeqCst) + } + + pub fn observe_job_port_last_resort_retention() { + JOB_PORT_LAST_RESORT_RETENTIONS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_port_last_resort_retentions() -> usize { + JOB_PORT_LAST_RESORT_RETENTIONS.load(Ordering::SeqCst) + } + + pub fn reset_job_extraction_faults() { + JOB_EXTRACTION_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_DISARM_FAILURES.store(0, Ordering::SeqCst); + JOB_CUSTODIAN_START_FAILURES.store(0, Ordering::SeqCst); + } + pub fn arm_owner_failure(failure: OwnerFailure) { OWNER_FAILURE.with(|slot| { assert!(slot.borrow_mut().replace(failure).is_none()); @@ -573,12 +675,24 @@ unsafe impl Sync for JobHandle {} pub(crate) struct JobPort { pub job: JobHandle, pub completion_port: StdOwnedHandle, + #[cfg(test)] + close_probe: Option>, } impl Drop for JobPort { fn drop(&mut self) { // SAFETY: `JobPort` solely owns this job handle. unsafe { CloseHandle(self.job.0) }.ok(); + #[cfg(test)] + if let Some(probe) = self.close_probe.as_ref() { + probe + .job_closes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // The completion-port OwnedHandle drops immediately after this Drop implementation returns. + probe + .completion_port_closes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } } } @@ -617,8 +731,25 @@ fn set_job_kill_on_drop_inner( ?info, "setting SetInformationJobObject(limit)" ); - // No tracing or other caller-controlled callback may run after the native transition: the sole - // final owner must either remain kill-on-close armed or complete disarming without unwinding. + set_job_kill_on_drop_native( + job, + kill_on_drop, + #[cfg(test)] + probe, + ) +} + +fn set_job_kill_on_drop_native( + job: JobHandle, + kill_on_drop: bool, + #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, +) -> Result<()> { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + if kill_on_drop { + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } + // No caller-controlled callback runs in this helper. After a successful native transition it only + // records a test-only atomic observation and returns. // SAFETY: `job` is live, and initialized `info` has the reported size and outlives the call. let result = unsafe { SetInformationJobObject( @@ -692,6 +823,8 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res Ok(JobPort { job: JobHandle(job.into_raw()), completion_port, + #[cfg(test)] + close_probe: test_support::current_job_port_close_probe(), }) } @@ -788,6 +921,133 @@ fn job_is_drained(job: JobHandle) -> Result { Ok(accounting.ActiveProcesses == 0) } +fn extracted_job_is_drained(job: JobHandle) -> Result { + #[cfg(test)] + if test_support::take_job_extraction_query_failure() { + return Err(Error::other( + "injected extracted JobObject accounting failure", + )); + } + job_is_drained(job) +} + +fn disarm_extracted_job(job: JobHandle) -> Result<()> { + #[cfg(test)] + if test_support::take_job_extraction_disarm_failure() { + return Err(Error::other("injected extracted JobObject disarm failure")); + } + set_job_kill_on_drop_native( + job, + false, + #[cfg(test)] + None, + ) +} + +fn extracted_job_can_close(job_port: &JobPort) -> bool { + if disarm_extracted_job(job_port.job).is_ok() { + return true; + } + matches!(extracted_job_is_drained(job_port.job), Ok(true)) +} + +struct JobPortCustodian { + sender: Option>, +} + +fn job_port_custodian() -> &'static Mutex { + static CUSTODIAN: OnceLock> = OnceLock::new(); + CUSTODIAN.get_or_init(|| Mutex::new(JobPortCustodian { sender: None })) +} + +#[cfg(test)] +pub(crate) fn reset_job_port_custodian_for_test() { + job_port_custodian() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sender = None; +} + +fn start_job_port_custodian(receiver: mpsc::Receiver) -> Result<()> { + #[cfg(test)] + if test_support::take_job_custodian_start_failure() { + return Err(Error::other("injected JobObject custodian start failure")); + } + std::thread::Builder::new() + .name("process-wrap-job-custodian".into()) + .spawn(move || { + #[cfg(test)] + test_support::observe_job_custodian_worker_start(); + let mut retained = Vec::new(); + loop { + match receiver.recv_timeout(JOB_POLL_INTERVAL) { + Ok(job_port) => { + retained.push(job_port); + retained.extend(receiver.try_iter()); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) if retained.is_empty() => return, + Err(mpsc::RecvTimeoutError::Disconnected) => {} + } + let mut index = 0; + while index < retained.len() { + if extracted_job_can_close(&retained[index]) { + retained.swap_remove(index); + } else { + index += 1; + } + } + } + }) + .map(drop) +} + +fn retain_extracted_job_port(mut job_port: JobPort) -> std::result::Result<(), JobPort> { + let mut custodian = job_port_custodian() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if let Some(sender) = custodian.sender.as_ref() { + match sender.send(job_port) { + Ok(()) => return Ok(()), + Err(error) => { + job_port = error.0; + custodian.sender = None; + } + } + } + + let (sender, receiver) = mpsc::channel(); + if start_job_port_custodian(receiver).is_err() { + return Err(job_port); + } + custodian.sender = Some(sender); + } +} + +fn retain_extracted_job_port_last_resort(job_port: JobPort) { + #[cfg(test)] + test_support::observe_job_port_last_resort_retention(); + // The infallible extraction API cannot report failure to establish shared custody. Forgetting this + // exact complete port keeps the possibly armed final job handle open and preserves the lower child. + std::mem::forget(job_port); +} + +/// Relinquish an extracted finalized Tokio JobObject without terminating its returned lower child. +pub(crate) fn release_extracted_job_port(job_port: JobPort, known_drained: bool) { + if known_drained || matches!(extracted_job_is_drained(job_port.job), Ok(true)) { + drop(job_port); + return; + } + if disarm_extracted_job(job_port.job).is_ok() { + drop(job_port); + return; + } + if let Err(job_port) = retain_extracted_job_port(job_port) { + retain_extracted_job_port_last_resort(job_port); + } +} + fn finite_timeout_millis(timeout: Duration) -> u32 { timeout .as_millis() diff --git a/tests/tokio_windows/job_object_kill_on_drop.rs b/tests/tokio_windows/job_object_kill_on_drop.rs index 8fab438..493f117 100644 --- a/tests/tokio_windows/job_object_kill_on_drop.rs +++ b/tests/tokio_windows/job_object_kill_on_drop.rs @@ -39,6 +39,16 @@ impl ProcessGuard { .expect("only ProcessGuard::disarm clears the handle, and it consumes the guard") } + fn has_exited(&self) -> Result { + // SAFETY: this guard owns the live process handle for the nonblocking wait. + let wait = unsafe { WaitForSingleObject(self.handle(), 0) }; + if wait == WAIT_FAILED { + Err(std::io::Error::last_os_error()) + } else { + Ok(wait == WAIT_OBJECT_0) + } + } + fn disarm(mut self) -> Result<()> { if let Some(handle) = self.0.take() { // SAFETY: taking the guard's owned handle removes the `Drop` close path. @@ -139,6 +149,45 @@ fn descendant_parent() { descendant.wait().unwrap(); } +#[tokio::test] +async fn consuming_job_object_leaves_only_direct_child_kill_on_drop() -> Result<()> { + for order in [Order::KillOnDropFirst, Order::JobObjectFirst] { + let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args(["descendant_parent", "--ignored", "--nocapture"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + }); + match order { + Order::KillOnDropFirst => { + command.wrap(KillOnDrop).wrap(JobObject); + } + Order::JobObjectFirst => { + command.wrap(JobObject).wrap(KillOnDrop); + } + } + + let mut child = command.spawn()?; + let direct = ProcessGuard::open(child.id().expect("the direct child has a PID"))?; + let descendant_pid = descendant_pid(child.as_mut()).await?; + let descendant = ProcessGuard::open(descendant_pid)?; + let mut lower = child.into_inner(); + assert_eq!(lower.try_wait()?, None); + assert!(!direct.has_exited()?); + assert!(!descendant.has_exited()?); + + drop(lower); + wait_for_process_exit(direct.handle()).await?; + direct.disarm()?; + assert!( + !descendant.has_exited()?, + "consuming JobObject must leave lower KillOnDrop scoped to the direct child" + ); + drop(descendant); + } + Ok(()) +} + #[tokio::test] async fn job_detects_kill_on_drop_in_both_orders() -> Result<()> { for order in [Order::KillOnDropFirst, Order::JobObjectFirst] { From 99a340c0716d132c5e11278d2a68bce1a4d5fbc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 01:44:18 +1300 Subject: [PATCH 086/114] test(windows): bound final-owner tracing isolation Run the tracing-panic subprocess through the shared finite watchdog and prove timeout termination, reaping, and reader cleanup with a deterministic stalled helper. Co-Authored-By: Claude Code --- .../std_windows/creation_flags_job_object.rs | 60 ++++++++++++++++--- tests/std_windows/mod.rs | 3 + 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index 4d7dd0d..a7bd1a5 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -25,6 +25,7 @@ use windows::Win32::System::Threading::{ }; use super::{ + bounded_process, prelude::*, windows_thread::{ProcessGuard, process_has_suspended_thread, resume_process_threads}, }; @@ -33,6 +34,8 @@ const EXIT_TIMEOUT: Duration = Duration::from_secs(5); const DESCENDANT_PID_FILE: &str = "PROCESS_WRAP_DESCENDANT_PID_FILE"; #[cfg(feature = "tracing")] const FINAL_OWNER_TRACE_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_HELPER"; +#[cfg(feature = "tracing")] +const FINAL_OWNER_TRACE_STALL_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_STALL_HELPER"; static PID_FILE_SEQUENCE: AtomicU32 = AtomicU32::new(0); #[derive(Clone, Copy)] @@ -595,19 +598,58 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { Ok(()) } +#[cfg(feature = "tracing")] +fn run_final_owner_trace_helper( + environment: (&str, &str), + timeout: Duration, +) -> Result<(std::process::Output, bool)> { + let mut command = StdCommand::new(std::env::current_exe()?); + command + .args([ + "std_windows::creation_flags_job_object::final_owner_tracing_panic_leaves_job_cleanup_armed", + "--exact", + "--nocapture", + ]) + .env(environment.0, environment.1); + bounded_process::run(command, timeout, None) +} + +#[cfg(feature = "tracing")] +#[test] +fn final_owner_tracing_watchdog_terminates_a_stalled_helper() -> Result<()> { + let (output, timed_out) = + run_final_owner_trace_helper((FINAL_OWNER_TRACE_STALL_HELPER, "1"), Duration::ZERO)?; + assert!( + timed_out, + "the zero-deadline watchdog must take its timeout path" + ); + assert!( + !output.status.success(), + "the stalled helper must be terminated" + ); + Ok(()) +} + #[cfg(feature = "tracing")] #[test] fn final_owner_tracing_panic_leaves_job_cleanup_armed() -> Result<()> { + if std::env::var_os(FINAL_OWNER_TRACE_STALL_HELPER).is_some() { + std::thread::sleep(Duration::from_secs(30)); + return Ok(()); + } if std::env::var_os(FINAL_OWNER_TRACE_HELPER).is_none() { - let status = StdCommand::new(std::env::current_exe()?) - .args([ - "std_windows::creation_flags_job_object::final_owner_tracing_panic_leaves_job_cleanup_armed", - "--exact", - "--nocapture", - ]) - .env(FINAL_OWNER_TRACE_HELPER, "1") - .status()?; - assert!(status.success(), "the isolated tracing regression failed"); + let (output, timed_out) = + run_final_owner_trace_helper((FINAL_OWNER_TRACE_HELPER, "1"), EXIT_TIMEOUT)?; + assert!( + !timed_out, + "the isolated tracing regression exceeded its deadline" + ); + assert!( + output.status.success(), + "the isolated tracing regression failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); return Ok(()); } diff --git a/tests/std_windows/mod.rs b/tests/std_windows/mod.rs index f406a8c..dbab355 100644 --- a/tests/std_windows/mod.rs +++ b/tests/std_windows/mod.rs @@ -11,6 +11,9 @@ mod prelude { pub const DIE_TIME: Duration = Duration::from_millis(1000); } +#[path = "../support/bounded_process.rs"] +mod bounded_process; + #[cfg(all(feature = "creation-flags", feature = "job-object"))] mod creation_flags_job_object; mod id_same_as_inner; From b7926fbe53105c9ed9294489755ec6bf4d98b381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 02:03:02 +1300 Subject: [PATCH 087/114] docs: correct lifecycle and supervision contracts Document Windows provider phases, JobObject KillOnDrop and extraction scope, logical PTY closure, batch execution, resize precedence, complete example dependencies, and logging completion. Compile README examples as downstream crates. Co-Authored-By: Claude Code --- .github/workflows/test.yml | 7 +++ README.md | 102 ++++++++++++++++++++++--------- src/generic_wrap.rs | 50 +++++++++------ src/lib.rs | 98 +++++++++++++++++++---------- src/tokio/job_object.rs | 20 +++++- src/tokio/kill_on_drop.rs | 5 +- src/tokio/pty.rs | 21 +++++-- src/windows.rs | 36 +++++++---- tests/readme/pty/.gitignore | 2 + tests/readme/pty/Cargo.toml | 10 +++ tests/readme/pty/src/main.rs | 34 +++++++++++ tests/readme/windows/.gitignore | 2 + tests/readme/windows/Cargo.toml | 12 ++++ tests/readme/windows/src/main.rs | 18 ++++++ tests/tokio_pty_unix.rs | 20 ++++++ tests/tokio_pty_windows.rs | 56 ++++++++++++++++- 16 files changed, 394 insertions(+), 99 deletions(-) create mode 100644 tests/readme/pty/.gitignore create mode 100644 tests/readme/pty/Cargo.toml create mode 100644 tests/readme/pty/src/main.rs create mode 100644 tests/readme/windows/.gitignore create mode 100644 tests/readme/windows/Cargo.toml create mode 100644 tests/readme/windows/src/main.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e31c4b..42f3387 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,10 @@ jobs: if: matrix.toolchain == 'stable' run: cargo test --locked --no-default-features --features ${{ matrix.features }} --all-targets + - name: Compile Windows README example as a downstream crate + if: matrix.platform == 'windows' && matrix.toolchain == 'stable' && matrix.features == 'pty' + run: cargo check --manifest-path tests/readme/windows/Cargo.toml + - run: cargo clippy --locked --features ${{ matrix.features }} --all-targets no-default-features: @@ -244,6 +248,9 @@ jobs: - name: Test documentation examples run: cargo test --locked --all-features --doc + - name: Compile PTY README example as a downstream crate + run: cargo check --manifest-path tests/readme/pty/Cargo.toml + package: name: Package contents runs-on: ubuntu-latest diff --git a/README.md b/README.md index db17325..254d9af 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ selecting the Tokio frontend and its terminal dependencies explicitly. ```toml [dependencies] process-wrap = { version = "10.0.0", features = ["pty"] } +tokio = { version = "1.38.2", features = ["io-util", "rt"] } ``` ```rust @@ -143,12 +144,20 @@ that `Pty` as the spawn provider with `.wrap(Pty::default())` (or `.wrap(configu ordinary `.spawn()` and its ordinary boxed-child result. Call `take_pty_controller()` once on that returned child to obtain terminal I/O and resize control. +On Windows, the ConPTY provider rejects direct `.bat` and `.cmd` programs case-insensitively. Invoke +`cmd.exe` explicitly with arguments such as `/d /s /c ...` when shell or batch semantics are required. +The provider resolves executable entries itself and reports an unresolved bare program as `NotFound`; +it does not perform implicit shell or `PATHEXT` dispatch. + 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. +`PtyInput` and `PtyOutput` are strong owners of one logical bidirectional controller, so dropping +either one alone does not release its master ownership. After both are gone, a valid request through +the weak `PtyResize` handle returns `BrokenPipe`. On Windows, final strong-owner loss schedules +`ClosePseudoConsole` on a detached worker. If that worker cannot be created, process-wrap +intentionally retains the HPCON rather than potentially blocking the dropping thread, so logical +controller closure does not guarantee OS pseudoconsole teardown. 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 @@ -159,8 +168,12 @@ 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 +`ResetSigmask` composes normally. Without `JobObject`, Tokio `KillOnDrop` terminates only the direct +child; it does not promise to terminate a process group, session, or descendants. On Windows, +combining `KillOnDrop` with `JobObject` enables `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. While the +`JobObject` child layer remains installed, dropping the returned child terminates every process still +associated with that job. `JobObject` and `KillOnDrop` may be registered in either order, and the +Windows PTY provider preserves the same policy. Spawning returns the ordinary boxed Tokio child, and `take_pty_controller()` traverses any outer child wrappers and yields the controller once. ### or with std @@ -223,6 +236,13 @@ Command::with_new("watch", |command| { command.arg("ls"); }) requested flag, temporarily adds `CREATE_SUSPENDED` while assigning the process, and resumes it after assignment unless the caller explicitly requested `CREATE_SUSPENDED`. +`KillOnDrop` may likewise be registered before or after `JobObject`. On a successful Tokio spawn with +both wrappers, process-wrap enables `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`; ordinary drop while the +`JobObject` child layer remains installed terminates every process still associated with the job. +Without `JobObject`, the lower Tokio child's policy targets only that direct child. Consuming the +`JobObject` layer with `ChildWrapper::into_inner` relinquishes whole-job waiting, explicit whole-job +killing, and kill-on-close supervision. The returned lower child retains only its direct-child policy. + ### Process group - Platforms: POSIX (Linux, Mac, BSDs...) @@ -285,10 +305,20 @@ This wrapper records Windows creation flags as portable per-attempt policy. Call `Command::creation_flags` method instead makes the command native-only because wrappers and alternate transports cannot query or reconstruct those flags. +```toml +[dependencies] +process-wrap = { version = "10.0.0", features = ["std", "creation-flags", "job-object"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_System_Threading"] } +``` + ```rust -use windows::Win32::System::Threading::*; +use process_wrap::std::{Command, CreationFlags, JobObject}; +use windows::Win32::System::Threading::{CREATE_NO_WINDOW, DETACHED_PROCESS}; + Command::with_new("watch", |command| { command.arg("ls"); }) - .wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_DETACHED)) + .wrap(CreationFlags(CREATE_NO_WINDOW | DETACHED_PROCESS)) .wrap(JobObject) .spawn()?; ``` @@ -305,7 +335,10 @@ after assignment unless the caller explicitly requested `CREATE_SUSPENDED`. This wrapper records kill-on-drop as portable per-attempt policy so `JobObject` and alternate spawn providers can preserve it. Calling the native-shaped `Command::kill_on_drop` method instead makes the -command native-only because the setting cannot be queried afterward. +command native-only because the setting cannot be queried afterward. Without an installed +`JobObject`, the policy terminates only the direct Tokio child. With an installed `JobObject`, it also +enables kill-on-last-job-handle-close for every process still associated with that job; consuming the +`JobObject` layer relinquishes that whole-job policy. ```rust let child = Command::with_new("watch", |command| { command.arg("ls"); }) @@ -348,6 +381,11 @@ The trait provides extension or hook points into the lifecycle of a `Command`: native API does not expose callback insertion or command ownership; prefer portable attempt methods for recurring configuration. +- **On Windows, `prepare_child`** runs in registration order after transport creation and before any + public `post_spawn` hook. It constructs fallible per-wrapper prepared state while native/provider + cleanup remains armed. The matching weak token is installed into a detached child layer during + wrapping, before that layer is published to later callbacks. + - **`fn post_spawn(&mut self, attempt: &mut SpawnAttempt, child: &mut dyn ChildWrapper, command: &Command) -> io::Result<()>`** is called after any transport has created its child. The child may be a terminal custom/provider child with no native child value. Changing command settings on `attempt` at this point cannot @@ -379,27 +417,35 @@ Callbacks run in this order: 4. every `pre_spawn` hook 5. native-only attempt rejection 6. `validate_attempt` -7. provider `spawn` -8. every `post_spawn` hook -9. every child wrapper -10. private return-sidecar allocation -11. transaction `commit` -12. committed transaction-residue installation -13. on Windows, the sole JobObject cleanup owner +7. provider `spawn`, returning a child and fresh armed transaction +8. on Windows, every fallible prepared-child construction hook +9. every `post_spawn` hook +10. every child-layer description and detached installation, including matching prepared state and + install-time custody validation +11. on Windows, ordinary child finalization and cleanup disarm, cleanup-owner validation, and + non-owner disarm while the sole final owner remains armed +12. fallible private return-custody allocation +13. transaction `commit` +14. committed transaction-residue installation +15. on Windows, the sole final cleanup owner disarms +16. infallible private ownership moves and child return Validation must reject unsupported portable policy before allocating operating-system resources. `spawn` returns a child satisfying the frontend's complete `ChildWrapper` contract together with a -fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. A -later hook, wrapper, pre-commit child step, or commit error or unwinding panic causes best-effort -rollback while preserving the original failure. After capturing that failure, process-wrap first -resolves transaction cleanup, then disposes any detached child layer, the child chain, and each -Windows prepared value independently. Secondary cleanup panic payloads are quarantined without -inspection or destruction. Before commit, process-wrap preallocates the private return sidecar. -Successful commit ends failed-spawn rollback, and its residue is installed in that sidecar. On Windows, -the sole JobObject owner remains authoritative until it disarms after commit; no caller callback or -allocation-dependent work follows that transition. On a successful spawn, arbitrary residue destruction -occurs outside the spawn lifecycle. Committed residue must retain no armed cleanup or independent -process, terminal, controller, handle, pseudoconsole, or other liveness resource. +fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. On +Windows, prepared-state construction occurs before public post hooks. After wrapping and install-time +custody validation, ordinary finalization, ordinary cleanup disarm, owner selection, and non-owner +disarm all complete before return-custody allocation and commit; every step is fallible while the +transaction remains armed. A later error or unwinding panic causes best-effort rollback while +preserving the original failure. After capturing that failure, process-wrap first resolves transaction +cleanup, then disposes any detached child layer, the child chain, and each Windows prepared value +independently. Secondary cleanup panic payloads are quarantined without inspection or destruction. +Successful commit ends failed-spawn rollback, and its residue is installed in the already-allocated +return custody. On Windows, the sole final owner remains armed until it disarms after commit; no caller +callback, allocation-dependent work, or native handle close follows that successful transition before +the infallible return move. On a successful spawn, arbitrary residue destruction occurs outside the +spawn lifecycle. Committed residue must retain no armed cleanup or independent process, terminal, +controller, handle, pseudoconsole, or other liveness resource. Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic containment apply only to unwinding panics. With `panic=abort`, the process terminates before those diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 0b7c358..53b89a7 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -315,9 +315,11 @@ macro_rules! Wrap { "This token is deliberately non-owning and not `Clone`, and arbitrary mutable access is ", "not public. A layer may inspect its installed value immutably with ", "[`PreparedChild::with`] while the returned child retains process-wrap's private owner. ", - "Moving or retaining this token cannot prolong the prepared value's lifetime. Resource ", - "types which themselves expose interior mutation or independently clonable native owners ", - "remain responsible for those capabilities.\n\n", + "Moving or retaining this token cannot prolong the prepared value's lifetime. Inspection holds ", + "exclusive value access for the callback, which must not synchronously reenter the same token ", + "or destroy an owner whose close waits for that access. Resource types which themselves expose ", + "interior mutation or independently clonable native owners remain responsible for those ", + "capabilities.\n\n", "On native Windows success, a private outer sidecar retains the strong prepared storage and ", "any disarmed spawn-cleanup handle until child disposal or consuming sidecar removal. It ", "delegates the full child contract, exposes the immediate application layer through ", @@ -1414,14 +1416,19 @@ macro_rules! Wrap { /// /// Process-wrap invokes provider callbacks in this order: `check_available`, native-only base /// rejection, `validate_command`, every `pre_spawn` hook in registration order, native-only - /// attempt rejection, `validate_attempt`, and `spawn`. After `spawn` returns a product, every - /// `post_spawn` and child-wrapping hook runs in registration order. Process-wrap then completes - /// the pre-commit child phase while provider rollback remains armed and preallocates the private - /// return sidecar. It commits the transaction, installs the committed residue in that sidecar, and, - /// on Windows, disarms the sole JobObject cleanup owner. Successful commit ends failed-spawn - /// rollback. After the final owner succeeds, only infallible private ownership moves remain before - /// return. Arbitrary residue destruction occurs outside the successful spawn lifecycle. `spawn_with` - /// and `spawn_with_child` reject a registered provider instead of bypassing it. + /// attempt rejection, `validate_attempt`, and `spawn`. `spawn` returns a child plus a fresh armed + /// transaction. On Windows, fallible prepared-state construction then runs before every public + /// `post_spawn` hook. Post hooks and child-layer descriptions run in registration order; detached + /// installation fills matching weak prepared tokens, validates private strong custody, and only + /// then publishes each layer. On Windows, ordinary finalization and cleanup disarm, cleanup-owner + /// validation, and non-owner disarm all run before the fallible return-custody allocation and + /// transaction commit while rollback remains armed. Process-wrap commits, transfers committed + /// residue into the ready return custody, and then disarms the sole final owner. Any earlier error + /// or unwinding panic rolls back first and independently disposes remaining lifecycle values while + /// quarantining secondary panic payloads. Successful commit ends failed-spawn rollback. After the + /// final owner succeeds, only infallible private ownership moves remain before return. Arbitrary + /// residue destruction occurs outside the successful spawn lifecycle. `spawn_with` and + /// `spawn_with_child` reject a registered provider instead of bypassing it. /// /// A committed transaction residue must retain no armed cleanup or independent process, /// terminal, controller, handle, pseudoconsole, or other liveness resource. @@ -2137,10 +2144,18 @@ macro_rules! Wrap { /// Spawn the command, returning a child that can be interacted with. /// /// With no alternate provider, this runs all `pre_spawn` hooks, spawns through the native - /// frontend, runs all capability-level `post_spawn` hooks, then stacks all - /// `wrap_child`s. A registered provider replaces only the native transport, commits its cleanup - /// transaction after the same complete hook chain succeeds, and returns the child with the - /// committed transaction residue in a private transparent layer. + /// frontend, runs Windows prepared-state construction before every public `post_spawn` hook, + /// then describes and installs child layers in registration order with install-time custody + /// validation. Windows ordinary finalization, cleanup disarm, and owner selection finish before + /// the sole final owner disarms and the child returns. + /// + /// A registered provider replaces only transport creation and returns a child plus fresh armed + /// transaction. The same prepared/post/wrap lifecycle and Windows pre-commit finalization run + /// while rollback remains armed. Process-wrap allocates return custody, commits, transfers residue, + /// and only then disarms the sole final owner. Any earlier error or unwinding panic rolls back + /// first, independently disposes remaining values, quarantines secondary payloads, and preserves + /// the exact primary failure. After successful final-owner disarm only infallible private moves + /// remain before return. pub fn spawn(&mut self) -> ::std::io::Result> { if let Some(provider_index) = self.select_spawn_provider()? { return self.spawn_with_provider(provider_index); @@ -2315,8 +2330,9 @@ macro_rules! Wrap { /// The complete safe installation pattern is: return `T` here; have the detached layer store /// an empty `Option`; confirm `PreparedChildRef::is::()` while wrapping; /// return that layer through `PendingChildWrapper::new`; and expose its slot with - /// `ChildWrapperSlots::with_prepared::`. Process-wrap validates the type and exact owner - /// topology before filling the slot. Once installed, the layer may use + /// `ChildWrapperSlots::with_prepared::`. Process-wrap validates the type, fills the weak token, + /// validates exclusive strong custody while the layer remains detached, then transfers the child + /// and publishes the installed layer. Once installed, the layer may use /// `PreparedChild::with::` for immutable access. /// /// A prepared type which itself exposes interior ownership transfer or independently clonable diff --git a/src/lib.rs b/src/lib.rs index 040462c..c4005a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,20 +155,33 @@ //! use ordinary `.spawn()` and its ordinary boxed-child result. Call `take_pty_controller()` once on //! that returned child to obtain terminal I/O and resize control. //! +//! On Windows, the ConPTY provider rejects direct `.bat` and `.cmd` programs case-insensitively. +//! Invoke `cmd.exe` explicitly with arguments such as `/d /s /c ...` for shell or batch semantics. +//! The provider resolves executable entries itself and reports unresolved bare programs as `NotFound`; +//! it does not perform implicit shell or `PATHEXT` dispatch. +//! //! 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. +//! Input and output each strongly own the logical bidirectional controller; resize handles are weak. +//! Dropping one I/O side is not a half-close. After both are gone, a valid resize request returns +//! `BrokenPipe`. On Windows, final strong-owner loss schedules `ClosePseudoConsole` on a detached +//! worker. If worker creation fails, process-wrap intentionally retains the HPCON rather than +//! potentially blocking the dropping thread, so logical closure does not guarantee OS pseudoconsole +//! teardown. 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. +//! explicitly registering both is invalid. `ResetSigmask` composes. Without `JobObject`, Tokio +//! `KillOnDrop` targets only the direct child. On Windows, combining `KillOnDrop` with `JobObject` +//! enables kill-on-last-job-handle-close for every process still associated with the job; either +//! registration order works and the Windows PTY provider preserves that policy. Consuming the +//! `JobObject` layer relinquishes whole-job supervision, leaving only the lower direct-child policy. +//! The returned boxed child keeps arbitrary outer wrappers, and `take_pty_controller()` traverses them +//! and yields the controller once. //! //! # KillOnDrop and CreationFlags //! @@ -196,10 +209,17 @@ //! //! ## Instead of `.creation_flags(CREATE_NO_WINDOW)` (Windows-only): //! -//! ```rust,ignore -//! use process_wrap::std::*; +//! ```rust,no_run +//! # #[cfg(all(windows, feature = "std", feature = "creation-flags"))] +//! # fn run() -> std::io::Result<()> { +//! use process_wrap::std::{Command, CreationFlags}; +//! use windows::Win32::System::Threading::CREATE_NO_WINDOW; +//! //! let mut command = Command::with_new("ls", |command| { command.arg("-l"); }); //! command.wrap(CreationFlags(CREATE_NO_WINDOW)); +//! # let _child = command.spawn()?; +//! # Ok(()) } +//! # fn main() {} //! ``` //! //! Internally the `JobObject` wrapper always sets the `CREATE_SUSPENDED` flag, but as it is able to @@ -243,6 +263,11 @@ //! retain native mutations. Calling `attempt.native_mut()` or `stdin`/`stdout`/`stderr` makes a //! tracked attempt incompatible with a portable provider. By default does nothing. //! +//! - **On Windows, `prepare_child`** runs in registration order after transport creation and before +//! public post-spawn hooks. It constructs fallible per-wrapper prepared state while cleanup remains +//! armed; the matching weak token is installed into a detached layer during wrapping before that +//! layer is published to later callbacks. +//! //! - **`fn post_spawn(&mut self, attempt: &mut SpawnAttempt, child: &mut dyn ChildWrapper, command: &Command)`** //! is called after any transport creates its child. The child may be a terminal custom/provider //! child with no native value. Changing command settings on `attempt` here cannot configure the @@ -274,27 +299,35 @@ //! 4. every `pre_spawn` hook //! 5. native-only attempt rejection //! 6. `validate_attempt` -//! 7. provider `spawn` -//! 8. every `post_spawn` hook -//! 9. every child wrapper -//! 10. private return-sidecar allocation -//! 11. transaction `commit` -//! 12. committed transaction-residue installation -//! 13. on Windows, the sole JobObject cleanup owner +//! 7. provider `spawn`, returning a child and fresh armed transaction +//! 8. on Windows, every fallible prepared-child construction hook +//! 9. every `post_spawn` hook +//! 10. every child-layer description and detached installation, including matching prepared state and +//! install-time custody validation +//! 11. on Windows, ordinary finalization and cleanup disarm, cleanup-owner validation, and non-owner +//! disarm while the sole final owner remains armed +//! 12. fallible private return-custody allocation +//! 13. transaction `commit` +//! 14. committed transaction-residue installation +//! 15. on Windows, the sole final cleanup owner disarms +//! 16. infallible private ownership moves and child return //! //! Validation rejects unsupported portable policy before operating-system allocation. `spawn` //! returns a child satisfying the frontend's complete `ChildWrapper` contract and a fresh, armed -//! `SpawnTransaction` which owns cleanup independently of the child chain. A later public hook, -//! wrapper, pre-commit child step, or commit error or unwinding panic causes best-effort rollback -//! while preserving the original failure. After capturing that failure, process-wrap first resolves -//! transaction cleanup, then disposes any detached child layer, the child chain, and each Windows -//! prepared value independently. Secondary cleanup panic payloads are quarantined without inspection -//! or destruction. Before commit, process-wrap preallocates the private return sidecar. Successful -//! commit ends failed-spawn rollback, and its residue is installed in that sidecar. On Windows, the -//! sole JobObject owner remains authoritative until it disarms after commit; no caller callback or -//! allocation-dependent work follows that transition. On a successful spawn, arbitrary residue -//! destruction occurs outside the spawn lifecycle. Committed residue must retain no armed cleanup or -//! independent process, terminal, controller, handle, pseudoconsole, or other liveness resource. +//! `SpawnTransaction` which owns cleanup independently of the child chain. On Windows, prepared-state +//! construction occurs before public post hooks. After wrapping and install-time custody validation, +//! ordinary finalization, ordinary cleanup disarm, owner selection, and non-owner disarm all complete +//! before return-custody allocation and commit; every step is fallible while the transaction remains +//! armed. A later error or unwinding panic causes best-effort rollback while preserving the original +//! failure. After capturing that failure, process-wrap first resolves transaction cleanup, then +//! disposes any detached child layer, the child chain, and each Windows prepared value independently. +//! Secondary cleanup panic payloads are quarantined without inspection or destruction. Successful +//! commit ends failed-spawn rollback, and its residue is installed in the already-allocated return +//! custody. On Windows, the sole final owner remains armed until it disarms after commit; no caller +//! callback, allocation-dependent work, or native handle close follows that successful transition +//! before the infallible return move. On a successful spawn, arbitrary residue destruction occurs +//! outside the spawn lifecycle. Committed residue must retain no armed cleanup or independent process, +//! terminal, controller, handle, pseudoconsole, or other liveness resource. //! //! Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic //! containment apply only to unwinding panics. With `panic=abort`, the process terminates before @@ -352,11 +385,10 @@ //! # fn main() {} //! ``` //! -//! That's a great start, but it's actually introduced a resource leak: if the main thread of your -//! program exits before that background one does, then the background thread won't get a chance to -//! call `logfile`'s `Drop` implementation which closes the file. The file handle will be left open! -//! To fix this, we'll need to keep track of the background thread's `ThreadHandle` and `.join()` it -//! when calling `.wait()` on the `ChildWrapper`. +//! That is a useful start, but returning from `main` can terminate the process before the detached +//! worker finishes copying and flushing terminal bytes, leaving incomplete log output. Retain the +//! background thread's `JoinHandle` and join it from the child wrapper's `wait` method so copying +//! completes before the caller proceeds. //! //! ```rust //! # #[cfg(feature = "std")] diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index eabede3..fa188ff 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -16,10 +16,13 @@ use crate::{ ChildExitStatus, windows::{ JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, - release_extracted_job_port, resume_threads, terminate_job, + resume_threads, terminate_job, }, }; +#[cfg(feature = "kill-on-drop")] +use crate::windows::release_extracted_job_port; + #[cfg(not(test))] use crate::windows::set_job_kill_on_drop; #[cfg(test)] @@ -46,6 +49,14 @@ use super::{ /// /// [`CreationFlags`] may be registered before or after `JobObject`; process-wrap preserves its flags /// and distinguishes explicit suspension from the temporary suspension needed for assignment. +/// `KillOnDrop` may likewise be registered before or after `JobObject`. With both wrappers installed, +/// successful spawn enables `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so ordinary drop while the +/// `JobObject` layer remains installed terminates every process still associated with the job. Without +/// `JobObject`, the lower Tokio child's kill-on-drop policy targets only that direct child. +/// +/// Consuming the `JobObject` layer through [`ChildWrapper::into_inner`] relinquishes whole-job waiting, +/// explicit whole-job killing, and kill-on-close supervision. The returned lower child remains usable +/// and retains only its direct-child kill-on-drop policy. #[derive(Clone, Copy, Debug)] pub struct JobObject; @@ -162,6 +173,11 @@ impl CommandWrapper for JobObject { } /// Wrapper for `Child` which waits on all processes within the job. +/// +/// When `KillOnDrop` is also registered, ordinary drop while this layer remains installed closes the +/// final job handle and terminates every process still associated with the job. Consuming this layer +/// relinquishes whole-job wait, kill, and kill-on-close supervision; the returned lower child retains +/// only direct-child policy. #[derive(Debug)] pub struct JobObjectChild { inner: Option>, @@ -227,6 +243,7 @@ impl JobObjectChild { ) } + #[cfg(feature = "kill-on-drop")] fn take_prepared(&mut self) -> PreparedJobObject { if let Some(prepared) = self.extracted.take() { return prepared; @@ -285,6 +302,7 @@ impl ChildWrapper for JobObjectChild { .inner .take() .expect("an installed JobObject layer owns its child"); + #[cfg(feature = "kill-on-drop")] if self.spawn_finalized && self.final_kill_on_drop { let job_drained = self.job_drained; let mut prepared = self.take_prepared(); diff --git a/src/tokio/kill_on_drop.rs b/src/tokio/kill_on_drop.rs index 15b71b8..26c192c 100644 --- a/src/tokio/kill_on_drop.rs +++ b/src/tokio/kill_on_drop.rs @@ -10,7 +10,10 @@ use super::{CommandWrap, CommandWrapper, SpawnAttempt}; /// /// On Unix, dropping a process-group or session child still applies Tokio's kill-on-drop behavior to /// the direct native child only. Use the group-aware child's explicit kill or signal methods when the -/// entire process group must be targeted. +/// entire process group must be targeted. On Windows, combining this wrapper with `JobObject` in either +/// registration order enables kill-on-last-job-handle-close for every process still associated with +/// that job while the JobObject layer remains installed. Consuming that layer relinquishes whole-job +/// supervision and leaves only the lower direct-child policy. #[derive(Clone, Copy, Debug)] pub struct KillOnDrop; diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index bbcf164..af96a6c 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -144,6 +144,11 @@ use windows as imp; /// # } /// # fn main() {} /// ``` +/// +/// On Windows, direct `.bat` and `.cmd` programs are rejected case-insensitively. Invoke `cmd.exe` +/// explicitly with arguments such as `/d /s /c ...` for shell or batch semantics. The provider +/// resolves executable entries itself, returns `NotFound` for unresolved bare programs, and does not +/// perform implicit shell or `PATHEXT` dispatch. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct Pty { size: PtySize, @@ -348,7 +353,8 @@ pub struct PtyResize { impl PtyResize { /// Change the terminal size. /// - /// Returns [`io::ErrorKind::BrokenPipe`] after both strong I/O owners have been dropped. + /// Generic nonzero character-size validation runs first. After both strong I/O owners have been + /// dropped, a valid request returns [`io::ErrorKind::BrokenPipe`]. pub fn resize(&self, size: PtySize) -> io::Result<()> { size.validate()?; self.inner.resize(size) @@ -357,11 +363,14 @@ impl PtyResize { /// The I/O and resize controls for a spawned pseudo-terminal. /// -/// [`PtyInput`] and [`PtyOutput`] each strongly own one shared bidirectional master. The terminal -/// hangs up only after both owners are gone; dropping either one alone is not a half-close. A -/// [`PtyResize`] is weak and never keeps the terminal alive. There is deliberately no clonable -/// force-close handle, parent-terminal raw mode, byte relay, key handling, VT parsing, scrollback, -/// or pager policy in this transport. +/// [`PtyInput`] and [`PtyOutput`] each strongly own one shared logical bidirectional controller. +/// Dropping both releases that master ownership, and valid requests through weak [`PtyResize`] handles +/// then return `BrokenPipe`; dropping either one alone is not a half-close. On Windows, final +/// strong-owner loss schedules `ClosePseudoConsole` on a detached worker. If worker creation fails, +/// process-wrap intentionally retains the HPCON rather than potentially blocking the dropping thread, +/// so logical controller closure does not guarantee OS pseudoconsole teardown. There is deliberately +/// no clonable force-close handle, parent-terminal raw mode, byte relay, key handling, VT parsing, +/// scrollback, or pager policy in this transport. /// /// Process supervision and PTY draining are separate lifecycles. Waiting for the direct child does /// not imply output EOF on most supported Unix systems, because descendants can retain slave diff --git a/src/windows.rs b/src/windows.rs index 1aa88fe..606dcd3 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -4,10 +4,12 @@ use std::{ io::{Error, Result}, ops::ControlFlow, os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle as StdOwnedHandle}, - sync::{Mutex, OnceLock, mpsc}, time::Duration, }; +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +use std::sync::{Mutex, OnceLock, mpsc}; + #[cfg(feature = "tracing")] use tracing::{debug, instrument}; use windows::{ @@ -720,17 +722,18 @@ fn set_job_kill_on_drop_inner( kill_on_drop: bool, #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, ) -> Result<()> { - let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - if kill_on_drop { - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - } - #[cfg(feature = "tracing")] - debug!( - kill_on_drop, - ?info, - "setting SetInformationJobObject(limit)" - ); + { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + if kill_on_drop { + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } + debug!( + kill_on_drop, + ?info, + "setting SetInformationJobObject(limit)" + ); + } set_job_kill_on_drop_native( job, kill_on_drop, @@ -921,6 +924,7 @@ fn job_is_drained(job: JobHandle) -> Result { Ok(accounting.ActiveProcesses == 0) } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn extracted_job_is_drained(job: JobHandle) -> Result { #[cfg(test)] if test_support::take_job_extraction_query_failure() { @@ -931,6 +935,7 @@ fn extracted_job_is_drained(job: JobHandle) -> Result { job_is_drained(job) } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn disarm_extracted_job(job: JobHandle) -> Result<()> { #[cfg(test)] if test_support::take_job_extraction_disarm_failure() { @@ -944,6 +949,7 @@ fn disarm_extracted_job(job: JobHandle) -> Result<()> { ) } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn extracted_job_can_close(job_port: &JobPort) -> bool { if disarm_extracted_job(job_port.job).is_ok() { return true; @@ -951,16 +957,18 @@ fn extracted_job_can_close(job_port: &JobPort) -> bool { matches!(extracted_job_is_drained(job_port.job), Ok(true)) } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] struct JobPortCustodian { sender: Option>, } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn job_port_custodian() -> &'static Mutex { static CUSTODIAN: OnceLock> = OnceLock::new(); CUSTODIAN.get_or_init(|| Mutex::new(JobPortCustodian { sender: None })) } -#[cfg(test)] +#[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] pub(crate) fn reset_job_port_custodian_for_test() { job_port_custodian() .lock() @@ -968,6 +976,7 @@ pub(crate) fn reset_job_port_custodian_for_test() { .sender = None; } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn start_job_port_custodian(receiver: mpsc::Receiver) -> Result<()> { #[cfg(test)] if test_support::take_job_custodian_start_failure() { @@ -1002,6 +1011,7 @@ fn start_job_port_custodian(receiver: mpsc::Receiver) -> Result<()> { .map(drop) } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn retain_extracted_job_port(mut job_port: JobPort) -> std::result::Result<(), JobPort> { let mut custodian = job_port_custodian() .lock() @@ -1025,6 +1035,7 @@ fn retain_extracted_job_port(mut job_port: JobPort) -> std::result::Result<(), J } } +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn retain_extracted_job_port_last_resort(job_port: JobPort) { #[cfg(test)] test_support::observe_job_port_last_resort_retention(); @@ -1034,6 +1045,7 @@ fn retain_extracted_job_port_last_resort(job_port: JobPort) { } /// Relinquish an extracted finalized Tokio JobObject without terminating its returned lower child. +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] pub(crate) fn release_extracted_job_port(job_port: JobPort, known_drained: bool) { if known_drained || matches!(extracted_job_is_drained(job_port.job), Ok(true)) { drop(job_port); diff --git a/tests/readme/pty/.gitignore b/tests/readme/pty/.gitignore new file mode 100644 index 0000000..1b72444 --- /dev/null +++ b/tests/readme/pty/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target diff --git a/tests/readme/pty/Cargo.toml b/tests/readme/pty/Cargo.toml new file mode 100644 index 0000000..c7f6377 --- /dev/null +++ b/tests/readme/pty/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "process-wrap-readme-pty" +version = "0.0.0" +publish = false +edition = "2024" +rust-version = "1.88.0" + +[dependencies] +process-wrap = { path = "../../..", version = "10.0.0", features = ["pty"] } +tokio = { version = "1.38.2", features = ["io-util", "rt"] } diff --git a/tests/readme/pty/src/main.rs b/tests/readme/pty/src/main.rs new file mode 100644 index 0000000..41fc926 --- /dev/null +++ b/tests/readme/pty/src/main.rs @@ -0,0 +1,34 @@ +use process_wrap::tokio::{Command, Pty}; +use tokio::io::AsyncReadExt; + +async fn example() -> Result<(), Box> { + #[cfg(unix)] + let mut command = Command::with_new("sh", |command| { + command.args(["-c", "printf terminal"]); + }); + #[cfg(windows)] + let mut command = Command::with_new("cmd.exe", |command| { + command.args(["/d", "/s", "/c", "echo terminal"]); + }); + command.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); + Ok(()) +} + +fn main() { + let _ = example; +} diff --git a/tests/readme/windows/.gitignore b/tests/readme/windows/.gitignore new file mode 100644 index 0000000..1b72444 --- /dev/null +++ b/tests/readme/windows/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target diff --git a/tests/readme/windows/Cargo.toml b/tests/readme/windows/Cargo.toml new file mode 100644 index 0000000..f441a25 --- /dev/null +++ b/tests/readme/windows/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "process-wrap-readme-windows" +version = "0.0.0" +publish = false +edition = "2024" +rust-version = "1.88.0" + +[dependencies] +process-wrap = { path = "../../..", version = "10.0.0", features = ["std", "creation-flags", "job-object"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_System_Threading"] } diff --git a/tests/readme/windows/src/main.rs b/tests/readme/windows/src/main.rs new file mode 100644 index 0000000..08ee425 --- /dev/null +++ b/tests/readme/windows/src/main.rs @@ -0,0 +1,18 @@ +#[cfg(windows)] +fn example() -> Result<(), Box> { + use process_wrap::std::{Command, CreationFlags, JobObject}; + use windows::Win32::System::Threading::{CREATE_NO_WINDOW, DETACHED_PROCESS}; + + Command::with_new("watch", |command| { + command.arg("ls"); + }) + .wrap(CreationFlags(CREATE_NO_WINDOW | DETACHED_PROCESS)) + .wrap(JobObject) + .spawn()?; + Ok(()) +} + +fn main() { + #[cfg(windows)] + let _ = example(); +} diff --git a/tests/tokio_pty_unix.rs b/tests/tokio_pty_unix.rs index a7a076d..f97c651 100644 --- a/tests/tokio_pty_unix.rs +++ b/tests/tokio_pty_unix.rs @@ -231,6 +231,16 @@ async fn shutting_down_input_does_not_hang_up_while_output_exists() -> io::Resul resize.resize(PtySize::default())?; drop(output); + assert_eq!( + resize + .resize(PtySize { + rows: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); assert_eq!( resize.resize(PtySize::default()).unwrap_err().kind(), io::ErrorKind::BrokenPipe @@ -254,6 +264,16 @@ async fn dropping_output_does_not_hang_up_while_input_exists() -> io::Result<()> assert!(child.try_wait()?.is_none()); resize.resize(PtySize::default())?; drop(input); + assert_eq!( + resize + .resize(PtySize { + columns: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); assert_eq!( resize.resize(PtySize::default()).unwrap_err().kind(), io::ErrorKind::BrokenPipe diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index f4ebee0..b1de1f5 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -144,6 +144,39 @@ fn expected_support_rejects_injected_unsupported_capability() { assert_eq!(error.kind(), io::ErrorKind::Unsupported); } +#[tokio::test] +async fn direct_batch_is_rejected_and_explicit_cmd_executes_it() -> io::Result<()> { + require_conpty!(); + let directory = tempfile::tempdir()?; + let script = directory.path().join("explicit-batch.CmD"); + std::fs::write(&script, b"@echo off\r\necho PW-BATCH-OK\r\n")?; + + let mut direct = Command::new(&script); + direct.wrap(Pty::default()); + let error = direct + .spawn() + .expect_err("the Windows PTY provider rejects direct batch execution"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "Windows PTY spawning does not execute batch scripts directly" + ); + + let mut explicit = Command::with_new("cmd.exe", |command| { + command.args(["/d", "/s", "/c"]).arg(&script); + }); + explicit.wrap(Pty::default()); + let mut child = explicit.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + assert!(child.wait().await?.success()); + assert!(String::from_utf8_lossy(&bytes).contains("PW-BATCH-OK")); + Ok(()) +} + #[test] fn capability_queries_ignore_invalid_configuration_on_windows() { let invalid = Pty::new(PtySize { @@ -328,7 +361,8 @@ fn process_exit_guard_drop_is_bounded_when_termination_fails() { // exactly once here. let controller = unsafe { OwnedHandle::from_raw_handle(event.0) }; // try_clone creates a separately owned handle to the same waitable event object. The controller - // remains independent so the old infinite drop can always be released before the worker joins. + // remains independent so timeout recovery can signal the event and release a blocked guard drop + // before the worker joins. let wait_handle = controller.try_clone().unwrap(); let (started, observe_start) = mpsc::channel(); let (completed, observe_completion) = mpsc::channel(); @@ -844,6 +878,16 @@ async fn shutting_down_input_releases_its_owner_without_early_hangup() -> io::Re assert!(child.try_wait()?.is_none()); resize.resize(PtySize::default())?; drop(output); + assert_eq!( + resize + .resize(PtySize { + rows: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); assert_eq!( resize.resize(PtySize::default()).unwrap_err().kind(), io::ErrorKind::BrokenPipe @@ -863,6 +907,16 @@ async fn dropping_output_keeps_the_terminal_live_while_input_exists() -> io::Res assert!(child.try_wait()?.is_none()); resize.resize(PtySize::default())?; drop(input); + assert_eq!( + resize + .resize(PtySize { + columns: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); assert_eq!( resize.resize(PtySize::default()).unwrap_err().kind(), io::ErrorKind::BrokenPipe From 07945f0f14ccbb1eef8883864c674a48a1d62323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 02:11:47 +1300 Subject: [PATCH 088/114] fix(windows): scope JobObject extraction probes Compile extraction fault and close instrumentation only for Tokio KillOnDrop graphs so std-only and PTY-only Windows test builds remain warning-free. Co-Authored-By: Claude Code --- src/windows.rs | 209 ++++++++++++++++++++++++++----------------------- 1 file changed, 110 insertions(+), 99 deletions(-) diff --git a/src/windows.rs b/src/windows.rs index 606dcd3..184cc63 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -197,32 +197,122 @@ pub(crate) mod test_support { Panic(Arc<()>), } - #[derive(Debug, Default)] - pub struct JobPortCloseProbe { - pub job_closes: AtomicUsize, - pub completion_port_closes: AtomicUsize, - } + #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] + mod job_extraction { + use super::*; + + #[derive(Debug, Default)] + pub struct JobPortCloseProbe { + pub job_closes: AtomicUsize, + pub completion_port_closes: AtomicUsize, + } - impl JobPortCloseProbe { - pub fn counts(&self) -> (usize, usize) { - ( - self.job_closes.load(Ordering::SeqCst), - self.completion_port_closes.load(Ordering::SeqCst), - ) + impl JobPortCloseProbe { + pub fn counts(&self) -> (usize, usize) { + ( + self.job_closes.load(Ordering::SeqCst), + self.completion_port_closes.load(Ordering::SeqCst), + ) + } + } + + static JOB_EXTRACTION_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DISARM_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_CUSTODIAN_START_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_CUSTODIAN_WORKER_STARTS: AtomicUsize = AtomicUsize::new(0); + static JOB_PORT_LAST_RESORT_RETENTIONS: AtomicUsize = AtomicUsize::new(0); + + thread_local! { + static JOB_PORT_CLOSE_PROBE: RefCell>> = const { RefCell::new(None) }; + } + + fn take_failure(counter: &AtomicUsize) -> bool { + counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + } + + pub fn serial_job_extraction() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn arm_job_port_close_probe() -> Arc { + let probe = Arc::new(JobPortCloseProbe::default()); + JOB_PORT_CLOSE_PROBE.with(|slot| { + assert!(slot.borrow_mut().replace(Arc::clone(&probe)).is_none()); + }); + probe + } + + pub fn current_job_port_close_probe() -> Option> { + JOB_PORT_CLOSE_PROBE.with(|slot| slot.borrow().clone()) + } + + pub fn clear_job_port_close_probe() { + JOB_PORT_CLOSE_PROBE.with(|slot| { + assert!(slot.borrow_mut().take().is_some()); + }); + } + + pub fn set_job_extraction_query_failures(failures: usize) { + JOB_EXTRACTION_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_QUERY_FAILURES) + } + + pub fn set_job_extraction_disarm_failures(failures: usize) { + JOB_EXTRACTION_DISARM_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_disarm_failure() -> bool { + take_failure(&JOB_EXTRACTION_DISARM_FAILURES) + } + + pub fn set_job_custodian_start_failures(failures: usize) { + JOB_CUSTODIAN_START_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_custodian_start_failure() -> bool { + take_failure(&JOB_CUSTODIAN_START_FAILURES) + } + + pub fn observe_job_custodian_worker_start() { + JOB_CUSTODIAN_WORKER_STARTS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_custodian_worker_starts() -> usize { + JOB_CUSTODIAN_WORKER_STARTS.load(Ordering::SeqCst) + } + + pub fn observe_job_port_last_resort_retention() { + JOB_PORT_LAST_RESORT_RETENTIONS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_port_last_resort_retentions() -> usize { + JOB_PORT_LAST_RESORT_RETENTIONS.load(Ordering::SeqCst) + } + + pub fn reset_job_extraction_faults() { + JOB_EXTRACTION_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_DISARM_FAILURES.store(0, Ordering::SeqCst); + JOB_CUSTODIAN_START_FAILURES.store(0, Ordering::SeqCst); } } - static JOB_EXTRACTION_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); - static JOB_EXTRACTION_DISARM_FAILURES: AtomicUsize = AtomicUsize::new(0); - static JOB_CUSTODIAN_START_FAILURES: AtomicUsize = AtomicUsize::new(0); - static JOB_CUSTODIAN_WORKER_STARTS: AtomicUsize = AtomicUsize::new(0); - static JOB_PORT_LAST_RESORT_RETENTIONS: AtomicUsize = AtomicUsize::new(0); + #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] + pub use job_extraction::*; thread_local! { static OWNER_FAILURE: RefCell> = const { RefCell::new(None) }; static OWNER_EVENTS: RefCell>>>> = const { RefCell::new(None) }; static EXTRA_PREPARED_OWNER: RefCell = RefCell::new(ExtraPreparedOwner::default()); - static JOB_PORT_CLOSE_PROBE: RefCell>> = const { RefCell::new(None) }; } #[derive(Default)] @@ -258,85 +348,6 @@ pub(crate) mod test_support { }) } - fn take_failure(counter: &AtomicUsize) -> bool { - counter - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { - remaining.checked_sub(1) - }) - .is_ok() - } - - pub fn serial_job_extraction() -> std::sync::MutexGuard<'static, ()> { - static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); - LOCK.get_or_init(|| std::sync::Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - pub fn arm_job_port_close_probe() -> Arc { - let probe = Arc::new(JobPortCloseProbe::default()); - JOB_PORT_CLOSE_PROBE.with(|slot| { - assert!(slot.borrow_mut().replace(Arc::clone(&probe)).is_none()); - }); - probe - } - - pub fn current_job_port_close_probe() -> Option> { - JOB_PORT_CLOSE_PROBE.with(|slot| slot.borrow().clone()) - } - - pub fn clear_job_port_close_probe() { - JOB_PORT_CLOSE_PROBE.with(|slot| { - assert!(slot.borrow_mut().take().is_some()); - }); - } - - pub fn set_job_extraction_query_failures(failures: usize) { - JOB_EXTRACTION_QUERY_FAILURES.store(failures, Ordering::SeqCst); - } - - pub fn take_job_extraction_query_failure() -> bool { - take_failure(&JOB_EXTRACTION_QUERY_FAILURES) - } - - pub fn set_job_extraction_disarm_failures(failures: usize) { - JOB_EXTRACTION_DISARM_FAILURES.store(failures, Ordering::SeqCst); - } - - pub fn take_job_extraction_disarm_failure() -> bool { - take_failure(&JOB_EXTRACTION_DISARM_FAILURES) - } - - pub fn set_job_custodian_start_failures(failures: usize) { - JOB_CUSTODIAN_START_FAILURES.store(failures, Ordering::SeqCst); - } - - pub fn take_job_custodian_start_failure() -> bool { - take_failure(&JOB_CUSTODIAN_START_FAILURES) - } - - pub fn observe_job_custodian_worker_start() { - JOB_CUSTODIAN_WORKER_STARTS.fetch_add(1, Ordering::SeqCst); - } - - pub fn job_custodian_worker_starts() -> usize { - JOB_CUSTODIAN_WORKER_STARTS.load(Ordering::SeqCst) - } - - pub fn observe_job_port_last_resort_retention() { - JOB_PORT_LAST_RESORT_RETENTIONS.fetch_add(1, Ordering::SeqCst); - } - - pub fn job_port_last_resort_retentions() -> usize { - JOB_PORT_LAST_RESORT_RETENTIONS.load(Ordering::SeqCst) - } - - pub fn reset_job_extraction_faults() { - JOB_EXTRACTION_QUERY_FAILURES.store(0, Ordering::SeqCst); - JOB_EXTRACTION_DISARM_FAILURES.store(0, Ordering::SeqCst); - JOB_CUSTODIAN_START_FAILURES.store(0, Ordering::SeqCst); - } - pub fn arm_owner_failure(failure: OwnerFailure) { OWNER_FAILURE.with(|slot| { assert!(slot.borrow_mut().replace(failure).is_none()); @@ -677,7 +688,7 @@ unsafe impl Sync for JobHandle {} pub(crate) struct JobPort { pub job: JobHandle, pub completion_port: StdOwnedHandle, - #[cfg(test)] + #[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] close_probe: Option>, } @@ -685,7 +696,7 @@ impl Drop for JobPort { fn drop(&mut self) { // SAFETY: `JobPort` solely owns this job handle. unsafe { CloseHandle(self.job.0) }.ok(); - #[cfg(test)] + #[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] if let Some(probe) = self.close_probe.as_ref() { probe .job_closes @@ -826,7 +837,7 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res Ok(JobPort { job: JobHandle(job.into_raw()), completion_port, - #[cfg(test)] + #[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] close_probe: test_support::current_job_port_close_probe(), }) } From e9f55b2bab3af37fcd4d8e1845c0dd51270ab964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 02:15:22 +1300 Subject: [PATCH 089/114] test(miri): ignore native signal seam fixtures Mark the new direct-child syscall seam tests as native-process cases so process-free Miri validation continues to model only supported state and ownership paths. Co-Authored-By: Claude Code --- src/std/core.rs | 2 ++ src/tokio/core.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/std/core.rs b/src/std/core.rs index 9345394..94fa0fc 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -860,6 +860,7 @@ mod signal_tests { use super::*; #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { let mut child = Command::new("true").spawn()?; let status = child.wait()?; @@ -877,6 +878,7 @@ mod signal_tests { } #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { let mut child = Command::new("sh").args(["-c", "sleep 30"]).spawn()?; let calls = AtomicUsize::new(0); diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 2ebd066..c855b98 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -854,6 +854,7 @@ mod signal_tests { use super::*; #[tokio::test] + #[cfg_attr(miri, ignore = "requires a native child process")] async fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { let mut child = NativeCommand::new("true").spawn()?; let status = child.wait().await?; @@ -871,6 +872,7 @@ mod signal_tests { } #[tokio::test] + #[cfg_attr(miri, ignore = "requires a native child process")] async fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { let mut child = NativeCommand::new("sh").args(["-c", "sleep 30"]).spawn()?; let calls = AtomicUsize::new(0); From 1a0450045276074dca93acae60085e7e3df2da84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 02:17:07 +1300 Subject: [PATCH 090/114] style(pty): simplify rollback kill error guard Express the ignored InvalidInput rollback case as one conditional so strict Clippy can validate the touched PTY cleanup path without a broad allowance. Co-Authored-By: Claude Code --- src/tokio/pty/unix.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index b8d5a1e..e3ba56b 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -348,10 +348,10 @@ fn terminate_and_reap(child: &Mutex) -> io::Result<()> { // Kill the direct child independently in case process-group signalling was unavailable. Holding the // only operational child lock keeps wait/kill state synchronized with every PTY child capability. - if let Err(error) = child.start_kill() { - if error.kind() != io::ErrorKind::InvalidInput { - return Err(error); - } + if let Err(error) = child.start_kill() + && error.kind() != io::ErrorKind::InvalidInput + { + return Err(error); } loop { From 7c5834123d7f16266093e9cc09e3bc7a2a59f47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 02:21:17 +1300 Subject: [PATCH 091/114] style(windows): normalize PTY validation paths Collapse executable-search branches, use captured test formatting, and document the narrow serial fault-injection lock allowance required by strict target Clippy. Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 4 ++++ src/tokio/pty/windows/program.rs | 32 ++++++++++++++++---------------- tests/tokio_pty_windows.rs | 6 ++---- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index fa188ff..75f63a4 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -722,6 +722,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly() -> Result<()> { let _serial = serial_job_extraction(); for job_first in [false, true] { @@ -741,6 +742,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn live_kill_on_drop_extraction_disarms_and_closes_before_returning() -> Result<()> { let _serial = serial_job_extraction(); for job_first in [false, true] { @@ -759,6 +761,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn extraction_failures_use_one_shared_custodian_then_close_every_port() -> Result<()> { let _serial = serial_job_extraction(); let _fault_cleanup = ExtractionFaultCleanup; @@ -801,6 +804,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn custodian_start_failure_uses_only_exact_last_resort_retention() -> Result<()> { let _serial = serial_job_extraction(); let _fault_cleanup = ExtractionFaultCleanup; diff --git a/src/tokio/pty/windows/program.rs b/src/tokio/pty/windows/program.rs index 7fa0a6d..078f243 100644 --- a/src/tokio/pty/windows/program.rs +++ b/src/tokio/pty/windows/program.rs @@ -54,10 +54,10 @@ pub(super) fn resolve<'a>( let has_extension = encoded.contains(&(b'.' as u16)); let child_path = child_path(changes)?; - if let Some(path) = child_path.as_deref() { - if let Some(program) = search_path(path, program, has_extension) { - return WideCString::from_os(program.as_os_str(), "PTY program"); - } + if let Some(path) = child_path.as_deref() + && let Some(program) = search_path(path, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); } if let Ok(mut directory) = env::current_exe() { directory.pop(); @@ -65,20 +65,20 @@ pub(super) fn resolve<'a>( return WideCString::from_os(program.as_os_str(), "PTY program"); } } - if let Ok(directory) = system_directory() { - if let Some(program) = candidate(directory, program, has_extension) { - return WideCString::from_os(program.as_os_str(), "PTY program"); - } + if let Ok(directory) = system_directory() + && let Some(program) = candidate(directory, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); } - if let Ok(directory) = windows_directory() { - if let Some(program) = candidate(directory, program, has_extension) { - return WideCString::from_os(program.as_os_str(), "PTY program"); - } + if let Ok(directory) = windows_directory() + && let Some(program) = candidate(directory, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); } - if let Some(path) = env::var_os("PATH") { - if let Some(program) = search_path(&path, program, has_extension) { - return WideCString::from_os(program.as_os_str(), "PTY program"); - } + if let Some(path) = env::var_os("PATH") + && let Some(program) = search_path(&path, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); } Err(io::Error::new( diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs index b1de1f5..2846609 100644 --- a/tests/tokio_pty_windows.rs +++ b/tests/tokio_pty_windows.rs @@ -819,8 +819,7 @@ async fn passes_bidirectional_terminal_bytes() -> io::Result<()> { bytes .windows(expected.len()) .any(|window| window == expected), - "{:?}", - bytes + "{bytes:?}" ); Ok(()) } @@ -1007,8 +1006,7 @@ async fn failed_spawn_leaves_the_tracked_command_reusable() -> io::Result<()> { .wrap(Pty::default()); let error = command .spawn() - .err() - .expect("the first spawn must fail while the executable is absent"); + .expect_err("the first spawn must fail while the executable is absent"); assert_eq!(error.kind(), io::ErrorKind::NotFound); std::fs::copy(env::current_exe()?, &executable)?; From 472313859e2d6b4dd4f24880fb9709bcc1c6b612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 02:27:49 +1300 Subject: [PATCH 092/114] test(tracing): isolate lifecycle subscriber regressions Run generic lifecycle subscriber assertions in bounded subprocesses so tracing callsite-cache updates from concurrent tests cannot suppress the targeted event. Co-Authored-By: Claude Code --- tests/spawn_provider.rs | 156 ++++++++++++++++++++++++++++++---------- 1 file changed, 118 insertions(+), 38 deletions(-) diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index a82d207..8261272 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -2457,49 +2457,82 @@ macro_rules! spawn_provider_tests { #[cfg(feature = "tracing")] #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] fn lifecycle_event_panics_roll_back_exactly_once_and_allow_reuse() { let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TRACE_IDENTITY"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for event in ["post_spawn", "wrap_child"] { + let child_value = format!("{module}:{event}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::lifecycle_event_panics_roll_back_exactly_once_and_allow_reuse" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "lifecycle tracing identity exceeded its deadline"); + assert!( + output.status.success(), + "{event} tracing identity was not preserved:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let event = if selected + .as_deref() + .expect("the subprocess selects a lifecycle event") + .ends_with(":post_spawn") + { + "post_spawn" + } else { + "wrap_child" + }; let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - for event in ["post_spawn", "wrap_child"] { - let shared = Arc::new(Shared::default()); - let identity = Arc::new(()); - let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( - event, - 1, - Arc::clone(&identity), - ); - let mut command = provider_command(Arc::clone(&shared), "provider"); + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); - let outcome = catch_unwind(AssertUnwindSafe(|| { - super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) - })); - let payload = match outcome { - Err(payload) => payload, - Ok(result) => { - panic!("the {event} lifecycle event must resume its panic: {result:?}") - } - }; - let payload = payload - .downcast::() - .expect("the exact typed subscriber payload is preserved"); - assert!(Arc::ptr_eq(&payload.0, &identity)); - assert_eq!( - shared - .events() - .iter() - .filter(|candidate| **candidate == Event::Rollback) - .count(), - 1, - "the armed transaction rolls back exactly once" - ); + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = match outcome { + Err(payload) => payload, + Ok(result) => { + panic!("the {event} lifecycle event must resume its panic: {result:?}") + } + }; + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!( + shared + .events() + .iter() + .filter(|candidate| **candidate == Event::Rollback) + .count(), + 1, + "the armed transaction rolls back exactly once" + ); - shared.clear_events(); - let child = super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) - .expect("the one-shot subscriber leaves the command reusable"); - drop(child); - assert_eq!(shared.events(), successful_events("provider")); - } + shared.clear_events(); + let child = super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the one-shot subscriber leaves the command reusable"); + drop(child); + assert_eq!(shared.events(), successful_events("provider")); } #[cfg(feature = "tracing")] @@ -4699,9 +4732,56 @@ macro_rules! real_provider_tests { #[cfg_attr(miri, ignore = "requires native child processes")] fn lifecycle_event_panic_rolls_back_and_reaps_a_live_child() { let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_REAL_PROVIDER_TRACE_IDENTITY"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected + .as_deref() + .is_none_or(|value| !value.starts_with(module)) + { + for event in ["post_spawn", "wrap_child"] { + let child_value = format!("{module}:{event}"); + let mut process = + std::process::Command::new(std::env::current_exe().unwrap()); + process + .args([ + "--exact", + concat!( + stringify!($module), + "::lifecycle_event_panic_rolls_back_and_reaps_a_live_child" + ), + "--nocapture", + ]) + .env(CHILD_ENV, &child_value); + let (output, timed_out) = + super::bounded_process::run(process, EXIT_TIMEOUT, None) + .expect("run isolated real-process lifecycle regression"); + assert!( + !timed_out, + "real-process tracing cleanup exceeded its deadline" + ); + assert!( + output.status.success(), + "{event} real-process tracing cleanup failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let event = if selected + .as_deref() + .expect("the subprocess selects a lifecycle event") + .ends_with(":post_spawn") + { + "post_spawn" + } else { + "wrap_child" + }; let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - for event in ["post_spawn", "wrap_child"] { + { let directory = tempfile::tempdir().unwrap(); let paths = MarkerPaths::new(directory.path()); let shared = Arc::new(Shared::default()); From d3d2ebf9ac583f0d69ad55127e4ce4ceb562f084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 03:13:14 +1300 Subject: [PATCH 093/114] test(windows): prove prepared reader publication ordering Require spawn success before active-reader release and place active plus upgraded queued readers through the real native/provider lifecycle with bounded close, rollback, disposal, and reuse evidence. Co-Authored-By: Claude Code --- src/generic_wrap.rs | 715 ++++++++++++++++++++++++++++++++++++++++ tests/spawn_provider.rs | 27 +- 2 files changed, 733 insertions(+), 9 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 53b89a7..bd1eca5 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -885,6 +885,721 @@ macro_rules! Wrap { assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); } + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationControl { + placed_tx: ::std::sync::mpsc::Sender<()>, + placed_rx: ::std::sync::Mutex<::std::sync::mpsc::Receiver<()>>, + closing_tx: ::std::sync::Mutex>>, + closing_rx: ::std::sync::Mutex<::std::sync::mpsc::Receiver<()>>, + active_release: ::std::sync::Mutex>>, + queued_release: ::std::sync::Mutex>>, + closing_release: ::std::sync::Mutex>>, + active_result: ::std::sync::Mutex>>, + queued_result: ::std::sync::Mutex>>, + workers: ::std::sync::Mutex>>, + queued_callbacks: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, + retained_token: ::std::sync::Mutex>>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationControl { + const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); + + fn new() -> ::std::sync::Arc { + let (placed_tx, placed_rx) = ::std::sync::mpsc::channel(); + let (closing_tx, closing_rx) = ::std::sync::mpsc::channel(); + ::std::sync::Arc::new(Self { + placed_tx, + placed_rx: ::std::sync::Mutex::new(placed_rx), + closing_tx: ::std::sync::Mutex::new(Some(closing_tx)), + closing_rx: ::std::sync::Mutex::new(closing_rx), + active_release: ::std::sync::Mutex::new(None), + queued_release: ::std::sync::Mutex::new(None), + closing_release: ::std::sync::Mutex::new(None), + active_result: ::std::sync::Mutex::new(None), + queued_result: ::std::sync::Mutex::new(None), + workers: ::std::sync::Mutex::new(Vec::new()), + queued_callbacks: ::std::sync::Arc::new( + ::std::sync::atomic::AtomicUsize::new(0), + ), + retained_token: ::std::sync::Mutex::new(None), + }) + } + + fn start(&self, token: PreparedChild) { + let state = token + .state + .upgrade() + .expect("the private owner remains live during wrapping"); + let token = ::std::sync::Arc::new(token); + let (active_entered_tx, active_entered_rx) = ::std::sync::mpsc::channel(); + let (active_release_tx, active_release_rx) = ::std::sync::mpsc::channel(); + let (active_result_tx, active_result_rx) = ::std::sync::mpsc::channel(); + let active_token = ::std::sync::Arc::clone(&token); + let active = ::std::thread::spawn(move || { + let result = active_token.with::(|_| { + let _ = active_entered_tx.send(()); + active_release_rx.recv().is_ok() + }) == Some(true); + let _ = active_result_tx.send(result); + }); + active_entered_rx + .recv_timeout(Self::TIMEOUT) + .expect("reader A owns the prepared value before wrapping returns"); + + let (queued_reached_tx, queued_reached_rx) = ::std::sync::mpsc::channel(); + let (queued_release_tx, queued_release_rx) = ::std::sync::mpsc::channel(); + *state + .value_lock_attempt + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: queued_reached_tx, + release: queued_release_rx, + }); + let (queued_result_tx, queued_result_rx) = ::std::sync::mpsc::channel(); + let queued_token = ::std::sync::Arc::clone(&token); + let queued_callbacks = ::std::sync::Arc::clone(&self.queued_callbacks); + let queued = ::std::thread::spawn(move || { + let accessed = queued_token + .with::(|_| { + queued_callbacks + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + }) + .is_some(); + let _ = queued_result_tx.send(accessed); + }); + queued_reached_rx + .recv_timeout(Self::TIMEOUT) + .expect("reader B upgraded and attempted value while A owned it"); + + let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); + let closing_tx = self + .closing_tx + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + .expect("the publication fixture arms closing once"); + *state + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: closing_tx, + release: closing_release_rx, + }); + + *self + .active_release + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(active_release_tx); + *self + .queued_release + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(queued_release_tx); + *self + .closing_release + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(closing_release_tx); + *self + .active_result + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(active_result_rx); + *self + .queued_result + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(queued_result_rx); + self.workers + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .extend([active, queued]); + *self + .retained_token + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(token); + self.placed_tx + .send(()) + .expect("the test observes both publication readers"); + } + + fn wait_placed(&self) { + self.placed_rx + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .recv_timeout(Self::TIMEOUT) + .expect("the later wrapper placed active and queued readers"); + } + + fn wait_closing(&self) { + self.closing_rx + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .recv_timeout(Self::TIMEOUT) + .expect("the real lifecycle path published prepared closing"); + } + + fn release(slot: &::std::sync::Mutex>>) { + if let Some(release) = slot + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + } + + fn release_queued(&self) { + Self::release(&self.queued_release); + } + + fn release_active(&self) { + Self::release(&self.active_release); + } + + fn release_closing(&self) { + Self::release(&self.closing_release); + } + + fn release_all(&self) { + self.release_queued(); + self.release_active(); + self.release_closing(); + } + + fn collect_readers(&self) -> (bool, bool) { + let active = self + .active_result + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + .expect("reader A retains its result") + .recv_timeout(Self::TIMEOUT) + .expect("reader A completes after release"); + let queued = self + .queued_result + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + .expect("reader B retains its result") + .recv_timeout(Self::TIMEOUT) + .expect("reader B completes after closing"); + for worker in ::std::mem::take( + &mut *self + .workers + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner), + ) { + worker.join().expect("prepared publication reader does not panic"); + } + (active, queued) + } + + fn finish_readers(&self) { + let (active, queued) = self.collect_readers(); + assert!(active, "reader A completes its finite admitted callback"); + assert!(!queued, "reader B is rejected after closing publication"); + assert_eq!( + self.queued_callbacks + .load(::std::sync::atomic::Ordering::SeqCst), + 0 + ); + } + + fn assert_closed(&self) { + let token = self + .retained_token + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + assert!( + token + .as_ref() + .expect("the fixture retains its weak token") + .with::(|_| ()) + .is_none(), + "the retained token cannot access state after close" + ); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationControl { + fn drop(&mut self) { + if let Some(release) = self + .queued_release + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + if let Some(release) = self + .active_release + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + if let Some(release) = self + .closing_release + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + for worker in ::std::mem::take( + self.workers + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner), + ) { + if let Err(payload) = worker.join() { + ::std::mem::forget(payload); + } + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationLayer { + inner: Option>, + prepared: Option, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl ChildWrapperLayer for PreparedPublicationLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl $childer for PreparedPublicationLayer { + fn inner(&self) -> &dyn $childer { + self.inner + .as_deref() + .expect("the publication layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self.inner + .as_deref_mut() + .expect("the publication layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("the publication layer owns its child") + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationProducer { + drops: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationProducer { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn $childer, + _command: &Command, + ) -> ::std::io::Result>> { + Ok(Some(Box::new(PreparedRaceDrop(::std::sync::Arc::clone( + &self.drops, + ))))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn $childer, + prepared: Option>, + _command: &Command, + ) -> ::std::io::Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::)); + Ok(Some(PendingChildWrapper::new(PreparedPublicationLayer { + inner: None, + prepared: None, + }))) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationReaders { + start_once: bool, + control: ::std::sync::Arc, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationReaders { + fn wrap_prepared_child( + &mut self, + child: &mut dyn $childer, + prepared: Option>, + _command: &Command, + ) -> ::std::io::Result> { + assert!(prepared.is_none()); + if self.start_once { + self.start_once = false; + let layer = (child as &mut dyn ::std::any::Any) + .downcast_mut::() + .expect("the earlier publication layer is directly reachable"); + let token = layer + .prepared + .take() + .expect("the earlier layer contains its installed weak token"); + self.control.start(token); + } + Ok(None) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationFailure(bool); + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationFailure { + fn wrap_child( + &mut self, + _child: &mut dyn $childer, + _command: &Command, + ) -> ::std::io::Result> { + if ::std::mem::take(&mut self.0) { + Err(::std::io::Error::other("later publication wrapping failed")) + } else { + Ok(None) + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationChild; + + #[cfg(all(test, windows, feature = "job-object"))] + impl $childer for PreparedPublicationChild { + fn inner(&self) -> &dyn $childer { + self + } + fn inner_mut(&mut self) -> &mut dyn $childer { + self + } + fn into_inner(self: Box) -> Box { + self + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug, Default)] + struct PreparedPublicationTransactionState { + commits: ::std::sync::atomic::AtomicUsize, + rollbacks: ::std::sync::atomic::AtomicUsize, + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationTransaction( + ::std::sync::Arc, + ); + + #[cfg(all(test, windows, feature = "job-object"))] + impl crate::SpawnTransaction for PreparedPublicationTransaction { + fn commit(&mut self) -> ::std::io::Result<()> { + self.0 + .commits + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + fn rollback(&mut self) -> ::std::io::Result<()> { + self.0 + .rollbacks + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationProvider { + state: ::std::sync::Arc, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl SpawnProvider for PreparedPublicationProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> ::std::io::Result { + Ok(ProviderProduct::new( + Box::new(PreparedPublicationChild), + Box::new(PreparedPublicationTransaction(::std::sync::Arc::clone( + &self.state, + ))), + )) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationProviderWrapper(PreparedPublicationProvider); + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedPublicationThreadCleanup { + control: ::std::sync::Arc, + workers: Vec<::std::thread::JoinHandle<()>>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationThreadCleanup { + fn new(control: ::std::sync::Arc) -> Self { + Self { + control, + workers: Vec::new(), + } + } + fn push(&mut self, worker: ::std::thread::JoinHandle<()>) { + self.workers.push(worker); + } + fn finish(mut self) { + self.control.release_all(); + for worker in ::std::mem::take(&mut self.workers) { + worker.join().expect("publication lifecycle worker does not panic"); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationThreadCleanup { + fn drop(&mut self) { + self.control.release_all(); + for worker in ::std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + ::std::mem::forget(payload); + } + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn prepared_publication_command( + provider: bool, + fail_later: bool, + control: ::std::sync::Arc, + drops: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, + transaction: ::std::sync::Arc, + ) -> Command { + let mut command = if provider { + Command::new("provider-owned-program") + } else { + Command::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }) + }; + if provider { + command.wrap(PreparedPublicationProviderWrapper( + PreparedPublicationProvider { state: transaction }, + )); + } + command + .wrap(PreparedPublicationProducer { drops }) + .wrap(PreparedPublicationReaders { + start_once: true, + control, + }); + if fail_later { + command.wrap(PreparedPublicationFailure(true)); + } + command + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn run_prepared_publication_spawn( + mut command: Command, + ) -> (Command, ::std::io::Result>) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build prepared-publication runtime"); + let _runtime_guard = runtime.enter(); + let result = command.spawn(); + (command, result) + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_published_active_and_queued_readers_allow_spawn_return(provider: bool) { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let transaction = ::std::sync::Arc::new(PreparedPublicationTransactionState::default()); + let control = PreparedPublicationControl::new(); + let command = prepared_publication_command( + provider, + false, + ::std::sync::Arc::clone(&control), + ::std::sync::Arc::clone(&drops), + ::std::sync::Arc::clone(&transaction), + ); + let (spawned_tx, spawned_rx) = ::std::sync::mpsc::channel(); + let mut cleanup = PreparedPublicationThreadCleanup::new(::std::sync::Arc::clone( + &control, + )); + cleanup.push(::std::thread::spawn(move || { + let _ = spawned_tx.send(run_prepared_publication_spawn(command)); + })); + control.wait_placed(); + + let first = spawned_rx.recv_timeout(::std::time::Duration::from_secs(2)); + let returned_success_while_held = matches!(&first, Ok((_, Ok(_)))); + if !returned_success_while_held { + control.release_all(); + let outcome = match first { + Ok(outcome) => outcome, + Err(::std::sync::mpsc::RecvTimeoutError::Timeout) => spawned_rx + .recv_timeout(PreparedPublicationControl::TIMEOUT) + .expect("spawn returns after emergency publication-reader release"), + Err(::std::sync::mpsc::RecvTimeoutError::Disconnected) => { + cleanup.finish(); + panic!("the publication spawn worker disconnected") + } + }; + let _ = control.collect_readers(); + drop(outcome); + cleanup.finish(); + panic!("public spawn did not return while active and queued readers remained held"); + } + + let (mut command, result) = first.expect("successful first publication outcome"); + let child = result.expect("published readers cannot reject the successful spawn"); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 0); + let (closed_tx, closed_rx) = ::std::sync::mpsc::channel(); + cleanup.push(::std::thread::spawn(move || { + drop(child); + let _ = closed_tx.send(()); + })); + control.wait_closing(); + assert_eq!( + closed_rx.try_recv(), + Err(::std::sync::mpsc::TryRecvError::Empty) + ); + control.release_queued(); + control.release_active(); + control.finish_readers(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 0); + control.release_closing(); + closed_rx + .recv_timeout(PreparedPublicationControl::TIMEOUT) + .expect("returned-child close completes after reader release"); + control.assert_closed(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + + let (returned_command, reused) = run_prepared_publication_spawn(command); + command = returned_command; + drop(reused.expect("the command remains reusable")); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 2); + if provider { + assert_eq!(transaction.commits.load(::std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(transaction.rollbacks.load(::std::sync::atomic::Ordering::SeqCst), 0); + } + drop(command); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_later_failure_rejects_published_queued_reader(provider: bool) { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let transaction = ::std::sync::Arc::new(PreparedPublicationTransactionState::default()); + let control = PreparedPublicationControl::new(); + let command = prepared_publication_command( + provider, + true, + ::std::sync::Arc::clone(&control), + ::std::sync::Arc::clone(&drops), + ::std::sync::Arc::clone(&transaction), + ); + let (finished_tx, finished_rx) = ::std::sync::mpsc::channel(); + let mut cleanup = PreparedPublicationThreadCleanup::new(::std::sync::Arc::clone( + &control, + )); + cleanup.push(::std::thread::spawn(move || { + let _ = finished_tx.send(run_prepared_publication_spawn(command)); + })); + control.wait_placed(); + control.wait_closing(); + let early = finished_rx.try_recv(); + let remained_pending = matches!( + &early, + Err(::std::sync::mpsc::TryRecvError::Empty) + ); + control.release_queued(); + control.release_active(); + control.finish_readers(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 0); + control.release_closing(); + let (mut command, result) = match early { + Ok(outcome) => outcome, + Err(::std::sync::mpsc::TryRecvError::Empty) => finished_rx + .recv_timeout(PreparedPublicationControl::TIMEOUT) + .expect("failure cleanup completes after reader release"), + Err(::std::sync::mpsc::TryRecvError::Disconnected) => { + cleanup.finish(); + panic!("the publication spawn worker disconnected") + } + }; + assert!( + remained_pending, + "failure cleanup returned while A owned the prepared value" + ); + let error = result.expect_err("the later wrapping callback fails"); + assert_eq!(error.to_string(), "later publication wrapping failed"); + control.assert_closed(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + if provider { + assert_eq!(transaction.commits.load(::std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(transaction.rollbacks.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + + let (returned_command, reused) = run_prepared_publication_spawn(command); + command = returned_command; + drop(reused.expect("the command remains reusable")); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 2); + if provider { + assert_eq!(transaction.commits.load(::std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(transaction.rollbacks.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + drop(command); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn published_active_and_queued_readers_allow_native_and_provider_spawn_return() { + for provider in [false, true] { + assert_published_active_and_queued_readers_allow_spawn_return(provider); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn later_failure_rejects_published_queued_reader_for_native_and_provider() { + for provider in [false, true] { + assert_later_failure_rejects_published_queued_reader(provider); + } + } + #[cfg(windows)] struct PreparedChildSlot<'a> { value: &'a mut Option, diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 8261272..9324118 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -3123,21 +3123,30 @@ macro_rules! spawn_provider_tests { entered .recv_timeout(EXIT_TIMEOUT) .expect("the later wrapper starts an admitted prepared reader"); - let outcome = observe_spawn.recv_timeout(Duration::from_secs(2)); - if outcome.is_err() { - let _ = reader.finish(); - } - let (mut command, result) = outcome.unwrap_or_else(|_| { - observe_spawn + let first = observe_spawn.recv_timeout(Duration::from_secs(2)); + let returned_success_while_held = matches!(&first, Ok((_, Ok(_)))); + let reader_completed = reader.finish(); + let outcome = match first { + Ok(outcome) => outcome, + Err(mpsc::RecvTimeoutError::Timeout) => observe_spawn .recv_timeout(EXIT_TIMEOUT) - .expect("spawn returns after emergency reader release") - }); + .expect("spawn returns after emergency reader release"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = spawn_worker.join(); + panic!("the spawn worker disconnected before reporting its result") + } + }; spawn_worker.join().expect("the spawn worker completes"); + assert!(reader_completed, "the admitted prepared callback completes"); + assert!( + returned_success_while_held, + "public spawn did not return successfully before the active reader was released" + ); + let (mut command, result) = outcome; let child = result.expect( "a sanctioned prepared reader may remain active across completed wrapping", ); assert_eq!(drops.load(Ordering::SeqCst), 0); - assert!(reader.finish(), "the admitted prepared callback completes"); drop(child); assert_eq!(drops.load(Ordering::SeqCst), 1); From a9ccd1a2b5bca6b684484897df07740f319410fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 03:20:59 +1300 Subject: [PATCH 094/114] test(unix): make shared signalling proof deterministic Use explicit terminal release and bounded observable wait/signal workers under one lower-child mutex, and guard every native syscall-seam child with unconditional kill/reap cleanup. Co-Authored-By: Claude Code --- src/std/core.rs | 51 ++++++-- src/tokio/core.rs | 69 +++++++++-- tests/process_identity.rs | 238 ++++++++++++++++++++++++++++++++++---- 3 files changed, 317 insertions(+), 41 deletions(-) diff --git a/src/std/core.rs b/src/std/core.rs index 94fa0fc..7b184be 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -859,39 +859,74 @@ mod signal_tests { use super::*; + struct ChildGuard(Option); + + impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("an armed test guard owns its child") + } + + fn finish(mut self) -> Result<()> { + let child = self.child_mut(); + match child.kill() { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let _ = child.wait()?; + self.0.take(); + Ok(()) + } + } + + impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + #[test] #[cfg_attr(miri, ignore = "requires a native child process")] fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { - let mut child = Command::new("true").spawn()?; - let status = child.wait()?; + let child = Command::new("true").spawn()?; + let mut child = ChildGuard::new(child); + let status = child.child_mut().wait()?; let calls = AtomicUsize::new(0); - let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { calls.fetch_add(1, Ordering::SeqCst); Ok(()) })?; assert_eq!(observed, Some(status)); assert_eq!(calls.load(Ordering::SeqCst), 0); - assert_eq!(child.wait()?, status); + assert_eq!(child.child_mut().wait()?, status); + child.finish()?; Ok(()) } #[test] #[cfg_attr(miri, ignore = "requires a native child process")] fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { - let mut child = Command::new("sh").args(["-c", "sleep 30"]).spawn()?; + let child = Command::new("sh").args(["-c", "sleep 30"]).spawn()?; + let mut child = ChildGuard::new(child); let calls = AtomicUsize::new(0); - let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { calls.fetch_add(1, Ordering::SeqCst); Ok(()) })?; assert_eq!(observed, None); assert_eq!(calls.load(Ordering::SeqCst), 1); - child.kill()?; - let _ = child.wait()?; + child.finish()?; Ok(()) } } diff --git a/src/tokio/core.rs b/src/tokio/core.rs index c855b98..a38d745 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -849,42 +849,95 @@ impl dyn ChildWrapper + '_ { #[cfg(all(test, unix))] mod signal_tests { - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, Instant}, + }; use super::*; + struct ChildGuard(Option); + + impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("an armed test guard owns its child") + } + + fn cleanup(&mut self) -> Result<()> { + let child = self.child_mut(); + match child.start_kill() { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.try_wait()?.is_some() { + self.0.take(); + return Ok(()); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Tokio signal seam child was not reaped", + )); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn finish(mut self) -> Result<()> { + self.cleanup() + } + } + + impl Drop for ChildGuard { + fn drop(&mut self) { + if self.0.is_some() { + let _ = self.cleanup(); + } + } + } + #[tokio::test] #[cfg_attr(miri, ignore = "requires a native child process")] async fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { - let mut child = NativeCommand::new("true").spawn()?; - let status = child.wait().await?; + let child = NativeCommand::new("true").spawn()?; + let mut child = ChildGuard::new(child); + let status = child.child_mut().wait().await?; let calls = AtomicUsize::new(0); - let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { calls.fetch_add(1, Ordering::SeqCst); Ok(()) })?; assert_eq!(observed, Some(status)); assert_eq!(calls.load(Ordering::SeqCst), 0); - assert_eq!(child.wait().await?, status); + assert_eq!(child.child_mut().wait().await?, status); + child.finish()?; Ok(()) } #[tokio::test] #[cfg_attr(miri, ignore = "requires a native child process")] async fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { - let mut child = NativeCommand::new("sh").args(["-c", "sleep 30"]).spawn()?; + let child = NativeCommand::new("sh").args(["-c", "sleep 30"]).spawn()?; + let mut child = ChildGuard::new(child); let calls = AtomicUsize::new(0); - let observed = signal_child_if_running_with(&mut child, Signal::SIGCONT, |_, _| { + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { calls.fetch_add(1, Ordering::SeqCst); Ok(()) })?; assert_eq!(observed, None); assert_eq!(calls.load(Ordering::SeqCst), 1); - child.kill().await?; + child.finish()?; Ok(()) } } diff --git a/tests/process_identity.rs b/tests/process_identity.rs index bc3cf25..9618fab 100644 --- a/tests/process_identity.rs +++ b/tests/process_identity.rs @@ -3,6 +3,7 @@ use std::{ future::Future, io, + os::unix::process::ExitStatusExt, pin::Pin, process::ExitStatus, sync::{ @@ -303,40 +304,227 @@ async fn sticky_custom_id_does_not_authorize_a_signal_after_wait() { assert_eq!(child.try_wait().unwrap(), Some(status)); } +#[derive(Debug)] +struct SyntheticSharedState { + status: Mutex>, + wait_acquired: Mutex>>, + completion: Mutex>>, + group_signals: AtomicUsize, +} + +impl SyntheticSharedState { + fn wait_under_custody(&self) -> io::Result { + let mut status = self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(status) = *status { + return Ok(status); + } + if let Some(acquired) = self + .wait_acquired + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = acquired.send(()); + } + let completion = self + .completion + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| { + io::Error::other("synthetic completion receiver was already consumed") + })?; + let completed = completion.recv_timeout(EXIT_TIMEOUT).map_err(|error| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("synthetic child completion was not released: {error}"), + ) + })?; + *status = Some(completed); + Ok(completed) + } +} + +#[derive(Debug)] +struct SyntheticSharedChild { + state: Arc, + spawned_id: u32, +} + +impl ChildWrapper for SyntheticSharedChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn try_clone(&self) -> Option> { + Some(Box::new(Self { + state: Arc::clone(&self.state), + spawned_id: self.spawned_id, + })) + } + + fn id(&self) -> Option { + Some(self.spawned_id) + } + + fn spawned_id_layer(&self) -> Option { + Some(self.spawned_id) + } + + fn has_process_group_signal_layer(&self) -> bool { + true + } + + fn signal_process_group_layer( + &mut self, + _process_group: i32, + _signal: i32, + ) -> Option>> { + let status = self + .state + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(status) = *status { + Some(Ok(Some(status))) + } else { + self.state.group_signals.fetch_add(1, Ordering::SeqCst); + Some(Ok(None)) + } + } + + fn try_wait(&mut self) -> io::Result> { + Ok(*self + .state + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner)) + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + let state = Arc::clone(&self.state); + Box::pin(async move { state.wait_under_custody() }) + } +} + +struct SharedSignalCleanup { + completion: Option>, + status: ExitStatus, + workers: Vec>, +} + +impl SharedSignalCleanup { + fn release(&mut self) { + if let Some(completion) = self.completion.take() { + let _ = completion.send(self.status); + } + } + + fn finish(mut self) { + self.release(); + for worker in std::mem::take(&mut self.workers) { + worker.join().expect("shared-child worker does not panic"); + } + } +} + +impl Drop for SharedSignalCleanup { + fn drop(&mut self) { + self.release(); + for worker in std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + std::mem::forget(payload); + } + } + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shared_wait_and_group_signal_linearize_under_child_custody() { - let published = Arc::new(Mutex::new(None)); - let mut command = Command::with_new("sh", |command| { - command.args(["-c", "sleep 0.2; exit 37"]); + let status = ExitStatus::from_raw(37 << 8); + let (wait_acquired_tx, wait_acquired_rx) = mpsc::channel(); + let (completion_tx, completion_rx) = mpsc::channel(); + let state = Arc::new(SyntheticSharedState { + status: Mutex::new(None), + wait_acquired: Mutex::new(Some(wait_acquired_tx)), + completion: Mutex::new(Some(completion_rx)), + group_signals: AtomicUsize::new(0), }); + let mut command = Command::new("synthetic-child"); command.wrap(ProcessGroup::leader()); let mut child = command .spawn_with_child({ - let published = Arc::clone(&published); - move |native| Ok(sticky_child(native.spawn()?, &published)) + let state = Arc::clone(&state); + move |_| { + Ok(Box::new(SyntheticSharedChild { + state, + spawned_id: 42, + }) as Box) + } }) .unwrap(); - let state = published - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - .unwrap(); - let waiter_state = Arc::clone(&state); - let (locked, observe_locked) = mpsc::channel(); - let runtime = tokio::runtime::Handle::current(); - let waiter = tokio::task::spawn_blocking(move || { - let mut child = waiter_state - .child - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - locked.send(()).unwrap(); - runtime.block_on(child.wait()) + let mut lower = child + .inner() + .try_clone() + .expect("the shared custom child exposes a clone"); + let (waited_tx, waited_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("build synthetic waiter runtime"); + let result = runtime.block_on(lower.wait()); + let _ = waited_tx.send(result); }); - observe_locked.recv_timeout(EXIT_TIMEOUT).unwrap(); - - child.signal(nix::libc::SIGCONT).unwrap(); - let status = waiter.await.unwrap().unwrap(); - assert_eq!(status.code(), Some(37)); + let mut cleanup = SharedSignalCleanup { + completion: Some(completion_tx), + status, + workers: vec![waiter], + }; + wait_acquired_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("wait owns lower-child custody before signal starts"); + + let (signaled_tx, signaled_rx) = mpsc::channel(); + cleanup.workers.push(std::thread::spawn(move || { + let result = child.signal(nix::libc::SIGCONT); + let _ = signaled_tx.send((child, result)); + })); + let early_signal = signaled_rx.recv_timeout(Duration::from_millis(100)); + let signal_was_pending = matches!(&early_signal, Err(mpsc::RecvTimeoutError::Timeout)); + cleanup.release(); + let waited = waited_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("wait completes after explicit terminal release") + .expect("synthetic wait succeeds"); + let (mut child, signal_result) = match early_signal { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => signaled_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("signal completes after wait releases custody"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + cleanup.finish(); + panic!("signal worker disconnected without a result") + } + }; + cleanup.finish(); + + assert!( + signal_was_pending, + "signal did not block behind wait custody" + ); + signal_result.unwrap(); + assert_eq!(waited, status); assert_eq!(state.group_signals.load(Ordering::SeqCst), 0); assert_eq!(child.wait().await.unwrap(), status); assert_eq!(child.try_wait().unwrap(), Some(status)); From 27a6cca870bb7d9c70286cb972fbbf53b11efc54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 03:35:28 +1300 Subject: [PATCH 095/114] test(windows): prove JobObject drain resource closure Attribute complete handle closure to authoritative drain under persistent disarm failure with a live tree, and sample repeated drained extraction through native process handle counts. Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 247 ++++++++++++++++++++++++++++++++++++++-- src/windows.rs | 50 +++++++- 2 files changed, 287 insertions(+), 10 deletions(-) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 75f63a4..a5af514 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -429,15 +429,20 @@ impl ChildWrapper for JobObjectChild { #[cfg(test)] mod tests { use std::{ + fs, os::windows::{io::BorrowedHandle, process::CommandExt}, panic::{AssertUnwindSafe, catch_unwind, panic_any}, + path::{Path, PathBuf}, + process::{Command as StdCommand, Stdio}, sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, }; - use windows::Win32::System::Threading::CREATE_SUSPENDED; + use windows::Win32::System::Threading::{ + CREATE_SUSPENDED, GetCurrentProcess, GetProcessHandleCount, + }; use crate::tokio::{ProviderProduct, SpawnProvider}; use crate::windows::{ @@ -449,11 +454,11 @@ mod tests { arm_spawn_cleanup_handle_probe, assert_tree_terminated, clear_extra_prepared_owners, clear_job_port_close_probe, clear_owner_events, clear_owner_failure, finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, - job_custodian_worker_starts, job_port_last_resort_retentions, observe_descendant, - publish_process_guards, record_owner_event, reset_job_extraction_faults, - serial_job_extraction, set_job_custodian_start_failures, - set_job_extraction_disarm_failures, set_job_extraction_query_failures, - spawn_cleanup_handle_close_count, + job_custodian_worker_starts, job_extraction_branch_counts, + job_port_last_resort_retentions, observe_descendant, publish_process_guards, + record_owner_event, reset_job_extraction_faults, serial_job_extraction, + set_job_custodian_start_failures, set_job_extraction_disarm_failures, + set_job_extraction_query_failures, spawn_cleanup_handle_close_count, }, }; @@ -721,12 +726,165 @@ mod tests { } } + const EXTRACTION_HANDLE_COUNT_CHILD: &str = "PROCESS_WRAP_EXTRACTION_HANDLE_COUNT_CHILD"; + const EXTRACTION_DESCENDANT_PID: &str = "PROCESS_WRAP_EXTRACTION_DESCENDANT_PID"; + const EXTRACTION_DESCENDANT_RELEASE: &str = "PROCESS_WRAP_EXTRACTION_DESCENDANT_RELEASE"; + + struct BoundedTestChild(Option); + + impl BoundedTestChild { + fn wait(mut self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = self + .0 + .as_mut() + .expect("an armed test subprocess guard owns its child") + .try_wait()? + { + self.0.take(); + return Ok(status); + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "isolated handle-count regression exceeded its deadline", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + } + + impl Drop for BoundedTestChild { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + fn current_process_handle_count() -> Result { + let mut count = 0; + // SAFETY: GetCurrentProcess returns the current-process pseudo-handle, and `count` is writable + // storage for the duration of GetProcessHandleCount. + unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count) }.map_err(Error::other)?; + Ok(count) + } + + fn wait_for_pid_file(path: &Path) -> Result { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(pid) = + fs::read_to_string(path).and_then(|pid| pid.trim().parse().map_err(Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "extraction descendant did not report its PID", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + #[ignore = "subprocess helper"] + fn extraction_custody_descendant_helper() -> Result<()> { + let release = PathBuf::from( + std::env::var_os(EXTRACTION_DESCENDANT_RELEASE) + .ok_or_else(|| Error::other("extraction release path is missing"))?, + ); + let deadline = Instant::now() + Duration::from_secs(30); + while !release.exists() { + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "extraction descendant was not released", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(()) + } + + #[test] + #[ignore = "subprocess helper"] + fn extraction_custody_direct_helper() -> Result<()> { + let pid_file = PathBuf::from( + std::env::var_os(EXTRACTION_DESCENDANT_PID) + .ok_or_else(|| Error::other("extraction descendant PID path is missing"))?, + ); + let release = std::env::var_os(EXTRACTION_DESCENDANT_RELEASE) + .ok_or_else(|| Error::other("extraction release path is missing"))?; + let mut descendant = StdCommand::new(std::env::current_exe()?) + .args([ + "--exact", + "tokio::job_object::tests::extraction_custody_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env(EXTRACTION_DESCENDANT_RELEASE, release) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + fs::write(pid_file, descendant.id().to_string())?; + let _ = descendant.wait()?; + Ok(()) + } + + fn extraction_tree_command(pid_file: &Path, release: &Path) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args([ + "--exact", + "tokio::job_object::tests::extraction_custody_direct_helper", + "--ignored", + "--nocapture", + ]) + .env(EXTRACTION_DESCENDANT_PID, pid_file) + .env(EXTRACTION_DESCENDANT_RELEASE, release) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) + } + + fn run_handle_count_regression_in_subprocess() -> Result<()> { + let mut command = StdCommand::new(std::env::current_exe()?); + command + .args([ + "--exact", + "tokio::job_object::tests::drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly", + "--nocapture", + ]) + .env(EXTRACTION_HANDLE_COUNT_CHILD, "1") + .stdin(Stdio::null()); + let child = command.spawn()?; + let status = BoundedTestChild(Some(child)).wait(Duration::from_secs(60))?; + if status.success() { + Ok(()) + } else { + Err(Error::other(format!( + "isolated handle-count regression exited with {status}" + ))) + } + } + #[tokio::test(flavor = "current_thread")] #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly() -> Result<()> { + if std::env::var_os(EXTRACTION_HANDLE_COUNT_CHILD).is_none() { + return run_handle_count_regression_in_subprocess(); + } let _serial = serial_job_extraction(); + let before = current_process_handle_count()?; for job_first in [false, true] { - for _ in 0..8 { + for _ in 0..32 { let probe = arm_job_port_close_probe(); let mut command = extraction_command(true, job_first); let mut child = command.spawn()?; @@ -738,6 +896,11 @@ mod tests { assert_eq!(lower.try_wait()?, Some(status)); } } + let after = current_process_handle_count()?; + assert!( + after <= before.saturating_add(8), + "repeated drained extraction grew process handles from {before} to {after}" + ); Ok(()) } @@ -803,6 +966,76 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn persistent_disarm_failure_closes_only_after_authoritative_tree_drain() -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release = directory.path().join("release-descendant"); + let baseline = job_extraction_branch_counts(); + let worker_starts = job_custodian_worker_starts(); + set_job_extraction_query_failures(usize::MAX); + set_job_extraction_disarm_failures(usize::MAX); + let probe = arm_job_port_close_probe(); + let mut command = extraction_tree_command(&pid_file, &release)?; + command.wrap(JobObject).wrap(KillOnDrop); + let child = command.spawn()?; + clear_job_port_close_probe(); + let direct_pid = child.id().expect("the direct child retains its PID"); + let mut direct = ProcessGuard::open(direct_pid)?; + let mut descendant = ProcessGuard::open(wait_for_pid_file(&pid_file)?)?; + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (0, 0)); + assert!( + !direct.has_exited()?, + "retained custody preserves the direct child" + ); + assert!( + !descendant.has_exited()?, + "retained custody preserves the descendant" + ); + let deadline = Instant::now() + Duration::from_secs(5); + while job_custodian_worker_starts() == worker_starts { + assert!( + Instant::now() < deadline, + "the shared custodian did not start" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let unresolved = job_extraction_branch_counts(); + assert!(unresolved.2 > baseline.2, "custody retried native disarm"); + assert_eq!( + unresolved.3, baseline.3, + "no extracted-job disarm may succeed in this branch" + ); + assert_eq!(unresolved.1, baseline.1, "the live tree has not drained"); + + set_job_extraction_query_failures(0); + fs::File::create(&release)?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + direct.wait_for_exit("the extracted direct child")?; + descendant.wait_for_exit("the extracted descendant")?; + wait_for_job_port_closes(&probe, (1, 1)); + let drained = job_extraction_branch_counts(); + assert!( + drained.0 > baseline.0, + "authoritative accounting was queried" + ); + assert!( + drained.1 > baseline.1, + "authoritative accounting observed drain" + ); + assert!(drained.2 > baseline.2, "native disarm remained attempted"); + assert_eq!( + drained.3, baseline.3, + "complete closure must not be attributed to disarm success" + ); + Ok(()) + } + #[tokio::test(flavor = "current_thread")] #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn custodian_start_failure_uses_only_exact_last_resort_retention() -> Result<()> { diff --git a/src/windows.rs b/src/windows.rs index 184cc63..a8a819f 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -221,6 +221,10 @@ pub(crate) mod test_support { static JOB_CUSTODIAN_START_FAILURES: AtomicUsize = AtomicUsize::new(0); static JOB_CUSTODIAN_WORKER_STARTS: AtomicUsize = AtomicUsize::new(0); static JOB_PORT_LAST_RESORT_RETENTIONS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_QUERY_ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DRAIN_OBSERVATIONS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DISARM_ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DISARM_SUCCESSES: AtomicUsize = AtomicUsize::new(0); thread_local! { static JOB_PORT_CLOSE_PROBE: RefCell>> = const { RefCell::new(None) }; @@ -259,6 +263,29 @@ pub(crate) mod test_support { }); } + pub fn observe_job_extraction_query(drained: bool) { + JOB_EXTRACTION_QUERY_ATTEMPTS.fetch_add(1, Ordering::SeqCst); + if drained { + JOB_EXTRACTION_DRAIN_OBSERVATIONS.fetch_add(1, Ordering::SeqCst); + } + } + + pub fn observe_job_extraction_disarm(success: bool) { + JOB_EXTRACTION_DISARM_ATTEMPTS.fetch_add(1, Ordering::SeqCst); + if success { + JOB_EXTRACTION_DISARM_SUCCESSES.fetch_add(1, Ordering::SeqCst); + } + } + + pub fn job_extraction_branch_counts() -> (usize, usize, usize, usize) { + ( + JOB_EXTRACTION_QUERY_ATTEMPTS.load(Ordering::SeqCst), + JOB_EXTRACTION_DRAIN_OBSERVATIONS.load(Ordering::SeqCst), + JOB_EXTRACTION_DISARM_ATTEMPTS.load(Ordering::SeqCst), + JOB_EXTRACTION_DISARM_SUCCESSES.load(Ordering::SeqCst), + ) + } + pub fn set_job_extraction_query_failures(failures: usize) { JOB_EXTRACTION_QUERY_FAILURES.store(failures, Ordering::SeqCst); } @@ -444,6 +471,15 @@ pub(crate) mod test_support { self.armed = false; } + pub fn has_exited(&self) -> Result { + // SAFETY: `self.handle` remains live and is used only for this nonblocking wait. + match unsafe { WaitForSingleObject(HANDLE(self.handle.as_raw_handle()), 0) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + _ => Err(Error::last_os_error()), + } + } + pub fn wait_for_exit(&mut self, label: &str) -> Result<()> { let deadline = Instant::now() + EXIT_TIMEOUT; loop { @@ -939,25 +975,33 @@ fn job_is_drained(job: JobHandle) -> Result { fn extracted_job_is_drained(job: JobHandle) -> Result { #[cfg(test)] if test_support::take_job_extraction_query_failure() { + test_support::observe_job_extraction_query(false); return Err(Error::other( "injected extracted JobObject accounting failure", )); } - job_is_drained(job) + let result = job_is_drained(job); + #[cfg(test)] + test_support::observe_job_extraction_query(matches!(result, Ok(true))); + result } #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn disarm_extracted_job(job: JobHandle) -> Result<()> { #[cfg(test)] if test_support::take_job_extraction_disarm_failure() { + test_support::observe_job_extraction_disarm(false); return Err(Error::other("injected extracted JobObject disarm failure")); } - set_job_kill_on_drop_native( + let result = set_job_kill_on_drop_native( job, false, #[cfg(test)] None, - ) + ); + #[cfg(test)] + test_support::observe_job_extraction_disarm(result.is_ok()); + result } #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] From 7fa65ecf10e7b9cca263524ebcac4e81948b50db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 03:42:52 +1300 Subject: [PATCH 096/114] test(windows): prove watchdog stall readiness Co-Authored-By: Claude Code --- .../std_windows/creation_flags_job_object.rs | 45 ++++++++++++++++--- tests/support/bounded_process.rs | 38 +++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index a7bd1a5..23a8555 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -3,7 +3,7 @@ use std::{ io::{Error, ErrorKind}, os::windows::process::CommandExt, panic::{AssertUnwindSafe, catch_unwind}, - path::PathBuf, + path::{Path, PathBuf}, process::{Command as StdCommand, ExitStatus, Stdio}, sync::{ Arc, Mutex, @@ -36,6 +36,8 @@ const DESCENDANT_PID_FILE: &str = "PROCESS_WRAP_DESCENDANT_PID_FILE"; const FINAL_OWNER_TRACE_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_HELPER"; #[cfg(feature = "tracing")] const FINAL_OWNER_TRACE_STALL_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_STALL_HELPER"; +#[cfg(feature = "tracing")] +const FINAL_OWNER_TRACE_READY: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_READY"; static PID_FILE_SEQUENCE: AtomicU32 = AtomicU32::new(0); #[derive(Clone, Copy)] @@ -598,10 +600,26 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { Ok(()) } +#[cfg(feature = "tracing")] +fn wait_for_final_owner_trace_readiness(path: &Path) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + while !path.exists() { + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "the final-owner tracing helper did not enter its stall branch", + )); + } + std::thread::sleep(Duration::from_millis(5)); + } + Ok(()) +} + #[cfg(feature = "tracing")] fn run_final_owner_trace_helper( environment: (&str, &str), timeout: Duration, + readiness: Option<&Path>, ) -> Result<(std::process::Output, bool)> { let mut command = StdCommand::new(std::env::current_exe()?); command @@ -611,14 +629,26 @@ fn run_final_owner_trace_helper( "--nocapture", ]) .env(environment.0, environment.1); - bounded_process::run(command, timeout, None) + if let Some(readiness) = readiness { + command.env(FINAL_OWNER_TRACE_READY, readiness); + bounded_process::run_after_start(command, timeout, None, || { + wait_for_final_owner_trace_readiness(readiness) + }) + } else { + bounded_process::run(command, timeout, None) + } } #[cfg(feature = "tracing")] #[test] fn final_owner_tracing_watchdog_terminates_a_stalled_helper() -> Result<()> { - let (output, timed_out) = - run_final_owner_trace_helper((FINAL_OWNER_TRACE_STALL_HELPER, "1"), Duration::ZERO)?; + let directory = tempfile::tempdir()?; + let readiness = directory.path().join("stall-ready"); + let (output, timed_out) = run_final_owner_trace_helper( + (FINAL_OWNER_TRACE_STALL_HELPER, "1"), + Duration::ZERO, + Some(&readiness), + )?; assert!( timed_out, "the zero-deadline watchdog must take its timeout path" @@ -634,12 +664,17 @@ fn final_owner_tracing_watchdog_terminates_a_stalled_helper() -> Result<()> { #[test] fn final_owner_tracing_panic_leaves_job_cleanup_armed() -> Result<()> { if std::env::var_os(FINAL_OWNER_TRACE_STALL_HELPER).is_some() { + let readiness = PathBuf::from( + std::env::var_os(FINAL_OWNER_TRACE_READY) + .ok_or_else(|| Error::other("the stall readiness path is missing"))?, + ); + fs::write(readiness, b"ready")?; std::thread::sleep(Duration::from_secs(30)); return Ok(()); } if std::env::var_os(FINAL_OWNER_TRACE_HELPER).is_none() { let (output, timed_out) = - run_final_owner_trace_helper((FINAL_OWNER_TRACE_HELPER, "1"), EXIT_TIMEOUT)?; + run_final_owner_trace_helper((FINAL_OWNER_TRACE_HELPER, "1"), EXIT_TIMEOUT, None)?; assert!( !timed_out, "the isolated tracing regression exceeded its deadline" diff --git a/tests/support/bounded_process.rs b/tests/support/bounded_process.rs index 2b5113a..d34f80b 100644 --- a/tests/support/bounded_process.rs +++ b/tests/support/bounded_process.rs @@ -196,16 +196,19 @@ fn preserve_after_cleanup( } pub(crate) fn run( + command: std::process::Command, + timeout: Duration, + reader_failure: Option, +) -> io::Result<(Output, bool)> { + run_after_start(command, timeout, reader_failure, || Ok(())) +} + +pub(crate) fn run_after_start( mut command: std::process::Command, timeout: Duration, reader_failure: Option, + ready: impl FnOnce() -> io::Result<()>, ) -> io::Result<(Output, bool)> { - let deadline = Instant::now().checked_add(timeout).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "subprocess timeout is too large", - ) - })?; let spawned = command .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -242,6 +245,29 @@ pub(crate) fn run( } }; + if let Err(error) = ready() { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + error, + )); + } + let deadline = match Instant::now().checked_add(timeout) { + Some(deadline) => deadline, + None => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + io::Error::new( + io::ErrorKind::InvalidInput, + "subprocess timeout is too large", + ), + )); + } + }; + loop { let status = match child.child_mut().try_wait() { Ok(status) => status, From ac94c4fa9856db935179c15aac9a2590013dce5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 03:45:42 +1300 Subject: [PATCH 097/114] test(ci): lock downstream README fixtures Co-Authored-By: Claude Code --- .github/workflows/test.yml | 10 +- tests/readme/pty/.gitignore | 1 - tests/readme/pty/Cargo.lock | 469 ++++++++++++++++++++++++++++++++ tests/readme/windows/.gitignore | 1 - tests/readme/windows/Cargo.lock | 267 ++++++++++++++++++ 5 files changed, 743 insertions(+), 5 deletions(-) create mode 100644 tests/readme/pty/Cargo.lock create mode 100644 tests/readme/windows/Cargo.lock diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 42f3387..00844fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,9 +58,13 @@ jobs: if: matrix.toolchain == 'stable' run: cargo test --locked --no-default-features --features ${{ matrix.features }} --all-targets + - name: Compile PTY README example as a downstream crate + if: matrix.features == 'pty' + run: cargo check --locked --manifest-path tests/readme/pty/Cargo.toml + - name: Compile Windows README example as a downstream crate - if: matrix.platform == 'windows' && matrix.toolchain == 'stable' && matrix.features == 'pty' - run: cargo check --manifest-path tests/readme/windows/Cargo.toml + if: matrix.platform == 'windows' && matrix.features == 'pty' + run: cargo check --locked --manifest-path tests/readme/windows/Cargo.toml - run: cargo clippy --locked --features ${{ matrix.features }} --all-targets @@ -249,7 +253,7 @@ jobs: run: cargo test --locked --all-features --doc - name: Compile PTY README example as a downstream crate - run: cargo check --manifest-path tests/readme/pty/Cargo.toml + run: cargo check --locked --manifest-path tests/readme/pty/Cargo.toml package: name: Package contents diff --git a/tests/readme/pty/.gitignore b/tests/readme/pty/.gitignore index 1b72444..ea8c4bf 100644 --- a/tests/readme/pty/.gitignore +++ b/tests/readme/pty/.gitignore @@ -1,2 +1 @@ -/Cargo.lock /target diff --git a/tests/readme/pty/Cargo.lock b/tests/readme/pty/Cargo.lock new file mode 100644 index 0000000..6f9c0c9 --- /dev/null +++ b/tests/readme/pty/Cargo.lock @@ -0,0 +1,469 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "10.0.0" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "process-wrap-readme-pty" +version = "0.0.0" +dependencies = [ + "process-wrap", + "tokio", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] diff --git a/tests/readme/windows/.gitignore b/tests/readme/windows/.gitignore index 1b72444..ea8c4bf 100644 --- a/tests/readme/windows/.gitignore +++ b/tests/readme/windows/.gitignore @@ -1,2 +1 @@ -/Cargo.lock /target diff --git a/tests/readme/windows/Cargo.lock b/tests/readme/windows/Cargo.lock new file mode 100644 index 0000000..b49cc44 --- /dev/null +++ b/tests/readme/windows/Cargo.lock @@ -0,0 +1,267 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "10.0.0" +dependencies = [ + "indexmap", + "nix", + "tracing", + "windows", +] + +[[package]] +name = "process-wrap-readme-windows" +version = "0.0.0" +dependencies = [ + "process-wrap", + "windows", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] From b893f2a2cae3fa9239284c702d996491daeeb471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 03:54:07 +1300 Subject: [PATCH 098/114] test(windows): isolate JobObject custodian assertions Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index a5af514..87bb1af 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -928,6 +928,7 @@ mod tests { async fn extraction_failures_use_one_shared_custodian_then_close_every_port() -> Result<()> { let _serial = serial_job_extraction(); let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); let worker_starts = job_custodian_worker_starts(); set_job_extraction_query_failures(usize::MAX); set_job_extraction_disarm_failures(usize::MAX); @@ -971,6 +972,7 @@ mod tests { async fn persistent_disarm_failure_closes_only_after_authoritative_tree_drain() -> Result<()> { let _serial = serial_job_extraction(); let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); let directory = tempfile::tempdir()?; let pid_file = directory.path().join("descendant.pid"); let release = directory.path().join("release-descendant"); From 213e282e6ee0c0b7a548aa7de3d48a9f2ccac547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 04:33:28 +1300 Subject: [PATCH 099/114] test(windows): own prepared reader setup workers Co-Authored-By: Claude Code --- src/generic_wrap.rs | 458 ++++++++++++++++++++++++++++++++-------- src/tokio/core.rs | 2 + tests/spawn_provider.rs | 190 +++++++++++++++-- 3 files changed, 535 insertions(+), 115 deletions(-) diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index bd1eca5..0645d1e 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -106,12 +106,15 @@ macro_rules! Wrap { struct PreparedStateTestRendezvous { reached: ::std::sync::mpsc::Sender<()>, release: ::std::sync::mpsc::Receiver<()>, + publish_reached: bool, } #[cfg(all(test, windows))] impl PreparedStateTestRendezvous { fn wait(self) { - let _ = self.reached.send(()); + if self.publish_reached { + let _ = self.reached.send(()); + } let _ = self.release.recv(); } } @@ -593,6 +596,7 @@ macro_rules! Wrap { Some(PreparedStateTestRendezvous { reached: queued_reached_tx, release: queued_release_rx, + publish_reached: true, }); cleanup.releases.push(queued_release_tx.clone()); let (queued_result_tx, queued_result_rx) = ::std::sync::mpsc::channel(); @@ -620,6 +624,7 @@ macro_rules! Wrap { Some(PreparedStateTestRendezvous { reached: closing_reached_tx, release: closing_release_rx, + publish_reached: true, }); cleanup.releases.push(closing_release_tx.clone()); queued_release_tx @@ -723,6 +728,7 @@ macro_rules! Wrap { Some(PreparedStateTestRendezvous { reached: closing_reached_tx, release: closing_release_rx, + publish_reached: true, }); cleanup.releases.push(closing_release_tx.clone()); let (finished_tx, finished_rx) = ::std::sync::mpsc::channel(); @@ -885,19 +891,203 @@ macro_rules! Wrap { assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); } + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum PreparedPublicationSetupFailure { + ActiveEntered, + QueuedReached, + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug, Default)] + struct PreparedPublicationSetupProbe { + started: ::std::sync::atomic::AtomicUsize, + active: ::std::sync::atomic::AtomicUsize, + joined: ::std::sync::atomic::AtomicUsize, + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedPublicationWorkerActivity( + ::std::sync::Arc, + ); + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationWorkerActivity { + fn new(probe: ::std::sync::Arc) -> Self { + probe + .active + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Self(probe) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationWorkerActivity { + fn drop(&mut self) { + self.0 + .active + .fetch_sub(1, ::std::sync::atomic::Ordering::SeqCst); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationReader { + release: Option<::std::sync::mpsc::Sender<()>>, + result: Option<::std::sync::mpsc::Receiver>, + worker: Option<::std::thread::JoinHandle<()>>, + probe: ::std::sync::Arc, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationReader { + fn new( + release: ::std::sync::mpsc::Sender<()>, + result: ::std::sync::mpsc::Receiver, + worker: ::std::thread::JoinHandle<()>, + probe: ::std::sync::Arc, + ) -> Self { + probe + .started + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Self { + release: Some(release), + result: Some(result), + worker: Some(worker), + probe, + } + } + + fn release(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + } + + fn receive( + &mut self, + ) -> ::std::result::Result { + self.result + .take() + .expect("an unfinished publication reader owns its result") + .recv_timeout(PreparedPublicationControl::TIMEOUT) + } + + fn join(&mut self) -> Option<::std::thread::Result<()>> { + self.worker.take().map(|worker| { + let result = worker.join(); + self.probe + .joined + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + result + }) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationReader { + fn drop(&mut self) { + self.release(); + if let Some(Err(payload)) = self.join() { + ::std::mem::forget(payload); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn join_publication_readers( + readers: [&mut PreparedPublicationReader; 2], + ) -> Option> { + let mut first_payload = None; + for reader in readers { + if let Some(Err(payload)) = reader.join() { + if first_payload.is_none() { + first_payload = Some(payload); + } else { + ::std::mem::forget(payload); + } + } + } + first_payload + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedPublicationReaderSetup { + active: Option, + queued: Option, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationReaderSetup { + fn new() -> Self { + Self { + active: None, + queued: None, + } + } + + fn own_active(&mut self, reader: PreparedPublicationReader) { + self.active = Some(reader); + } + + fn own_queued(&mut self, reader: PreparedPublicationReader) { + self.queued = Some(reader); + } + + fn install(mut self, control: &PreparedPublicationControl) { + *control + .active_reader + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = self.active.take(); + *control + .queued_reader + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = self.queued.take(); + } + + fn release_all(&mut self) { + if let Some(reader) = self.queued.as_mut() { + reader.release(); + } + if let Some(reader) = self.active.as_mut() { + reader.release(); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationReaderSetup { + fn drop(&mut self) { + self.release_all(); + match (self.active.as_mut(), self.queued.as_mut()) { + (Some(active), Some(queued)) => { + if let Some(payload) = join_publication_readers([active, queued]) { + ::std::mem::forget(payload); + } + } + (Some(reader), None) | (None, Some(reader)) => { + if let Some(Err(payload)) = reader.join() { + ::std::mem::forget(payload); + } + } + (None, None) => {} + } + } + } + #[cfg(all(test, windows, feature = "job-object"))] #[derive(Debug)] struct PreparedPublicationControl { placed_tx: ::std::sync::mpsc::Sender<()>, placed_rx: ::std::sync::Mutex<::std::sync::mpsc::Receiver<()>>, + setup_failure: Option, + setup_timeout: ::std::time::Duration, + setup_probe: ::std::sync::Arc, closing_tx: ::std::sync::Mutex>>, closing_rx: ::std::sync::Mutex<::std::sync::mpsc::Receiver<()>>, - active_release: ::std::sync::Mutex>>, - queued_release: ::std::sync::Mutex>>, + active_reader: ::std::sync::Mutex>, + queued_reader: ::std::sync::Mutex>, closing_release: ::std::sync::Mutex>>, - active_result: ::std::sync::Mutex>>, - queued_result: ::std::sync::Mutex>>, - workers: ::std::sync::Mutex>>, queued_callbacks: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, retained_token: ::std::sync::Mutex>>, } @@ -907,19 +1097,31 @@ macro_rules! Wrap { const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); fn new() -> ::std::sync::Arc { + Self::with_setup_failure(None) + } + + fn with_setup_failure( + setup_failure: Option, + ) -> ::std::sync::Arc { let (placed_tx, placed_rx) = ::std::sync::mpsc::channel(); let (closing_tx, closing_rx) = ::std::sync::mpsc::channel(); ::std::sync::Arc::new(Self { placed_tx, placed_rx: ::std::sync::Mutex::new(placed_rx), + setup_failure, + setup_timeout: if setup_failure.is_some() { + ::std::time::Duration::from_millis(100) + } else { + Self::TIMEOUT + }, + setup_probe: ::std::sync::Arc::new( + PreparedPublicationSetupProbe::default(), + ), closing_tx: ::std::sync::Mutex::new(Some(closing_tx)), closing_rx: ::std::sync::Mutex::new(closing_rx), - active_release: ::std::sync::Mutex::new(None), - queued_release: ::std::sync::Mutex::new(None), + active_reader: ::std::sync::Mutex::new(None), + queued_reader: ::std::sync::Mutex::new(None), closing_release: ::std::sync::Mutex::new(None), - active_result: ::std::sync::Mutex::new(None), - queued_result: ::std::sync::Mutex::new(None), - workers: ::std::sync::Mutex::new(Vec::new()), queued_callbacks: ::std::sync::Arc::new( ::std::sync::atomic::AtomicUsize::new(0), ), @@ -933,19 +1135,31 @@ macro_rules! Wrap { .upgrade() .expect("the private owner remains live during wrapping"); let token = ::std::sync::Arc::new(token); + let mut setup = PreparedPublicationReaderSetup::new(); let (active_entered_tx, active_entered_rx) = ::std::sync::mpsc::channel(); let (active_release_tx, active_release_rx) = ::std::sync::mpsc::channel(); let (active_result_tx, active_result_rx) = ::std::sync::mpsc::channel(); let active_token = ::std::sync::Arc::clone(&token); - let active = ::std::thread::spawn(move || { - let result = active_token.with::(|_| { - let _ = active_entered_tx.send(()); - active_release_rx.recv().is_ok() - }) == Some(true); - let _ = active_result_tx.send(result); - }); + let suppress_active = + self.setup_failure == Some(PreparedPublicationSetupFailure::ActiveEntered); + let active_probe = ::std::sync::Arc::clone(&self.setup_probe); + setup.own_active(PreparedPublicationReader::new( + active_release_tx, + active_result_rx, + ::std::thread::spawn(move || { + let _activity = PreparedPublicationWorkerActivity::new(active_probe); + let result = active_token.with::(|_| { + if !suppress_active { + let _ = active_entered_tx.send(()); + } + active_release_rx.recv().is_ok() + }) == Some(true); + let _ = active_result_tx.send(result); + }), + ::std::sync::Arc::clone(&self.setup_probe), + )); active_entered_rx - .recv_timeout(Self::TIMEOUT) + .recv_timeout(self.setup_timeout) .expect("reader A owns the prepared value before wrapping returns"); let (queued_reached_tx, queued_reached_rx) = ::std::sync::mpsc::channel(); @@ -957,21 +1171,30 @@ macro_rules! Wrap { Some(PreparedStateTestRendezvous { reached: queued_reached_tx, release: queued_release_rx, + publish_reached: self.setup_failure + != Some(PreparedPublicationSetupFailure::QueuedReached), }); let (queued_result_tx, queued_result_rx) = ::std::sync::mpsc::channel(); let queued_token = ::std::sync::Arc::clone(&token); let queued_callbacks = ::std::sync::Arc::clone(&self.queued_callbacks); - let queued = ::std::thread::spawn(move || { - let accessed = queued_token - .with::(|_| { - queued_callbacks - .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); - }) - .is_some(); - let _ = queued_result_tx.send(accessed); - }); + let queued_probe = ::std::sync::Arc::clone(&self.setup_probe); + setup.own_queued(PreparedPublicationReader::new( + queued_release_tx, + queued_result_rx, + ::std::thread::spawn(move || { + let _activity = PreparedPublicationWorkerActivity::new(queued_probe); + let accessed = queued_token + .with::(|_| { + queued_callbacks + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + }) + .is_some(); + let _ = queued_result_tx.send(accessed); + }), + ::std::sync::Arc::clone(&self.setup_probe), + )); queued_reached_rx - .recv_timeout(Self::TIMEOUT) + .recv_timeout(self.setup_timeout) .expect("reader B upgraded and attempted value while A owned it"); let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); @@ -988,32 +1211,14 @@ macro_rules! Wrap { Some(PreparedStateTestRendezvous { reached: closing_tx, release: closing_release_rx, + publish_reached: true, }); - *self - .active_release - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(active_release_tx); - *self - .queued_release - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(queued_release_tx); + setup.install(self); *self .closing_release .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(closing_release_tx); - *self - .active_result - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(active_result_rx); - *self - .queued_result - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(queued_result_rx); - self.workers - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner) - .extend([active, queued]); *self .retained_token .lock() @@ -1023,6 +1228,30 @@ macro_rules! Wrap { .expect("the test observes both publication readers"); } + fn assert_setup_workers_joined(&self, expected: usize) { + assert_eq!( + self.setup_probe + .started + .load(::std::sync::atomic::Ordering::SeqCst), + expected, + "the setup seam starts the expected readers" + ); + assert_eq!( + self.setup_probe + .joined + .load(::std::sync::atomic::Ordering::SeqCst), + expected, + "every started reader is joined before setup failure returns" + ); + assert_eq!( + self.setup_probe + .active + .load(::std::sync::atomic::Ordering::SeqCst), + 0, + "no prepared reader remains active after setup failure" + ); + } + fn wait_placed(&self) { self.placed_rx .lock() @@ -1039,8 +1268,21 @@ macro_rules! Wrap { .expect("the real lifecycle path published prepared closing"); } - fn release(slot: &::std::sync::Mutex>>) { - if let Some(release) = slot + fn release_reader( + slot: &::std::sync::Mutex>, + ) { + if let Some(reader) = slot + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .as_mut() + { + reader.release(); + } + } + + fn release_closing(&self) { + if let Some(release) = self + .closing_release .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner) .take() @@ -1050,15 +1292,11 @@ macro_rules! Wrap { } fn release_queued(&self) { - Self::release(&self.queued_release); + Self::release_reader(&self.queued_reader); } fn release_active(&self) { - Self::release(&self.active_release); - } - - fn release_closing(&self) { - Self::release(&self.closing_release); + Self::release_reader(&self.active_reader); } fn release_all(&self) { @@ -1068,30 +1306,25 @@ macro_rules! Wrap { } fn collect_readers(&self) -> (bool, bool) { - let active = self - .active_result + let mut active = self + .active_reader .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner) .take() - .expect("reader A retains its result") - .recv_timeout(Self::TIMEOUT) - .expect("reader A completes after release"); - let queued = self - .queued_result + .expect("reader A remains owned until collection"); + let mut queued = self + .queued_reader .lock() .unwrap_or_else(::std::sync::PoisonError::into_inner) .take() - .expect("reader B retains its result") - .recv_timeout(Self::TIMEOUT) - .expect("reader B completes after closing"); - for worker in ::std::mem::take( - &mut *self - .workers - .lock() - .unwrap_or_else(::std::sync::PoisonError::into_inner), - ) { - worker.join().expect("prepared publication reader does not panic"); + .expect("reader B remains owned until collection"); + let active_result = active.receive(); + let queued_result = queued.receive(); + if let Some(payload) = join_publication_readers([&mut active, &mut queued]) { + ::std::panic::resume_unwind(payload); } + let active = active_result.expect("reader A completes after release"); + let queued = queued_result.expect("reader B completes after closing"); (active, queued) } @@ -1125,21 +1358,21 @@ macro_rules! Wrap { #[cfg(all(test, windows, feature = "job-object"))] impl Drop for PreparedPublicationControl { fn drop(&mut self) { - if let Some(release) = self - .queued_release + let mut active = self + .active_reader .get_mut() .unwrap_or_else(::std::sync::PoisonError::into_inner) - .take() - { - let _ = release.send(()); - } - if let Some(release) = self - .active_release + .take(); + let mut queued = self + .queued_reader .get_mut() .unwrap_or_else(::std::sync::PoisonError::into_inner) - .take() - { - let _ = release.send(()); + .take(); + if let Some(reader) = queued.as_mut() { + reader.release(); + } + if let Some(reader) = active.as_mut() { + reader.release(); } if let Some(release) = self .closing_release @@ -1149,14 +1382,18 @@ macro_rules! Wrap { { let _ = release.send(()); } - for worker in ::std::mem::take( - self.workers - .get_mut() - .unwrap_or_else(::std::sync::PoisonError::into_inner), - ) { - if let Err(payload) = worker.join() { - ::std::mem::forget(payload); + match (active.as_mut(), queued.as_mut()) { + (Some(active), Some(queued)) => { + if let Some(payload) = join_publication_readers([active, queued]) { + ::std::mem::forget(payload); + } } + (Some(reader), None) | (None, Some(reader)) => { + if let Some(Err(payload)) = reader.join() { + ::std::mem::forget(payload); + } + } + (None, None) => {} } } } @@ -1584,6 +1821,43 @@ macro_rules! Wrap { cleanup.finish(); } + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_publication_reader_setup_failure_cleanup( + failure: PreparedPublicationSetupFailure, + expected_workers: usize, + ) { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let control = PreparedPublicationControl::with_setup_failure(Some(failure)); + let outcome = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { + control.start(owner.installed_token()); + })); + assert!(outcome.is_err(), "the suppressed setup event must fail"); + control.assert_setup_workers_joined(expected_workers); + drop(owner); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn missing_active_reader_setup_event_releases_and_joins_started_worker() { + assert_publication_reader_setup_failure_cleanup( + PreparedPublicationSetupFailure::ActiveEntered, + 1, + ); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn missing_queued_reader_setup_event_releases_and_joins_started_workers() { + assert_publication_reader_setup_failure_cleanup( + PreparedPublicationSetupFailure::QueuedReached, + 2, + ); + } + #[cfg(all(test, windows, feature = "job-object"))] #[test] fn published_active_and_queued_readers_allow_native_and_provider_spawn_return() { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index a38d745..684768f 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -1077,6 +1077,7 @@ mod prepared_output_wait_tests { .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { reached: racing_reached_tx, release: racing_release_rx, + publish_reached: true, }); cleanup.releases.push(racing_release_tx.clone()); let (racing_tx, racing_rx) = mpsc::channel(); @@ -1102,6 +1103,7 @@ mod prepared_output_wait_tests { .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { reached: closing_reached_tx, release: closing_release_rx, + publish_reached: true, }); cleanup.releases.push(closing_release_tx.clone()); racing_release_tx diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index 9324118..94c9b0c 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -836,23 +836,115 @@ macro_rules! spawn_provider_tests { } + #[cfg(windows)] + #[derive(Debug, Default)] + struct ActivePreparedReaderSetupProbe { + started: AtomicUsize, + active: AtomicUsize, + joined: AtomicUsize, + } + + #[cfg(windows)] + struct ActivePreparedReaderActivity(Arc); + + #[cfg(windows)] + impl ActivePreparedReaderActivity { + fn new(probe: Arc) -> Self { + probe.active.fetch_add(1, Ordering::SeqCst); + Self(probe) + } + } + + #[cfg(windows)] + impl Drop for ActivePreparedReaderActivity { + fn drop(&mut self) { + self.0.active.fetch_sub(1, Ordering::SeqCst); + } + } + + #[cfg(windows)] + struct ActivePreparedReaderSetup { + release: Option>, + worker: Option>, + probe: Arc, + } + + #[cfg(windows)] + impl ActivePreparedReaderSetup { + fn new( + release: mpsc::Sender<()>, + worker: thread::JoinHandle, + probe: Arc, + ) -> Self { + probe.started.fetch_add(1, Ordering::SeqCst); + Self { + release: Some(release), + worker: Some(worker), + probe, + } + } + + fn install(mut self, reader: &ActivePreparedReader) { + *reader + .release + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.release.take(); + *reader + .worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.worker.take(); + } + } + + #[cfg(windows)] + impl Drop for ActivePreparedReaderSetup { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + if let Some(worker) = self.worker.take() { + let result = worker.join(); + self.probe.joined.fetch_add(1, Ordering::SeqCst); + if let Err(payload) = result { + std::mem::forget(payload); + } + } + } + } + #[cfg(windows)] #[derive(Debug)] struct ActivePreparedReader { entered: mpsc::Sender<()>, release: Mutex>>, worker: Mutex>>, + suppress_entered: bool, + setup_timeout: Duration, + setup_probe: Arc, } #[cfg(windows)] impl ActivePreparedReader { fn new() -> (Arc, mpsc::Receiver<()>) { + Self::with_setup_failure(false) + } + + fn with_setup_failure( + suppress_entered: bool, + ) -> (Arc, mpsc::Receiver<()>) { let (entered, observe_entered) = mpsc::channel(); ( Arc::new(Self { entered, release: Mutex::new(None), worker: Mutex::new(None), + suppress_entered, + setup_timeout: if suppress_entered { + Duration::from_millis(100) + } else { + EXIT_TIMEOUT + }, + setup_probe: Arc::new(ActivePreparedReaderSetupProbe::default()), }), observe_entered, ) @@ -861,26 +953,28 @@ macro_rules! spawn_provider_tests { fn start(&self, token: PreparedChild) { let (active_tx, active_rx) = mpsc::channel(); let (release_tx, release_rx) = mpsc::channel(); - let worker = thread::spawn(move || { - token.with::, _>(|prepared| { - assert!(prepared.is_some()); - active_tx - .send(()) - .expect("the wrapping callback observes active prepared access"); - release_rx.recv_timeout(EXIT_TIMEOUT).is_ok() - }) == Some(true) - }); + let suppress_entered = self.suppress_entered; + let setup_probe = Arc::clone(&self.setup_probe); + let setup = ActivePreparedReaderSetup::new( + release_tx, + thread::spawn(move || { + let _activity = ActivePreparedReaderActivity::new(setup_probe); + token.with::, _>(|prepared| { + assert!(prepared.is_some()); + if !suppress_entered { + active_tx.send(()).expect( + "the wrapping callback observes active prepared access", + ); + } + release_rx.recv_timeout(EXIT_TIMEOUT).is_ok() + }) == Some(true) + }), + Arc::clone(&self.setup_probe), + ); active_rx - .recv_timeout(EXIT_TIMEOUT) + .recv_timeout(self.setup_timeout) .expect("the prepared reader enters before wrapping returns"); - *self - .release - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(release_tx); - *self - .worker - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(worker); + setup.install(self); let _ = self.entered.send(()); } @@ -893,11 +987,36 @@ macro_rules! spawn_provider_tests { { let _ = release.send(()); } - self.worker + let worker = self + .worker .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - .is_none_or(|worker| worker.join().unwrap_or(false)) + .take(); + match worker { + Some(worker) => { + let result = worker.join(); + self.setup_probe.joined.fetch_add(1, Ordering::SeqCst); + match result { + Ok(completed) => completed, + Err(payload) => std::panic::resume_unwind(payload), + } + } + None => true, + } + } + + fn assert_setup_worker_joined(&self) { + assert_eq!(self.setup_probe.started.load(Ordering::SeqCst), 1); + assert_eq!( + self.setup_probe.joined.load(Ordering::SeqCst), + 1, + "the started active reader is joined before setup failure returns" + ); + assert_eq!( + self.setup_probe.active.load(Ordering::SeqCst), + 0, + "no active prepared reader survives setup failure" + ); } } @@ -917,9 +1036,12 @@ macro_rules! spawn_provider_tests { .get_mut() .unwrap_or_else(std::sync::PoisonError::into_inner) .take() - && let Err(payload) = worker.join() { - std::mem::forget(payload); + let result = worker.join(); + self.setup_probe.joined.fetch_add(1, Ordering::SeqCst); + if let Err(payload) = result { + std::mem::forget(payload); + } } } } @@ -3092,6 +3214,28 @@ macro_rules! spawn_provider_tests { } } + #[cfg(windows)] + #[test] + fn active_reader_setup_failure_releases_and_joins_started_worker() { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let (reader, _entered) = ActivePreparedReader::with_setup_failure(true); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(AccessInstalledPrepared { + start_once: true, + reader: Arc::clone(&reader), + }); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let _payload = outcome.expect_err("the suppressed entered event must fail setup"); + reader.assert_setup_worker_joined(); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + #[cfg(windows)] #[test] fn active_reader_during_later_wrapping_is_not_an_escaped_owner() { From 111e50f75b9dd971dfdb712091d79165222b1daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 04:39:06 +1300 Subject: [PATCH 100/114] test(unix): prove signal mutex acquisition Co-Authored-By: Claude Code --- tests/process_identity.rs | 154 +++++++++++++++++++++++++++++--------- 1 file changed, 119 insertions(+), 35 deletions(-) diff --git a/tests/process_identity.rs b/tests/process_identity.rs index 9618fab..2900888 100644 --- a/tests/process_identity.rs +++ b/tests/process_identity.rs @@ -308,6 +308,7 @@ async fn sticky_custom_id_does_not_authorize_a_signal_after_wait() { struct SyntheticSharedState { status: Mutex>, wait_acquired: Mutex>>, + signal_acquisition: Mutex>>, completion: Mutex>>, group_signals: AtomicUsize, } @@ -391,6 +392,27 @@ impl ChildWrapper for SyntheticSharedChild { _process_group: i32, _signal: i32, ) -> Option>> { + if let Some(acquisition) = self + .state + .signal_acquisition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + match self.state.status.try_lock() { + Err(std::sync::TryLockError::WouldBlock) => { + let _ = acquisition.send(()); + } + Ok(status) => { + drop(status); + panic!("signal unexpectedly acquired the shared status mutex"); + } + Err(std::sync::TryLockError::Poisoned(error)) => { + drop(error.into_inner()); + panic!("the shared status mutex was poisoned before signal acquisition"); + } + } + } let status = self .state .status @@ -418,23 +440,102 @@ impl ChildWrapper for SyntheticSharedChild { } } +type SharedSignalResult = (Box, io::Result<()>); + +struct SharedSignalWorker { + result: Option>, + handle: Option>, +} + struct SharedSignalCleanup { completion: Option>, status: ExitStatus, - workers: Vec>, + waiter: Option>, + signal: Option, } impl SharedSignalCleanup { + fn new( + completion: mpsc::Sender, + status: ExitStatus, + waiter: std::thread::JoinHandle<()>, + ) -> Self { + Self { + completion: Some(completion), + status, + waiter: Some(waiter), + signal: None, + } + } + + fn start_signal(&mut self, mut child: Box) { + assert!(self.signal.is_none(), "the signal worker starts once"); + let (result_tx, result_rx) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let result = child.signal(nix::libc::SIGCONT); + let _ = result_tx.send((child, result)); + }); + self.signal = Some(SharedSignalWorker { + result: Some(result_rx), + handle: Some(handle), + }); + } + + fn try_signal_result(&self) -> Result { + self.signal + .as_ref() + .expect("the signal worker is owned before inspection") + .result + .as_ref() + .expect("the signal result remains available") + .try_recv() + } + + fn receive_signal_result(&mut self) -> SharedSignalResult { + self.signal + .as_mut() + .expect("the signal worker is owned before collection") + .result + .take() + .expect("the signal result is collected once") + .recv_timeout(EXIT_TIMEOUT) + .expect("signal completes after wait releases custody") + } + fn release(&mut self) { if let Some(completion) = self.completion.take() { let _ = completion.send(self.status); } } + fn record_join( + handle: &mut Option>, + first_payload: &mut Option>, + ) { + if let Some(handle) = handle.take() + && let Err(payload) = handle.join() + { + if first_payload.is_none() { + *first_payload = Some(payload); + } else { + std::mem::forget(payload); + } + } + } + + fn join_all(&mut self) -> Option> { + let mut first_payload = None; + Self::record_join(&mut self.waiter, &mut first_payload); + if let Some(signal) = self.signal.as_mut() { + Self::record_join(&mut signal.handle, &mut first_payload); + } + first_payload + } + fn finish(mut self) { self.release(); - for worker in std::mem::take(&mut self.workers) { - worker.join().expect("shared-child worker does not panic"); + if let Some(payload) = self.join_all() { + std::panic::resume_unwind(payload); } } } @@ -442,10 +543,8 @@ impl SharedSignalCleanup { impl Drop for SharedSignalCleanup { fn drop(&mut self) { self.release(); - for worker in std::mem::take(&mut self.workers) { - if let Err(payload) = worker.join() { - std::mem::forget(payload); - } + if let Some(payload) = self.join_all() { + std::mem::forget(payload); } } } @@ -454,16 +553,18 @@ impl Drop for SharedSignalCleanup { async fn shared_wait_and_group_signal_linearize_under_child_custody() { let status = ExitStatus::from_raw(37 << 8); let (wait_acquired_tx, wait_acquired_rx) = mpsc::channel(); + let (signal_acquisition_tx, signal_acquisition_rx) = mpsc::channel(); let (completion_tx, completion_rx) = mpsc::channel(); let state = Arc::new(SyntheticSharedState { status: Mutex::new(None), wait_acquired: Mutex::new(Some(wait_acquired_tx)), + signal_acquisition: Mutex::new(Some(signal_acquisition_tx)), completion: Mutex::new(Some(completion_rx)), group_signals: AtomicUsize::new(0), }); let mut command = Command::new("synthetic-child"); command.wrap(ProcessGroup::leader()); - let mut child = command + let child = command .spawn_with_child({ let state = Arc::clone(&state); move |_| { @@ -486,43 +587,26 @@ async fn shared_wait_and_group_signal_linearize_under_child_custody() { let result = runtime.block_on(lower.wait()); let _ = waited_tx.send(result); }); - let mut cleanup = SharedSignalCleanup { - completion: Some(completion_tx), - status, - workers: vec![waiter], - }; + let mut cleanup = SharedSignalCleanup::new(completion_tx, status, waiter); wait_acquired_rx .recv_timeout(EXIT_TIMEOUT) .expect("wait owns lower-child custody before signal starts"); - let (signaled_tx, signaled_rx) = mpsc::channel(); - cleanup.workers.push(std::thread::spawn(move || { - let result = child.signal(nix::libc::SIGCONT); - let _ = signaled_tx.send((child, result)); - })); - let early_signal = signaled_rx.recv_timeout(Duration::from_millis(100)); - let signal_was_pending = matches!(&early_signal, Err(mpsc::RecvTimeoutError::Timeout)); + cleanup.start_signal(child); + signal_acquisition_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("signal did not report its authoritative blocked status-lock attempt"); + assert!( + matches!(cleanup.try_signal_result(), Err(mpsc::TryRecvError::Empty)), + "signal returned after reporting blocked status acquisition" + ); cleanup.release(); let waited = waited_rx .recv_timeout(EXIT_TIMEOUT) .expect("wait completes after explicit terminal release") .expect("synthetic wait succeeds"); - let (mut child, signal_result) = match early_signal { - Ok(result) => result, - Err(mpsc::RecvTimeoutError::Timeout) => signaled_rx - .recv_timeout(EXIT_TIMEOUT) - .expect("signal completes after wait releases custody"), - Err(mpsc::RecvTimeoutError::Disconnected) => { - cleanup.finish(); - panic!("signal worker disconnected without a result") - } - }; + let (mut child, signal_result) = cleanup.receive_signal_result(); cleanup.finish(); - - assert!( - signal_was_pending, - "signal did not block behind wait custody" - ); signal_result.unwrap(); assert_eq!(waited, status); assert_eq!(state.group_signals.load(Ordering::SeqCst), 0); From 6da0932ea532c13e77bb0c94fc2d58c38b024b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 22:54:16 +1300 Subject: [PATCH 101/114] test(windows): measure warmed handle slope Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 91 ++++++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 87bb1af..cc05d5f 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -430,6 +430,7 @@ impl ChildWrapper for JobObjectChild { mod tests { use std::{ fs, + future::{Future, poll_fn}, os::windows::{io::BorrowedHandle, process::CommandExt}, panic::{AssertUnwindSafe, catch_unwind, panic_any}, path::{Path, PathBuf}, @@ -438,6 +439,7 @@ mod tests { Arc, atomic::{AtomicUsize, Ordering}, }, + task::Poll, }; use windows::Win32::System::Threading::{ @@ -875,6 +877,68 @@ mod tests { } } + async fn warm_handle_count_infrastructure() -> Result<()> { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release"); + let mut command = StdCommand::new(std::env::current_exe()?); + command + .args([ + "--exact", + "tokio::job_object::tests::extraction_custody_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env(EXTRACTION_DESCENDANT_RELEASE, &release) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let mut child = command.spawn()?; + + // Tokio registers its Windows process wait only after the first poll establishes that the + // process is still live. The helper cannot exit before `release` exists, so this poll must + // initialize RegisterWaitForSingleObject rather than take the already-exited fast path. + let mut wait = Box::pin(child.wait()); + let first_poll = poll_fn(|cx| Poll::Ready(wait.as_mut().poll(cx))).await; + assert!( + first_poll.is_pending(), + "the registered-wait warm-up child exited before release" + ); + drop(wait); + + fs::write(&release, [])?; + assert!(child.wait().await?.success()); + // Reaping drops Tokio's per-child wait registration, whose Windows implementation uses + // UnregisterWaitEx with INVALID_HANDLE_VALUE to synchronously finish any callback. + tokio::task::spawn_blocking(|| {}) + .await + .map_err(Error::other)?; + Ok(()) + } + + async fn run_drained_extraction(job_first: bool) -> Result<()> { + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(true, job_first); + let mut child = command.spawn()?; + clear_job_port_close_probe(); + let status = child.wait().await?; + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (1, 1)); + assert_eq!(lower.wait().await?, status); + assert_eq!(lower.try_wait()?, Some(status)); + Ok(()) + } + + async fn run_drained_extraction_batch() -> Result<()> { + for job_first in [false, true] { + for _ in 0..32 { + run_drained_extraction(job_first).await?; + } + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly() -> Result<()> { @@ -882,24 +946,21 @@ mod tests { return run_handle_count_regression_in_subprocess(); } let _serial = serial_job_extraction(); - let before = current_process_handle_count()?; + + warm_handle_count_infrastructure().await?; for job_first in [false, true] { - for _ in 0..32 { - let probe = arm_job_port_close_probe(); - let mut command = extraction_command(true, job_first); - let mut child = command.spawn()?; - clear_job_port_close_probe(); - let status = child.wait().await?; - let mut lower = child.into_inner(); - assert_eq!(probe.counts(), (1, 1)); - assert_eq!(lower.wait().await?, status); - assert_eq!(lower.try_wait()?, Some(status)); - } + run_drained_extraction(job_first).await?; } - let after = current_process_handle_count()?; + + let warmed_baseline = current_process_handle_count()?; + run_drained_extraction_batch().await?; + let batch_a = current_process_handle_count()?; + run_drained_extraction_batch().await?; + let batch_b = current_process_handle_count()?; assert!( - after <= before.saturating_add(8), - "repeated drained extraction grew process handles from {before} to {after}" + batch_a <= warmed_baseline && batch_b <= batch_a, + "drained extraction handle counts increased after warm-up: \ + warmed baseline {warmed_baseline}, batch A {batch_a}, batch B {batch_b}" ); Ok(()) } From ea5d45075df1c702a1d5765c1ad4a6590b25c2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 23:03:41 +1300 Subject: [PATCH 102/114] ci: add explicit major-release semver path Co-Authored-By: Claude Code --- .github/workflows/test.yml | 43 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00844fd..75ac3df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,7 @@ name: Test on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: - main @@ -274,6 +275,8 @@ jobs: cargo package --locked cargo package --locked --list + # `semver-major` is trusted maintainer release intent for the whole PR, not an + # item-specific compatibility suppression. semver: name: Semver against PR base if: github.event_name == 'pull_request' @@ -297,10 +300,46 @@ jobs: env: BASE_REF: ${{ github.base_ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | - printf 'Comparing against %s at %s\n' "$BASE_REF" "$BASE_SHA" + if [ -z "$BASE_REF" ] || [ -z "$BASE_SHA" ] || [ -z "$PR_LABELS_JSON" ] || [ "$PR_LABELS_JSON" = null ]; then + printf 'pull_request base and label metadata are required for the semver job\n' >&2 + exit 1 + fi + if ! semver_major="$( + printf '%s\n' "$PR_LABELS_JSON" \ + | jq -er 'if type != "array" or any(.[]; type != "string") then error("invalid PR labels") elif index("semver-major") != null then "true" else "false" end' + )"; then + printf 'pull_request label metadata is malformed\n' >&2 + exit 1 + fi + git cat-file -e "${BASE_SHA}^{commit}" - cargo semver-checks check-release --all-features --baseline-rev "$BASE_SHA" + if [ "$semver_major" = true ]; then + mapfile -t version_10_tags < <(git tag --list 'v10.*' --sort=-version:refname) + latest_version_10_tag= + for tag in "${version_10_tags[@]}"; do + if [[ "$tag" =~ ^v10\.[0-9]+\.[0-9]+$ ]] \ + && git cat-file -e "${tag}^{commit}" \ + && git merge-base "$BASE_SHA" "$tag" >/dev/null; then + latest_version_10_tag="$tag" + break + fi + done + if [ -z "$latest_version_10_tag" ]; then + printf 'no published version-10 tag is reachable from PR base %s\n' "$BASE_SHA" >&2 + exit 1 + fi + published_sha="$(git rev-parse "${latest_version_10_tag}^{commit}")" + printf 'PR base: %s at %s\n' "$BASE_REF" "$BASE_SHA" + printf 'Baseline: latest reachable published version-10 tag %s at %s\n' "$latest_version_10_tag" "$published_sha" + printf 'Selected release type: major (trusted semver-major label)\n' + cargo semver-checks check-release --all-features --baseline-rev "$latest_version_10_tag" --release-type major + else + printf 'Baseline: PR base %s at %s\n' "$BASE_REF" "$BASE_SHA" + printf 'Selected release type: inferred by cargo-semver-checks (semver-major absent)\n' + cargo semver-checks check-release --all-features --baseline-rev "$BASE_SHA" + fi formatting: name: Fmt check From 2536d17c8c5dc5e3ca2f06f4bd6679ed3c5ae75b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Mon, 28 Sep 2026 23:49:57 +1300 Subject: [PATCH 103/114] ci: restrict major baseline to base ancestry Co-Authored-By: Claude Code --- .github/workflows/test.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75ac3df..3e222df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -316,25 +316,26 @@ jobs: git cat-file -e "${BASE_SHA}^{commit}" if [ "$semver_major" = true ]; then + # Only stable SemVer tags on the PR-base lineage can define the major baseline. mapfile -t version_10_tags < <(git tag --list 'v10.*' --sort=-version:refname) - latest_version_10_tag= + latest_version_10_ancestor= for tag in "${version_10_tags[@]}"; do - if [[ "$tag" =~ ^v10\.[0-9]+\.[0-9]+$ ]] \ + if [[ "$tag" =~ ^v10\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ && git cat-file -e "${tag}^{commit}" \ - && git merge-base "$BASE_SHA" "$tag" >/dev/null; then - latest_version_10_tag="$tag" + && git merge-base --is-ancestor "$tag" "$BASE_SHA"; then + latest_version_10_ancestor="$tag" break fi done - if [ -z "$latest_version_10_tag" ]; then - printf 'no published version-10 tag is reachable from PR base %s\n' "$BASE_SHA" >&2 + if [ -z "$latest_version_10_ancestor" ]; then + printf 'no stable version-10 tag is an ancestor of PR base %s\n' "$BASE_SHA" >&2 exit 1 fi - published_sha="$(git rev-parse "${latest_version_10_tag}^{commit}")" + ancestor_sha="$(git rev-parse "${latest_version_10_ancestor}^{commit}")" printf 'PR base: %s at %s\n' "$BASE_REF" "$BASE_SHA" - printf 'Baseline: latest reachable published version-10 tag %s at %s\n' "$latest_version_10_tag" "$published_sha" + printf 'Baseline: latest stable version-10 ancestor %s at %s\n' "$latest_version_10_ancestor" "$ancestor_sha" printf 'Selected release type: major (trusted semver-major label)\n' - cargo semver-checks check-release --all-features --baseline-rev "$latest_version_10_tag" --release-type major + cargo semver-checks check-release --all-features --baseline-rev "$latest_version_10_ancestor" --release-type major else printf 'Baseline: PR base %s at %s\n' "$BASE_REF" "$BASE_SHA" printf 'Selected release type: inferred by cargo-semver-checks (semver-major absent)\n' From dde53783a9e2b6e00dbe4327127d9e1dc3bf6bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Tue, 29 Sep 2026 00:35:12 +1300 Subject: [PATCH 104/114] ci: keep semver compatibility checks strict Co-Authored-By: Claude Code --- .github/workflows/test.yml | 44 ++------------------------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e222df..00844fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,6 @@ name: Test on: pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: - main @@ -275,8 +274,6 @@ jobs: cargo package --locked cargo package --locked --list - # `semver-major` is trusted maintainer release intent for the whole PR, not an - # item-specific compatibility suppression. semver: name: Semver against PR base if: github.event_name == 'pull_request' @@ -300,47 +297,10 @@ jobs: env: BASE_REF: ${{ github.base_ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }} run: | - if [ -z "$BASE_REF" ] || [ -z "$BASE_SHA" ] || [ -z "$PR_LABELS_JSON" ] || [ "$PR_LABELS_JSON" = null ]; then - printf 'pull_request base and label metadata are required for the semver job\n' >&2 - exit 1 - fi - if ! semver_major="$( - printf '%s\n' "$PR_LABELS_JSON" \ - | jq -er 'if type != "array" or any(.[]; type != "string") then error("invalid PR labels") elif index("semver-major") != null then "true" else "false" end' - )"; then - printf 'pull_request label metadata is malformed\n' >&2 - exit 1 - fi - + printf 'Comparing against %s at %s\n' "$BASE_REF" "$BASE_SHA" git cat-file -e "${BASE_SHA}^{commit}" - if [ "$semver_major" = true ]; then - # Only stable SemVer tags on the PR-base lineage can define the major baseline. - mapfile -t version_10_tags < <(git tag --list 'v10.*' --sort=-version:refname) - latest_version_10_ancestor= - for tag in "${version_10_tags[@]}"; do - if [[ "$tag" =~ ^v10\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] \ - && git cat-file -e "${tag}^{commit}" \ - && git merge-base --is-ancestor "$tag" "$BASE_SHA"; then - latest_version_10_ancestor="$tag" - break - fi - done - if [ -z "$latest_version_10_ancestor" ]; then - printf 'no stable version-10 tag is an ancestor of PR base %s\n' "$BASE_SHA" >&2 - exit 1 - fi - ancestor_sha="$(git rev-parse "${latest_version_10_ancestor}^{commit}")" - printf 'PR base: %s at %s\n' "$BASE_REF" "$BASE_SHA" - printf 'Baseline: latest stable version-10 ancestor %s at %s\n' "$latest_version_10_ancestor" "$ancestor_sha" - printf 'Selected release type: major (trusted semver-major label)\n' - cargo semver-checks check-release --all-features --baseline-rev "$latest_version_10_ancestor" --release-type major - else - printf 'Baseline: PR base %s at %s\n' "$BASE_REF" "$BASE_SHA" - printf 'Selected release type: inferred by cargo-semver-checks (semver-major absent)\n' - cargo semver-checks check-release --all-features --baseline-rev "$BASE_SHA" - fi + cargo semver-checks check-release --all-features --baseline-rev "$BASE_SHA" formatting: name: Fmt check From 1d50d7c0a2b4e2836e5f7222c0903543a2594b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 03:28:26 +1300 Subject: [PATCH 105/114] test(windows): decouple JobObject extraction fixture Co-Authored-By: Claude Code --- .../tokio_windows/job_object_kill_on_drop.rs | 797 +++++++++++++++--- 1 file changed, 659 insertions(+), 138 deletions(-) diff --git a/tests/tokio_windows/job_object_kill_on_drop.rs b/tests/tokio_windows/job_object_kill_on_drop.rs index 493f117..44391c3 100644 --- a/tests/tokio_windows/job_object_kill_on_drop.rs +++ b/tests/tokio_windows/job_object_kill_on_drop.rs @@ -1,58 +1,184 @@ use std::{ - io::{ErrorKind, Write}, - process::Command as StdCommand, - time::Instant, + fmt, fs, + io::{Error, ErrorKind, Result}, + path::{Path, PathBuf}, + process::{Child as StdChild, Command as StdCommand, Stdio}, + time::{Duration, Instant}, }; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use windows::Win32::{ - Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}, + Foundation::{ + CloseHandle, HANDLE, STILL_ACTIVE, WAIT_EVENT, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, System::Threading::{ - OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, WaitForSingleObject, + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + PROCESS_TERMINATE, TerminateProcess, WaitForSingleObject, }, }; use super::prelude::*; const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const EXIT_TIMEOUT: Duration = Duration::from_secs(5); -const DESCENDANT_MARKER: &str = "PW-DESCENDANT:"; +const LEAF_SELF_DEADLINE: Duration = Duration::from_secs(30); +const DIRECT_SELF_DEADLINE: Duration = Duration::from_secs(35); +const CONTROL_DIRECTORY_ENV: &str = "PROCESS_WRAP_JOB_EXTRACTION_CONTROL_DIRECTORY"; +const DIRECT_HELPER: &str = "tokio_windows::job_object_kill_on_drop::descendant_parent"; +const LEAF_HELPER: &str = "tokio_windows::job_object_kill_on_drop::descendant_leaf"; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] enum Order { KillOnDropFirst, JobObjectFirst, } -struct ProcessGuard(Option); +impl Order { + fn label(self) -> &'static str { + match self { + Self::KillOnDropFirst => "KillOnDrop then JobObject", + Self::JobObjectFirst => "JobObject then KillOnDrop", + } + } + + fn error(self, phase: &str, error: impl fmt::Display) -> Error { + Error::other(format!("{}: {phase}: {error}", self.label())) + } + + fn failure(self, message: impl fmt::Display) -> Error { + Error::other(format!("{}: {message}", self.label())) + } + + fn wrap(self, command: &mut CommandWrap) { + match self { + Self::KillOnDropFirst => { + command.wrap(KillOnDrop).wrap(JobObject); + } + Self::JobObjectFirst => { + command.wrap(JobObject).wrap(KillOnDrop); + } + } + } +} + +#[derive(Debug)] +struct ProcessGuard { + label: &'static str, + handle: Option, +} impl ProcessGuard { - fn open(pid: u32) -> Result { + fn open(label: &'static str, pid: u32, order: Order) -> Result { // SAFETY: success returns a new process handle immediately owned by this guard. - let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } - .map_err(std::io::Error::other)?; - Ok(Self(Some(handle))) + let handle = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, + false, + pid, + ) + } + .map_err(|error| order.error(&format!("opening {label} process {pid}"), error))?; + Ok(Self { + label, + handle: Some(handle), + }) } fn handle(&self) -> HANDLE { - self.0 - .expect("only ProcessGuard::disarm clears the handle, and it consumes the guard") + self.handle + .expect("only ProcessGuard::disarm clears the handle") } - fn has_exited(&self) -> Result { + fn wait_state(&self) -> Result { // SAFETY: this guard owns the live process handle for the nonblocking wait. let wait = unsafe { WaitForSingleObject(self.handle(), 0) }; if wait == WAIT_FAILED { - Err(std::io::Error::last_os_error()) + Err(Error::last_os_error()) + } else { + Ok(wait) + } + } + + fn exit_code(&self) -> Result { + let mut code = 0; + // SAFETY: this guard owns the live process handle and `code` is writable for the call. + unsafe { GetExitCodeProcess(self.handle(), &mut code) }.map_err(Error::other)?; + Ok(code) + } + + fn require_live(&self, order: Order, phase: &str) -> Result<()> { + let wait = self.wait_state().map_err(|error| { + order.error(&format!("querying {} during {phase}", self.label), error) + })?; + let code = self.exit_code().map_err(|error| { + order.error( + &format!("reading {} exit code during {phase}", self.label), + error, + ) + })?; + if wait == WAIT_TIMEOUT && code == STILL_ACTIVE.0 as u32 { + Ok(()) } else { - Ok(wait == WAIT_OBJECT_0) + Err(order.failure(format!( + "{} was not live during {phase}: wait={:#x}, exit_code={code:#x}", + self.label, wait.0 + ))) } } - fn disarm(mut self) -> Result<()> { - if let Some(handle) = self.0.take() { + async fn wait_for_exit(&self, order: Order, phase: &str) -> Result { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + let wait = self.wait_state().map_err(|error| { + order.error(&format!("waiting for {} during {phase}", self.label), error) + })?; + if wait == WAIT_OBJECT_0 { + return self.exit_code().map_err(|error| { + order.error( + &format!("reading {} exit code after {phase}", self.label), + error, + ) + }); + } + if wait != WAIT_TIMEOUT { + return Err(order.failure(format!( + "unexpected wait result {:#x} for {} during {phase}", + wait.0, self.label + ))); + } + if Instant::now() >= deadline { + let code = self.exit_code().unwrap_or(u32::MAX); + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "{}: {} did not exit during {phase}; exit_code={code:#x}", + order.label(), + self.label + ), + )); + } + sleep(Duration::from_millis(10)).await; + } + } + + fn describe(&self) -> String { + let wait = self.wait_state().map_or_else( + |error| format!("error({error})"), + |wait| format!("{:#x}", wait.0), + ); + let code = self.exit_code().map_or_else( + |error| format!("error({error})"), + |code| format!("{code:#x}"), + ); + format!("{}[wait={wait}, exit_code={code}]", self.label) + } + + fn disarm(mut self, order: Order) -> Result<()> { + if let Some(handle) = self.handle.take() { // SAFETY: taking the guard's owned handle removes the `Drop` close path. - unsafe { CloseHandle(handle) }?; + unsafe { CloseHandle(handle) }.map_err(|error| { + order.error(&format!("closing {} process handle", self.label), error) + })?; } Ok(()) } @@ -60,158 +186,553 @@ impl ProcessGuard { impl Drop for ProcessGuard { fn drop(&mut self) { - if let Some(handle) = self.0.take() { - // SAFETY: the guard still owns this handle and has taken it out of the later drop path. + if let Some(handle) = self.handle.take() { + // SAFETY: the guard still owns this handle and uses its termination right only for cleanup. unsafe { TerminateProcess(handle, 1) }.ok(); - // SAFETY: the same taken handle remains live until this close. + // SAFETY: the same handle remains live; this finite wait bounds failure cleanup. + unsafe { WaitForSingleObject(handle, 1_000) }; + // SAFETY: the handle was taken from this sole owner and is closed exactly once. unsafe { CloseHandle(handle) }.ok(); } } } -async fn descendant_pid(child: &mut dyn ChildWrapper) -> Result { - let stdout = child.stdout().take().ok_or_else(|| { - std::io::Error::new( - ErrorKind::BrokenPipe, - "descendant helper stdout was not piped", +#[derive(Debug)] +struct Protocol { + directory: PathBuf, +} + +impl Protocol { + fn new(directory: &Path) -> Self { + Self { + directory: directory.to_owned(), + } + } + + fn from_environment() -> Result { + let directory = std::env::var_os(CONTROL_DIRECTORY_ENV) + .ok_or_else(|| Error::other("JobObject extraction control directory is missing"))?; + Ok(Self { + directory: directory.into(), + }) + } + + fn pid(&self) -> PathBuf { + self.directory.join("leaf.pid") + } + + fn ready(&self) -> PathBuf { + self.directory.join("leaf.ready") + } + + fn release(&self) -> PathBuf { + self.directory.join("leaf.release") + } + + fn released(&self) -> PathBuf { + self.directory.join("leaf.released") + } + + fn deadline_failure(&self) -> PathBuf { + self.directory.join("leaf.deadline") + } + + fn ping_path(&self, sequence: usize) -> PathBuf { + self.directory.join(format!("leaf.ping.{sequence}")) + } + + fn ack(&self, sequence: usize) -> PathBuf { + self.directory.join(format!("leaf.ack.{sequence}")) + } + + async fn wait_for_content( + &self, + path: &Path, + expected: &str, + timeout: Duration, + order: Order, + phase: &str, + ) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + match fs::read_to_string(path) { + Ok(content) if content == expected => return Ok(()), + Ok(content) if Instant::now() >= deadline => { + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "{}: {phase} had unexpected marker content {content:?}; expected {expected:?}", + order.label() + ), + )); + } + Err(error) if error.kind() != ErrorKind::NotFound => { + return Err(order.error(&format!("reading {phase}"), error)); + } + _ if Instant::now() >= deadline => { + return Err(Error::new( + ErrorKind::TimedOut, + format!("{}: timed out waiting for {phase}", order.label()), + )); + } + _ => sleep(Duration::from_millis(10)).await, + } + } + } + + async fn wait_for_pid(&self, order: Order) -> Result { + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + match fs::read_to_string(self.pid()) { + Ok(pid) => match pid.trim().parse::() { + Ok(pid) => return Ok(pid), + Err(error) if Instant::now() >= deadline => { + return Err(order.error("parsing the leaf PID marker", error)); + } + Err(_) => {} + }, + Err(error) if error.kind() != ErrorKind::NotFound => { + return Err(order.error("reading the leaf PID marker", error)); + } + Err(_) => {} + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + format!("{}: leaf did not publish its PID", order.label()), + )); + } + sleep(Duration::from_millis(10)).await; + } + } + + async fn wait_until_ready(&self, pid: u32, order: Order) -> Result<()> { + self.wait_for_content( + &self.ready(), + &format!("ready:{pid}"), + STARTUP_TIMEOUT, + order, + "the leaf-owned ready marker", ) - })?; - let mut lines = BufReader::new(stdout).lines(); - let result = tokio::time::timeout(STARTUP_TIMEOUT, async { - let mut transcript = String::new(); - while let Some(line) = lines.next_line().await? { - if let Some(pid) = line.strip_prefix(DESCENDANT_MARKER) { - return pid.trim().parse().map_err(std::io::Error::other); + .await + } + + async fn ping(&self, sequence: usize, order: Order, phase: &str) -> Result<()> { + let request = self.ping_path(sequence); + let ack = self.ack(sequence); + if request.exists() || ack.exists() { + return Err(order.failure(format!( + "stale ping state existed before {phase}: request={}, ack={}", + request.exists(), + ack.exists() + ))); + } + let token = format!("{}:{sequence}", order.label()); + publish_file(&request, &token).map_err(|error| { + order.error(&format!("publishing ping {sequence} during {phase}"), error) + })?; + self.wait_for_content( + &ack, + &token, + RESPONSE_TIMEOUT, + order, + &format!("leaf acknowledgement {sequence} during {phase}"), + ) + .await + } + + fn publish_release(&self, order: Order) -> Result<()> { + publish_file(&self.release(), "release") + .map_err(|error| order.error("publishing the leaf release", error)) + } + + fn require_released(&self, pid: u32, order: Order) -> Result<()> { + let content = fs::read_to_string(self.released()) + .map_err(|error| order.error("reading the leaf release marker", error))?; + if content == format!("released:{pid}") { + Ok(()) + } else { + Err(order.failure(format!( + "unexpected leaf release marker {content:?} for PID {pid}" + ))) + } + } + + fn diagnostics(&self) -> String { + let mut entries = Vec::new(); + for (label, path) in [ + ("pid", self.pid()), + ("ready", self.ready()), + ("release", self.release()), + ("released", self.released()), + ("deadline", self.deadline_failure()), + ] { + entries.push(marker_diagnostic(label, &path)); + } + for sequence in 0..3 { + entries.push(marker_diagnostic( + &format!("ping.{sequence}"), + &self.ping_path(sequence), + )); + entries.push(marker_diagnostic( + &format!("ack.{sequence}"), + &self.ack(sequence), + )); + } + entries.join(", ") + } +} + +fn publish_file(path: &Path, content: &str) -> Result<()> { + let temporary = path.with_extension("publishing"); + fs::write(&temporary, content)?; + fs::rename(temporary, path) +} + +fn marker_diagnostic(label: &str, path: &Path) -> String { + match fs::read_to_string(path) { + Ok(content) => format!("{label}={content:?}"), + Err(error) if error.kind() == ErrorKind::NotFound => format!("{label}=missing"), + Err(error) => format!("{label}=error({error})"), + } +} + +struct TreeFixture { + _order: Order, + _directory: tempfile::TempDir, + protocol: Protocol, + direct: Option, + descendant: Option, +} + +impl TreeFixture { + fn new(order: Order) -> Result { + let directory = tempfile::tempdir() + .map_err(|error| order.error("creating control directory", error))?; + let protocol = Protocol::new(directory.path()); + Ok(Self { + _order: order, + _directory: directory, + protocol, + direct: None, + descendant: None, + }) + } + + fn install_direct(&mut self, pid: u32, order: Order) -> Result<()> { + self.direct = Some(ProcessGuard::open("direct", pid, order)?); + Ok(()) + } + + fn install_descendant(&mut self, pid: u32, order: Order) -> Result<()> { + self.descendant = Some(ProcessGuard::open("descendant", pid, order)?); + Ok(()) + } + + fn direct(&self, order: Order) -> Result<&ProcessGuard> { + self.direct + .as_ref() + .ok_or_else(|| order.failure("the direct-process guard was not installed")) + } + + fn descendant(&self, order: Order) -> Result<&ProcessGuard> { + self.descendant + .as_ref() + .ok_or_else(|| order.failure("the descendant-process guard was not installed")) + } + + fn disarm_direct(&mut self, order: Order) -> Result<()> { + self.direct + .take() + .ok_or_else(|| order.failure("the direct-process guard was already removed"))? + .disarm(order) + } + + fn disarm_descendant(&mut self, order: Order) -> Result<()> { + self.descendant + .take() + .ok_or_else(|| order.failure("the descendant-process guard was already removed"))? + .disarm(order) + } + + fn finish(self, result: Result<()>, order: Order) -> Result<()> { + match result { + Ok(()) => Ok(()), + Err(error) => { + let process_state = [ + self.direct.as_ref().map(ProcessGuard::describe), + self.descendant.as_ref().map(ProcessGuard::describe), + ] + .into_iter() + .flatten() + .collect::>() + .join(", "); + Err(Error::new( + error.kind(), + format!( + "{error}; {} fixture state: processes=[{process_state}], markers=[{}]", + order.label(), + self.protocol.diagnostics() + ), + )) } - transcript.push_str(&line); - transcript.push('\n'); } + } +} - let status = child.wait().await?; - let mut stderr = String::new(); - if let Some(mut pipe) = child.stderr().take() { - pipe.read_to_string(&mut stderr).await?; +impl Drop for TreeFixture { + fn drop(&mut self) { + if self.direct.is_some() || self.descendant.is_some() { + eprintln!( + "{} fixture cleanup: processes=[{}], markers=[{}]", + self._order.label(), + [ + self.direct.as_ref().map(ProcessGuard::describe), + self.descendant.as_ref().map(ProcessGuard::describe), + ] + .into_iter() + .flatten() + .collect::>() + .join(", "), + self.protocol.diagnostics() + ); } - Err(std::io::Error::other(format!( - "descendant helper exited with {status} before reporting its pid; stdout: {transcript}; stderr: {stderr}" - ))) - }) - .await; + // Terminate the descendant first so no surviving child can outlive cleanup of the direct helper. + drop(self.descendant.take()); + drop(self.direct.take()); + } +} + +struct HelperChild(Option); + +impl HelperChild { + fn child_mut(&mut self) -> &mut StdChild { + self.0 + .as_mut() + .expect("the helper child remains owned until it exits") + } + + fn disarm(&mut self) { + self.0.take(); + } +} + +impl Drop for HelperChild { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn direct_command(protocol: &Protocol) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args(["--exact", DIRECT_HELPER, "--ignored", "--nocapture"]) + .env(CONTROL_DIRECTORY_ENV, &protocol.directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) +} + +async fn prepare_tree(fixture: &mut TreeFixture, order: Order) -> Result> { + let mut command = direct_command(&fixture.protocol) + .map_err(|error| order.error("constructing the direct helper command", error))?; + order.wrap(&mut command); + let child = command + .spawn() + .map_err(|error| order.error("spawning the wrapped direct helper", error))?; + let direct_pid = child + .id() + .ok_or_else(|| order.failure("the direct helper did not retain its PID"))?; + fixture.install_direct(direct_pid, order)?; + let descendant_pid = fixture.protocol.wait_for_pid(order).await?; + fixture.install_descendant(descendant_pid, order)?; + fixture + .protocol + .wait_until_ready(descendant_pid, order) + .await?; + fixture + .direct(order)? + .require_live(order, "pre-extraction readiness")?; + fixture + .descendant(order)? + .require_live(order, "pre-extraction readiness")?; + fixture + .protocol + .ping(0, order, "pre-extraction readiness") + .await?; + Ok(child) +} + +async fn run_consuming_order_inner(fixture: &mut TreeFixture, order: Order) -> Result<()> { + let child = prepare_tree(fixture, order).await?; + let mut lower = child.into_inner(); + if lower + .try_wait() + .map_err(|error| order.error("querying the returned lower child", error))? + .is_some() + { + return Err(order.failure("the returned lower child exited during extraction")); + } + fixture + .direct(order)? + .require_live(order, "post-extraction verification")?; + fixture + .descendant(order)? + .require_live(order, "post-extraction verification")?; + fixture + .protocol + .ping(1, order, "post-extraction verification") + .await?; + + drop(lower); + let direct_code = fixture + .direct(order)? + .wait_for_exit(order, "lower direct-child KillOnDrop") + .await?; + fixture.disarm_direct(order)?; + fixture + .descendant(order)? + .require_live(order, "post-direct-drop verification")?; + fixture + .protocol + .ping(2, order, "post-direct-drop verification") + .await?; - match result { - Ok(result) => result, - Err(_) => Err(std::io::Error::new( - ErrorKind::TimedOut, - format!( - "descendant helper did not report its pid; process status: {:?}", - child.try_wait()? - ), - )), + fixture.protocol.publish_release(order)?; + let descendant_code = fixture + .descendant(order)? + .wait_for_exit(order, "explicit descendant release") + .await?; + if descendant_code != 0 { + return Err(order.failure(format!( + "the explicitly released descendant exited with {descendant_code:#x}; direct exit code was {direct_code:#x}" + ))); } + let descendant_pid = fs::read_to_string(fixture.protocol.pid()) + .map_err(|error| order.error("re-reading the descendant PID", error))? + .trim() + .parse::() + .map_err(|error| order.error("re-parsing the descendant PID", error))?; + fixture.protocol.require_released(descendant_pid, order)?; + fixture.disarm_descendant(order)?; + Ok(()) } -async fn wait_for_process_exit(handle: HANDLE) -> Result<()> { - let deadline = Instant::now() + EXIT_TIMEOUT; +async fn run_consuming_order(order: Order) -> Result<()> { + let mut fixture = TreeFixture::new(order)?; + let result = run_consuming_order_inner(&mut fixture, order).await; + fixture.finish(result, order) +} + +async fn run_installed_drop_order_inner(fixture: &mut TreeFixture, order: Order) -> Result<()> { + let child = prepare_tree(fixture, order).await?; + drop(child); + fixture + .direct(order)? + .wait_for_exit(order, "installed JobObject drop") + .await?; + fixture + .descendant(order)? + .wait_for_exit(order, "installed JobObject drop") + .await?; + fixture.disarm_direct(order)?; + fixture.disarm_descendant(order)?; + Ok(()) +} + +async fn run_installed_drop_order(order: Order) -> Result<()> { + let mut fixture = TreeFixture::new(order)?; + let result = run_installed_drop_order_inner(&mut fixture, order).await; + fixture.finish(result, order) +} + +#[test] +#[ignore = "subprocess helper"] +fn descendant_leaf() -> Result<()> { + let protocol = Protocol::from_environment()?; + let pid = std::process::id(); + let deadline = Instant::now() + LEAF_SELF_DEADLINE; + let mut next_ping = 0; + + // The controlled wait state is fully initialized before the leaf publishes its own PID/readiness. + publish_file(&protocol.pid(), &pid.to_string())?; + publish_file(&protocol.ready(), &format!("ready:{pid}"))?; loop { - // SAFETY: the caller's `ProcessGuard` keeps this handle live for the call. - let wait = unsafe { WaitForSingleObject(handle, 0) }; - if wait == WAIT_OBJECT_0 { + if protocol.release().exists() { + publish_file(&protocol.released(), &format!("released:{pid}"))?; return Ok(()); } - if wait == WAIT_FAILED { - return Err(std::io::Error::last_os_error()); + let ping = protocol.ping_path(next_ping); + if ping.exists() { + let token = fs::read_to_string(&ping)?; + publish_file(&protocol.ack(next_ping), &token)?; + next_ping += 1; } if Instant::now() >= deadline { - return Err(std::io::Error::new( + publish_file(&protocol.deadline_failure(), "leaf self-deadline expired")?; + return Err(Error::new( ErrorKind::TimedOut, - "descendant survived dropping the job", + "JobObject extraction leaf exceeded its self-deadline", )); } - sleep(Duration::from_millis(10)).await; + std::thread::sleep(Duration::from_millis(10)); } } #[test] #[ignore = "subprocess helper"] -fn descendant_leaf() { - std::thread::sleep(Duration::from_secs(300)); +fn descendant_parent() -> Result<()> { + let protocol = Protocol::from_environment()?; + let descendant = StdCommand::new(std::env::current_exe()?) + .args(["--exact", LEAF_HELPER, "--ignored", "--nocapture"]) + .env(CONTROL_DIRECTORY_ENV, &protocol.directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let mut descendant = HelperChild(Some(descendant)); + let deadline = Instant::now() + DIRECT_SELF_DEADLINE; + loop { + if let Some(status) = descendant.child_mut().try_wait()? { + descendant.disarm(); + return if status.success() { + Ok(()) + } else { + Err(Error::other(format!( + "JobObject extraction leaf exited with {status}" + ))) + }; + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "JobObject extraction direct helper exceeded its self-deadline", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } } -#[test] -#[ignore = "subprocess helper"] -fn descendant_parent() { - let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) - .args(["descendant_leaf", "--ignored", "--nocapture"]) - .spawn() - .unwrap(); - println!("{DESCENDANT_MARKER}{}", descendant.id()); - std::io::stdout().flush().unwrap(); - descendant.wait().unwrap(); +#[tokio::test] +async fn consuming_job_object_after_kill_on_drop_preserves_descendant() -> Result<()> { + run_consuming_order(Order::KillOnDropFirst).await } #[tokio::test] -async fn consuming_job_object_leaves_only_direct_child_kill_on_drop() -> Result<()> { - for order in [Order::KillOnDropFirst, Order::JobObjectFirst] { - let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { - command - .args(["descendant_parent", "--ignored", "--nocapture"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - }); - match order { - Order::KillOnDropFirst => { - command.wrap(KillOnDrop).wrap(JobObject); - } - Order::JobObjectFirst => { - command.wrap(JobObject).wrap(KillOnDrop); - } - } - - let mut child = command.spawn()?; - let direct = ProcessGuard::open(child.id().expect("the direct child has a PID"))?; - let descendant_pid = descendant_pid(child.as_mut()).await?; - let descendant = ProcessGuard::open(descendant_pid)?; - let mut lower = child.into_inner(); - assert_eq!(lower.try_wait()?, None); - assert!(!direct.has_exited()?); - assert!(!descendant.has_exited()?); - - drop(lower); - wait_for_process_exit(direct.handle()).await?; - direct.disarm()?; - assert!( - !descendant.has_exited()?, - "consuming JobObject must leave lower KillOnDrop scoped to the direct child" - ); - drop(descendant); - } - Ok(()) +async fn consuming_job_object_before_kill_on_drop_preserves_descendant() -> Result<()> { + run_consuming_order(Order::JobObjectFirst).await } #[tokio::test] -async fn job_detects_kill_on_drop_in_both_orders() -> Result<()> { - for order in [Order::KillOnDropFirst, Order::JobObjectFirst] { - let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { - command - .args(["descendant_parent", "--ignored", "--nocapture"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - }); - match order { - Order::KillOnDropFirst => { - command.wrap(KillOnDrop).wrap(JobObject); - } - Order::JobObjectFirst => { - command.wrap(JobObject).wrap(KillOnDrop); - } - } +async fn installed_job_object_after_kill_on_drop_terminates_descendant() -> Result<()> { + run_installed_drop_order(Order::KillOnDropFirst).await +} - let mut child = command.spawn()?; - let pid = descendant_pid(child.as_mut()).await?; - let guard = ProcessGuard::open(pid)?; - drop(child); - wait_for_process_exit(guard.handle()).await?; - guard.disarm()?; - } - Ok(()) +#[tokio::test] +async fn installed_job_object_before_kill_on_drop_terminates_descendant() -> Result<()> { + run_installed_drop_order(Order::JobObjectFirst).await } From ad52bbe775b4fb14774d4504808e2827bc68aff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 03:39:52 +1300 Subject: [PATCH 106/114] fix(windows): preserve JobObject limit state Co-Authored-By: Claude Code --- src/lib.rs | 21 +++- src/tokio/job_object.rs | 12 +- src/windows.rs | 247 ++++++++++++++++++++++++++++++++++------ 3 files changed, 240 insertions(+), 40 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c4005a8..7cecd30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -675,7 +675,9 @@ pub(crate) mod test_allocator { #[derive(Default)] pub(crate) struct PostTransitionProbe { pub(crate) active: AtomicBool, - pub(crate) transitioned: AtomicBool, + queried: AtomicBool, + query_before_transition: AtomicBool, + transitioned: AtomicBool, allocator_callback: AtomicBool, operation: AtomicBool, } @@ -684,11 +686,25 @@ pub(crate) mod test_allocator { #[cfg(feature = "job-object")] pub(crate) fn arm(&self) { assert!(!self.active.swap(true, Ordering::SeqCst)); + self.queried.store(false, Ordering::SeqCst); + self.query_before_transition.store(false, Ordering::SeqCst); self.transitioned.store(false, Ordering::SeqCst); self.allocator_callback.store(false, Ordering::SeqCst); self.operation.store(false, Ordering::SeqCst); } + #[cfg(feature = "job-object")] + pub(crate) fn observe_query(&self) { + self.queried.store(true, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_transition(&self) { + self.query_before_transition + .store(self.queried.load(Ordering::SeqCst), Ordering::SeqCst); + self.transitioned.store(true, Ordering::SeqCst); + } + pub(crate) fn observe_operation(&self) { if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { self.operation.store(true, Ordering::SeqCst); @@ -702,9 +718,10 @@ pub(crate) mod test_allocator { } #[cfg(feature = "job-object")] - pub(crate) fn finish(&self) -> (bool, bool, bool) { + pub(crate) fn finish(&self) -> (bool, bool, bool, bool) { self.active.store(false, Ordering::SeqCst); ( + self.query_before_transition.load(Ordering::SeqCst), self.transitioned.load(Ordering::SeqCst), self.allocator_callback.load(Ordering::SeqCst), self.operation.load(Ordering::SeqCst), diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index cc05d5f..f82a3c2 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -1192,8 +1192,12 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); - let (transitioned, allocator_callback, later_operation) = + let (query_before_transition, transitioned, allocator_callback, later_operation) = finish_owner_transition_probe(); + assert!( + !query_before_transition, + "the injected final-owner failure must occur before the native limit query" + ); assert!(!transitioned, "the native policy transition must not run"); assert!(!allocator_callback); assert!(!later_operation); @@ -1294,7 +1298,7 @@ mod tests { arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); - let (transitioned, allocator_callback, later_operation) = + let (query_before_transition, transitioned, allocator_callback, later_operation) = finish_owner_transition_probe(); let mut succeeded = false; match outcome { @@ -1318,6 +1322,10 @@ mod tests { clear_owner_events(), "the owner event barrier remained armed" ); + assert!( + query_before_transition, + "the final-owner limit query must complete before the terminal native set" + ); assert!(transitioned, "the native final-owner transition completed"); assert!( !allocator_callback, diff --git a/src/windows.rs b/src/windows.rs index a8a819f..8189ddd 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -142,6 +142,155 @@ mod job_wait_tests { } } +#[cfg(test)] +mod job_limit_tests { + use windows::Win32::System::JobObjects::{ + JOB_OBJECT_LIMIT_ACTIVE_PROCESS, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + JOB_OBJECT_LIMIT_PROCESS_MEMORY, + }; + + use super::*; + + fn query_extended_limits(job: JobHandle) -> Result { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: `job` is borrowed from the live owner below; `info` is initialized writable storage of + // exactly the size required by `JobObjectExtendedLimitInformation` and outlives the call. + unsafe { + QueryInformationJobObject( + Some(job.0), + JobObjectExtendedLimitInformation, + &mut info as *mut _ as _, + std::mem::size_of_val(&info) + .try_into() + .expect("extended job limits cannot exceed a DWORD"), + None, + ) + } + .map_err(Error::other)?; + Ok(info) + } + + fn set_extended_limits( + job: JobHandle, + info: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + ) -> Result<()> { + // SAFETY: `job` is borrowed from the live owner below; `info` is initialized readable storage of + // exactly the size required by `JobObjectExtendedLimitInformation` and outlives the call. + unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + info as *const _ as _, + std::mem::size_of_val(info) + .try_into() + .expect("extended job limits cannot exceed a DWORD"), + ) + } + .map_err(Error::other) + } + + fn running_under_wine() -> bool { + std::env::var_os("WINELOADER").is_some() && std::env::var_os("WINEDLLDIR0").is_some() + } + + #[test] + fn kill_on_close_bit_mutation_preserves_complete_structure() { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + info.BasicLimitInformation.PerProcessUserTimeLimit = 11; + info.BasicLimitInformation.PerJobUserTimeLimit = 12; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_BREAKAWAY_OK + | JOB_OBJECT_LIMIT_PROCESS_MEMORY; + info.BasicLimitInformation.MinimumWorkingSetSize = 13; + info.BasicLimitInformation.MaximumWorkingSetSize = 14; + info.BasicLimitInformation.ActiveProcessLimit = 15; + info.BasicLimitInformation.Affinity = 16; + info.BasicLimitInformation.PriorityClass = 17; + info.BasicLimitInformation.SchedulingClass = 18; + info.IoInfo.ReadOperationCount = 19; + info.IoInfo.WriteOperationCount = 20; + info.IoInfo.OtherOperationCount = 21; + info.IoInfo.ReadTransferCount = 22; + info.IoInfo.WriteTransferCount = 23; + info.IoInfo.OtherTransferCount = 24; + info.ProcessMemoryLimit = 25; + info.JobMemoryLimit = 26; + info.PeakProcessMemoryUsed = 27; + info.PeakJobMemoryUsed = 28; + let original = info; + + set_job_kill_on_drop_flag(&mut info, true); + let mut enabled = original; + enabled.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + assert_eq!(info, enabled); + + set_job_kill_on_drop_flag(&mut info, false); + assert_eq!(info, original); + } + + #[test] + fn kill_on_close_transition_preserves_peer_limit_fields() -> Result<()> { + if running_under_wine() { + // Wine's current extended-limit query zeroes the output structure, so the native preservation + // oracle is not available there. The structure-level test above remains selected under Wine. + return Ok(()); + } + // SAFETY: null attributes and name request a new private job; the successful handle is owned below. + let owned_job = OwnedHandle(unsafe { CreateJobObjectW(None, None) }.map_err(Error::other)?); + let job = JobHandle(owned_job.0); + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + info.BasicLimitInformation.ActiveProcessLimit = 7; + set_extended_limits(job, &info)?; + let initial = query_extended_limits(job)?; + assert!( + initial + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS), + "the isolated test job must install the peer limit before toggling kill-on-close" + ); + assert_eq!(initial.BasicLimitInformation.ActiveProcessLimit, 7); + + set_job_kill_on_drop(job, true)?; + let enabled = query_extended_limits(job)?; + assert!( + enabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS), + "enabling kill-on-close must preserve the peer limit flag" + ); + assert!( + enabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), + "enabling kill-on-close must install its own flag" + ); + assert_eq!(enabled.BasicLimitInformation.ActiveProcessLimit, 7); + + set_job_kill_on_drop(job, false)?; + let disabled = query_extended_limits(job)?; + assert!( + disabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS), + "disabling kill-on-close must preserve the peer limit flag" + ); + assert!( + !disabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), + "disabling kill-on-close must clear only its own flag" + ); + assert_eq!(disabled.BasicLimitInformation.ActiveProcessLimit, 7); + Ok(()) + } +} + #[cfg(test)] pub(crate) mod test_support { use std::{ @@ -407,7 +556,7 @@ pub(crate) mod test_support { crate::test_allocator::current_probe() } - pub fn finish_owner_transition_probe() -> (bool, bool, bool) { + pub fn finish_owner_transition_probe() -> (bool, bool, bool, bool) { crate::test_allocator::current_probe().finish() } @@ -764,60 +913,91 @@ pub(crate) fn set_job_kill_on_drop_observed( set_job_kill_on_drop_inner(job, kill_on_drop, Some(probe)) } +fn query_job_extended_limits(job: JobHandle) -> Result { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + let info_size = std::mem::size_of_val(&info) + .try_into() + .expect("extended JobObject limit information cannot exceed a DWORD"); + // SAFETY: + // - `job` is borrowed from a live `JobPort` or newly created job owner and carries query access. + // - `info` is fully initialized, writable `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` storage. + // - `info_size` is its exact byte size, and the storage outlives this synchronous call. + // On success the information-class contract initializes the current complete extended-limit state. + unsafe { + QueryInformationJobObject( + Some(job.0), + JobObjectExtendedLimitInformation, + &mut info as *mut _ as _, + info_size, + None, + ) + } + .map_err(Error::other)?; + Ok(info) +} + +fn set_job_kill_on_drop_flag(info: &mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION, kill_on_drop: bool) { + if kill_on_drop { + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } else { + info.BasicLimitInformation.LimitFlags &= !JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } +} + fn set_job_kill_on_drop_inner( job: JobHandle, kill_on_drop: bool, #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, ) -> Result<()> { - #[cfg(feature = "tracing")] - { - let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - if kill_on_drop { - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - } - debug!( - kill_on_drop, - ?info, - "setting SetInformationJobObject(limit)" - ); + let mut info = query_job_extended_limits(job)?; + #[cfg(test)] + if let Some(probe) = probe { + probe.observe_query(); } - set_job_kill_on_drop_native( - job, + set_job_kill_on_drop_flag(&mut info, kill_on_drop); + #[cfg(feature = "tracing")] + debug!( kill_on_drop, + ?info, + "setting SetInformationJobObject(limit)" + ); + set_job_extended_limits_native( + job, + &info, #[cfg(test)] probe, ) } -fn set_job_kill_on_drop_native( +fn set_job_extended_limits_native( job: JobHandle, - kill_on_drop: bool, + info: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION, #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, ) -> Result<()> { - let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - if kill_on_drop { - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - } - // No caller-controlled callback runs in this helper. After a successful native transition it only - // records a test-only atomic observation and returns. - // SAFETY: `job` is live, and initialized `info` has the reported size and outlives the call. + let info_size = std::mem::size_of_val(info) + .try_into() + .expect("extended JobObject limit information cannot exceed a DWORD"); + // No caller-controlled callback runs in this helper. All querying, formatting, tracing, test-probe + // lookup, and size conversion have completed. After a successful native transition, this function + // performs only test-only scalar/atomic observations and result moves before returning. + // SAFETY: + // - `job` is borrowed from a live `JobPort` or newly created job owner and carries set access. + // - `info` is initialized readable `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` storage containing the + // complete queried state with only the requested bit changed. + // - `info_size` is its exact byte size, and the storage outlives this synchronous call. let result = unsafe { SetInformationJobObject( job.0, JobObjectExtendedLimitInformation, - &info as *const _ as _, - std::mem::size_of_val(&info) - .try_into() - .expect("cannot safely cast to DWORD"), + info as *const _ as _, + info_size, ) }; #[cfg(test)] if result.is_ok() && let Some(probe) = probe { - probe - .transitioned - .store(true, std::sync::atomic::Ordering::SeqCst); + probe.observe_transition(); } result.map_err(Error::other) } @@ -993,12 +1173,7 @@ fn disarm_extracted_job(job: JobHandle) -> Result<()> { test_support::observe_job_extraction_disarm(false); return Err(Error::other("injected extracted JobObject disarm failure")); } - let result = set_job_kill_on_drop_native( - job, - false, - #[cfg(test)] - None, - ); + let result = set_job_kill_on_drop(job, false); #[cfg(test)] test_support::observe_job_extraction_disarm(result.is_ok()); result From e8ed9ee5b5b4b8e2db105554a936a38e85af744a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 03:48:33 +1300 Subject: [PATCH 107/114] fix(windows): verify JobObject extraction disarm Co-Authored-By: Claude Code --- src/tokio/job_object.rs | 153 +++++++++++++++++++++++++++++++++++++--- src/windows.rs | 127 ++++++++++++++++++++++++++++----- 2 files changed, 252 insertions(+), 28 deletions(-) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index f82a3c2..fe94f97 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -460,7 +460,9 @@ mod tests { job_port_last_resort_retentions, observe_descendant, publish_process_guards, record_owner_event, reset_job_extraction_faults, serial_job_extraction, set_job_custodian_start_failures, set_job_extraction_disarm_failures, - set_job_extraction_query_failures, spawn_cleanup_handle_close_count, + set_job_extraction_post_query_failures, set_job_extraction_pre_query_failures, + set_job_extraction_query_failures, set_job_extraction_still_set_observations, + spawn_cleanup_handle_close_count, }, }; @@ -970,12 +972,18 @@ mod tests { async fn live_kill_on_drop_extraction_disarms_and_closes_before_returning() -> Result<()> { let _serial = serial_job_extraction(); for job_first in [false, true] { + let baseline = job_extraction_branch_counts(); let probe = arm_job_port_close_probe(); let mut command = extraction_command(false, job_first); let child = command.spawn()?; clear_job_port_close_probe(); let mut lower = child.into_inner(); assert_eq!(probe.counts(), (1, 1)); + let verified = job_extraction_branch_counts(); + assert!(verified.disarm_attempts > baseline.disarm_attempts); + assert!(verified.set_successes > baseline.set_successes); + assert!(verified.verification_queries > baseline.verification_queries); + assert!(verified.verified_clears > baseline.verified_clears); assert_eq!(lower.try_wait()?, None); lower.start_kill()?; let status = lower.wait().await?; @@ -984,6 +992,110 @@ mod tests { Ok(()) } + #[derive(Clone, Copy, Debug)] + enum LiveDisarmFault { + PreQuery, + Set, + PostQueryFailure, + StillSet, + } + + impl LiveDisarmFault { + fn arm(self, count: usize) { + match self { + Self::PreQuery => set_job_extraction_pre_query_failures(count), + Self::Set => set_job_extraction_disarm_failures(count), + Self::PostQueryFailure => set_job_extraction_post_query_failures(count), + Self::StillSet => set_job_extraction_still_set_observations(count), + } + } + } + + async fn live_disarm_fault_enters_custody(fault: LiveDisarmFault) -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); + let worker_starts = job_custodian_worker_starts(); + let baseline = job_extraction_branch_counts(); + fault.arm(usize::MAX); + + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, true); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + assert_eq!( + probe.counts(), + (0, 0), + "{fault:?} must transfer the complete live port instead of closing it" + ); + assert_eq!(lower.try_wait()?, None); + let deadline = Instant::now() + Duration::from_secs(5); + while job_custodian_worker_starts() == worker_starts { + assert!( + Instant::now() < deadline, + "{fault:?} did not start the shared custodian" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job_custodian_worker_starts(), worker_starts + 1); + assert_eq!(probe.counts(), (0, 0)); + let unresolved = job_extraction_branch_counts(); + assert!(unresolved.disarm_attempts > baseline.disarm_attempts); + assert_eq!(unresolved.verified_clears, baseline.verified_clears); + match fault { + LiveDisarmFault::PreQuery | LiveDisarmFault::Set => { + assert_eq!(unresolved.set_successes, baseline.set_successes); + assert_eq!( + unresolved.verification_queries, + baseline.verification_queries + ); + } + LiveDisarmFault::PostQueryFailure | LiveDisarmFault::StillSet => { + assert!(unresolved.set_successes > baseline.set_successes); + assert!(unresolved.verification_queries > baseline.verification_queries); + } + } + + fault.arm(0); + wait_for_job_port_closes(&probe, (1, 1)); + let resolved = job_extraction_branch_counts(); + assert!(resolved.verified_clears > baseline.verified_clears); + assert_eq!( + lower.try_wait()?, + None, + "verified custodian disarm must preserve the lower child" + ); + lower.start_kill()?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_pre_query_failure_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::PreQuery).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_set_failure_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::Set).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_post_query_failure_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::PostQueryFailure).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_still_set_post_query_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::StillSet).await + } + #[tokio::test(flavor = "current_thread")] #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn extraction_failures_use_one_shared_custodian_then_close_every_port() -> Result<()> { @@ -1068,12 +1180,26 @@ mod tests { std::thread::sleep(Duration::from_millis(10)); } let unresolved = job_extraction_branch_counts(); - assert!(unresolved.2 > baseline.2, "custody retried native disarm"); + assert!( + unresolved.disarm_attempts > baseline.disarm_attempts, + "custody retried native disarm" + ); + assert_eq!( + unresolved.set_successes, baseline.set_successes, + "no extracted-job setter may succeed in this branch" + ); + assert_eq!( + unresolved.verification_queries, baseline.verification_queries, + "a failed setter cannot reach post-set verification" + ); + assert_eq!( + unresolved.verified_clears, baseline.verified_clears, + "no extracted-job disarm may verify clear in this branch" + ); assert_eq!( - unresolved.3, baseline.3, - "no extracted-job disarm may succeed in this branch" + unresolved.drain_observations, baseline.drain_observations, + "the live tree has not drained" ); - assert_eq!(unresolved.1, baseline.1, "the live tree has not drained"); set_job_extraction_query_failures(0); fs::File::create(&release)?; @@ -1084,17 +1210,24 @@ mod tests { wait_for_job_port_closes(&probe, (1, 1)); let drained = job_extraction_branch_counts(); assert!( - drained.0 > baseline.0, + drained.accounting_queries > baseline.accounting_queries, "authoritative accounting was queried" ); assert!( - drained.1 > baseline.1, + drained.drain_observations > baseline.drain_observations, "authoritative accounting observed drain" ); - assert!(drained.2 > baseline.2, "native disarm remained attempted"); + assert!( + drained.disarm_attempts > baseline.disarm_attempts, + "native disarm remained attempted" + ); + assert_eq!( + drained.set_successes, baseline.set_successes, + "complete closure must not be attributed to setter success" + ); assert_eq!( - drained.3, baseline.3, - "complete closure must not be attributed to disarm success" + drained.verified_clears, baseline.verified_clears, + "complete closure must not be attributed to verified disarm" ); Ok(()) } diff --git a/src/windows.rs b/src/windows.rs index 8189ddd..f5ca186 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -366,14 +366,19 @@ pub(crate) mod test_support { } static JOB_EXTRACTION_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_PRE_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); static JOB_EXTRACTION_DISARM_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_POST_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_STILL_SET_OBSERVATIONS: AtomicUsize = AtomicUsize::new(0); static JOB_CUSTODIAN_START_FAILURES: AtomicUsize = AtomicUsize::new(0); static JOB_CUSTODIAN_WORKER_STARTS: AtomicUsize = AtomicUsize::new(0); static JOB_PORT_LAST_RESORT_RETENTIONS: AtomicUsize = AtomicUsize::new(0); - static JOB_EXTRACTION_QUERY_ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_ACCOUNTING_QUERIES: AtomicUsize = AtomicUsize::new(0); static JOB_EXTRACTION_DRAIN_OBSERVATIONS: AtomicUsize = AtomicUsize::new(0); static JOB_EXTRACTION_DISARM_ATTEMPTS: AtomicUsize = AtomicUsize::new(0); - static JOB_EXTRACTION_DISARM_SUCCESSES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_SET_SUCCESSES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_VERIFICATION_QUERIES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_VERIFIED_CLEARS: AtomicUsize = AtomicUsize::new(0); thread_local! { static JOB_PORT_CLOSE_PROBE: RefCell>> = const { RefCell::new(None) }; @@ -412,27 +417,48 @@ pub(crate) mod test_support { }); } + #[derive(Clone, Copy, Debug)] + pub struct JobExtractionBranchCounts { + pub accounting_queries: usize, + pub drain_observations: usize, + pub disarm_attempts: usize, + pub set_successes: usize, + pub verification_queries: usize, + pub verified_clears: usize, + } + pub fn observe_job_extraction_query(drained: bool) { - JOB_EXTRACTION_QUERY_ATTEMPTS.fetch_add(1, Ordering::SeqCst); + JOB_EXTRACTION_ACCOUNTING_QUERIES.fetch_add(1, Ordering::SeqCst); if drained { JOB_EXTRACTION_DRAIN_OBSERVATIONS.fetch_add(1, Ordering::SeqCst); } } - pub fn observe_job_extraction_disarm(success: bool) { + pub fn observe_job_extraction_disarm_attempt() { JOB_EXTRACTION_DISARM_ATTEMPTS.fetch_add(1, Ordering::SeqCst); - if success { - JOB_EXTRACTION_DISARM_SUCCESSES.fetch_add(1, Ordering::SeqCst); - } } - pub fn job_extraction_branch_counts() -> (usize, usize, usize, usize) { - ( - JOB_EXTRACTION_QUERY_ATTEMPTS.load(Ordering::SeqCst), - JOB_EXTRACTION_DRAIN_OBSERVATIONS.load(Ordering::SeqCst), - JOB_EXTRACTION_DISARM_ATTEMPTS.load(Ordering::SeqCst), - JOB_EXTRACTION_DISARM_SUCCESSES.load(Ordering::SeqCst), - ) + pub fn observe_job_extraction_set_success() { + JOB_EXTRACTION_SET_SUCCESSES.fetch_add(1, Ordering::SeqCst); + } + + pub fn observe_job_extraction_verification_query() { + JOB_EXTRACTION_VERIFICATION_QUERIES.fetch_add(1, Ordering::SeqCst); + } + + pub fn observe_job_extraction_verified_clear() { + JOB_EXTRACTION_VERIFIED_CLEARS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_extraction_branch_counts() -> JobExtractionBranchCounts { + JobExtractionBranchCounts { + accounting_queries: JOB_EXTRACTION_ACCOUNTING_QUERIES.load(Ordering::SeqCst), + drain_observations: JOB_EXTRACTION_DRAIN_OBSERVATIONS.load(Ordering::SeqCst), + disarm_attempts: JOB_EXTRACTION_DISARM_ATTEMPTS.load(Ordering::SeqCst), + set_successes: JOB_EXTRACTION_SET_SUCCESSES.load(Ordering::SeqCst), + verification_queries: JOB_EXTRACTION_VERIFICATION_QUERIES.load(Ordering::SeqCst), + verified_clears: JOB_EXTRACTION_VERIFIED_CLEARS.load(Ordering::SeqCst), + } } pub fn set_job_extraction_query_failures(failures: usize) { @@ -443,6 +469,14 @@ pub(crate) mod test_support { take_failure(&JOB_EXTRACTION_QUERY_FAILURES) } + pub fn set_job_extraction_pre_query_failures(failures: usize) { + JOB_EXTRACTION_PRE_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_pre_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_PRE_QUERY_FAILURES) + } + pub fn set_job_extraction_disarm_failures(failures: usize) { JOB_EXTRACTION_DISARM_FAILURES.store(failures, Ordering::SeqCst); } @@ -451,6 +485,22 @@ pub(crate) mod test_support { take_failure(&JOB_EXTRACTION_DISARM_FAILURES) } + pub fn set_job_extraction_post_query_failures(failures: usize) { + JOB_EXTRACTION_POST_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_post_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_POST_QUERY_FAILURES) + } + + pub fn set_job_extraction_still_set_observations(observations: usize) { + JOB_EXTRACTION_STILL_SET_OBSERVATIONS.store(observations, Ordering::SeqCst); + } + + pub fn take_job_extraction_still_set_observation() -> bool { + take_failure(&JOB_EXTRACTION_STILL_SET_OBSERVATIONS) + } + pub fn set_job_custodian_start_failures(failures: usize) { JOB_CUSTODIAN_START_FAILURES.store(failures, Ordering::SeqCst); } @@ -477,7 +527,10 @@ pub(crate) mod test_support { pub fn reset_job_extraction_faults() { JOB_EXTRACTION_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_PRE_QUERY_FAILURES.store(0, Ordering::SeqCst); JOB_EXTRACTION_DISARM_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_POST_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_STILL_SET_OBSERVATIONS.store(0, Ordering::SeqCst); JOB_CUSTODIAN_START_FAILURES.store(0, Ordering::SeqCst); } } @@ -1168,15 +1221,53 @@ fn extracted_job_is_drained(job: JobHandle) -> Result { #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] fn disarm_extracted_job(job: JobHandle) -> Result<()> { + #[cfg(test)] + test_support::observe_job_extraction_disarm_attempt(); + #[cfg(test)] + if test_support::take_job_extraction_pre_query_failure() { + return Err(Error::other( + "injected extracted JobObject pre-set limit query failure", + )); + } + let mut info = query_job_extended_limits(job)?; + set_job_kill_on_drop_flag(&mut info, false); + #[cfg(test)] if test_support::take_job_extraction_disarm_failure() { - test_support::observe_job_extraction_disarm(false); return Err(Error::other("injected extracted JobObject disarm failure")); } - let result = set_job_kill_on_drop(job, false); + set_job_extended_limits_native( + job, + &info, + #[cfg(test)] + None, + )?; #[cfg(test)] - test_support::observe_job_extraction_disarm(result.is_ok()); - result + test_support::observe_job_extraction_set_success(); + + #[cfg(test)] + test_support::observe_job_extraction_verification_query(); + #[cfg(test)] + if test_support::take_job_extraction_post_query_failure() { + return Err(Error::other( + "injected extracted JobObject post-set limit query failure", + )); + } + let effective = query_job_extended_limits(job)?; + let kill_on_close = effective + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + #[cfg(test)] + let kill_on_close = kill_on_close || test_support::take_job_extraction_still_set_observation(); + if kill_on_close { + return Err(Error::other( + "extracted JobObject still reports kill-on-close after disarm", + )); + } + #[cfg(test)] + test_support::observe_job_extraction_verified_clear(); + Ok(()) } #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] From 6a4d4160372485432085f6a452f90f4807938eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 03:53:51 +1300 Subject: [PATCH 108/114] test(windows): prove terminal JobObject set ordering Co-Authored-By: Claude Code --- src/lib.rs | 8 +++++++- src/tokio/job_object.rs | 23 +++++++++++++++++++---- src/windows.rs | 33 ++++++++++++++++++++++++--------- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7cecd30..fcccc16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -677,6 +677,7 @@ pub(crate) mod test_allocator { pub(crate) active: AtomicBool, queried: AtomicBool, query_before_transition: AtomicBool, + query_after_transition: AtomicBool, transitioned: AtomicBool, allocator_callback: AtomicBool, operation: AtomicBool, @@ -688,6 +689,7 @@ pub(crate) mod test_allocator { assert!(!self.active.swap(true, Ordering::SeqCst)); self.queried.store(false, Ordering::SeqCst); self.query_before_transition.store(false, Ordering::SeqCst); + self.query_after_transition.store(false, Ordering::SeqCst); self.transitioned.store(false, Ordering::SeqCst); self.allocator_callback.store(false, Ordering::SeqCst); self.operation.store(false, Ordering::SeqCst); @@ -695,6 +697,9 @@ pub(crate) mod test_allocator { #[cfg(feature = "job-object")] pub(crate) fn observe_query(&self) { + if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { + self.query_after_transition.store(true, Ordering::SeqCst); + } self.queried.store(true, Ordering::SeqCst); } @@ -718,11 +723,12 @@ pub(crate) mod test_allocator { } #[cfg(feature = "job-object")] - pub(crate) fn finish(&self) -> (bool, bool, bool, bool) { + pub(crate) fn finish(&self) -> (bool, bool, bool, bool, bool) { self.active.store(false, Ordering::SeqCst); ( self.query_before_transition.load(Ordering::SeqCst), self.transitioned.load(Ordering::SeqCst), + self.query_after_transition.load(Ordering::SeqCst), self.allocator_callback.load(Ordering::SeqCst), self.operation.load(Ordering::SeqCst), ) diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index fe94f97..28962c6 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -1325,13 +1325,19 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); - let (query_before_transition, transitioned, allocator_callback, later_operation) = - finish_owner_transition_probe(); + let ( + query_before_transition, + transitioned, + query_after_transition, + allocator_callback, + later_operation, + ) = finish_owner_transition_probe(); assert!( !query_before_transition, "the injected final-owner failure must occur before the native limit query" ); assert!(!transitioned, "the native policy transition must not run"); + assert!(!query_after_transition); assert!(!allocator_callback); assert!(!later_operation); assert_eq!(spawn_cleanup_handle_close_count(), 1); @@ -1431,8 +1437,13 @@ mod tests { arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); - let (query_before_transition, transitioned, allocator_callback, later_operation) = - finish_owner_transition_probe(); + let ( + query_before_transition, + transitioned, + query_after_transition, + allocator_callback, + later_operation, + ) = finish_owner_transition_probe(); let mut succeeded = false; match outcome { Ok(Ok(mut child)) => { @@ -1460,6 +1471,10 @@ mod tests { "the final-owner limit query must complete before the terminal native set" ); assert!(transitioned, "the native final-owner transition completed"); + assert!( + !query_after_transition, + "the terminal final-owner set must not be followed by another native limit query" + ); assert!( !allocator_callback, "an allocator callback ran after the native final-owner transition" diff --git a/src/windows.rs b/src/windows.rs index f5ca186..d7f1c1f 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -609,7 +609,7 @@ pub(crate) mod test_support { crate::test_allocator::current_probe() } - pub fn finish_owner_transition_probe() -> (bool, bool, bool, bool) { + pub fn finish_owner_transition_probe() -> (bool, bool, bool, bool, bool) { crate::test_allocator::current_probe().finish() } @@ -966,7 +966,10 @@ pub(crate) fn set_job_kill_on_drop_observed( set_job_kill_on_drop_inner(job, kill_on_drop, Some(probe)) } -fn query_job_extended_limits(job: JobHandle) -> Result { +fn query_job_extended_limits( + job: JobHandle, + #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, +) -> Result { let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); let info_size = std::mem::size_of_val(&info) .try_into() @@ -986,6 +989,10 @@ fn query_job_extended_limits(job: JobHandle) -> Result, ) -> Result<()> { - let mut info = query_job_extended_limits(job)?; - #[cfg(test)] - if let Some(probe) = probe { - probe.observe_query(); - } + let mut info = query_job_extended_limits( + job, + #[cfg(test)] + probe, + )?; set_job_kill_on_drop_flag(&mut info, kill_on_drop); #[cfg(feature = "tracing")] debug!( @@ -1229,7 +1236,11 @@ fn disarm_extracted_job(job: JobHandle) -> Result<()> { "injected extracted JobObject pre-set limit query failure", )); } - let mut info = query_job_extended_limits(job)?; + let mut info = query_job_extended_limits( + job, + #[cfg(test)] + None, + )?; set_job_kill_on_drop_flag(&mut info, false); #[cfg(test)] @@ -1253,7 +1264,11 @@ fn disarm_extracted_job(job: JobHandle) -> Result<()> { "injected extracted JobObject post-set limit query failure", )); } - let effective = query_job_extended_limits(job)?; + let effective = query_job_extended_limits( + job, + #[cfg(test)] + None, + )?; let kill_on_close = effective .BasicLimitInformation .LimitFlags From cbbf9af84755a6100ffdfb4ba147a2d92fc434a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 04:00:40 +1300 Subject: [PATCH 109/114] test(windows): extend terminal ordering coverage Co-Authored-By: Claude Code --- src/std/job_object.rs | 31 +++++++++++++++++++++++++++---- src/tokio/job_object.rs | 1 + 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/std/job_object.rs b/src/std/job_object.rs index a4a630a..8a15ecb 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -728,9 +728,19 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); - let (transitioned, allocator_callback, later_operation) = - finish_owner_transition_probe(); + let ( + query_before_transition, + transitioned, + query_after_transition, + allocator_callback, + later_operation, + ) = finish_owner_transition_probe(); + assert!( + !query_before_transition, + "the injected final-owner failure must occur before the native limit query" + ); assert!(!transitioned, "the native policy transition must not run"); + assert!(!query_after_transition); assert!(!allocator_callback); assert!(!later_operation); assert_eq!(spawn_cleanup_handle_close_count(), 1); @@ -829,8 +839,13 @@ mod tests { arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); - let (transitioned, allocator_callback, later_operation) = - finish_owner_transition_probe(); + let ( + query_before_transition, + transitioned, + query_after_transition, + allocator_callback, + later_operation, + ) = finish_owner_transition_probe(); let mut succeeded = false; match outcome { Ok(Ok(mut child)) => { @@ -853,7 +868,15 @@ mod tests { clear_owner_events(), "the owner event barrier remained armed" ); + assert!( + query_before_transition, + "the final-owner limit query must complete before the terminal native set" + ); assert!(transitioned, "the native final-owner transition completed"); + assert!( + !query_after_transition, + "the terminal final-owner set must not be followed by another native limit query" + ); assert!( !allocator_callback, "an allocator callback ran after the native final-owner transition" diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 28962c6..cc560c7 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -1011,6 +1011,7 @@ mod tests { } } + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. async fn live_disarm_fault_enters_custody(fault: LiveDisarmFault) -> Result<()> { let _serial = serial_job_extraction(); let _fault_cleanup = ExtractionFaultCleanup; From 9202477bd439bf0e9b6a3f40729f2e2f22bc53d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 04:57:40 +1300 Subject: [PATCH 110/114] test(windows): distinguish final-owner call attempts Co-Authored-By: Claude Code --- src/lib.rs | 108 ++++++++++++++++++++++++------ src/std/job_object.rs | 143 +++++++++++++++++++++++++++++++--------- src/tokio/job_object.rs | 142 ++++++++++++++++++++++++++++++--------- src/windows.rs | 31 +++++++-- 4 files changed, 338 insertions(+), 86 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fcccc16..0342f20 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -672,41 +672,98 @@ pub(crate) mod test_allocator { sync::atomic::{AtomicBool, Ordering}, }; + #[derive(Clone, Copy, Debug)] + pub(crate) struct PostTransitionProbeState { + pub(crate) query_attempted: bool, + pub(crate) query_succeeded: bool, + pub(crate) query_attempted_before_setter: bool, + pub(crate) query_succeeded_before_setter: bool, + pub(crate) setter_attempted: bool, + pub(crate) transitioned: bool, + pub(crate) query_attempted_after_transition: bool, + pub(crate) allocator_callback: bool, + pub(crate) later_operation: bool, + } + #[derive(Default)] pub(crate) struct PostTransitionProbe { pub(crate) active: AtomicBool, - queried: AtomicBool, - query_before_transition: AtomicBool, - query_after_transition: AtomicBool, + query_attempted: AtomicBool, + query_succeeded: AtomicBool, + query_attempted_before_setter: AtomicBool, + query_succeeded_before_setter: AtomicBool, + setter_attempted: AtomicBool, transitioned: AtomicBool, + query_attempted_after_transition: AtomicBool, allocator_callback: AtomicBool, operation: AtomicBool, + inject_setter_failure: AtomicBool, } impl PostTransitionProbe { #[cfg(feature = "job-object")] - pub(crate) fn arm(&self) { + fn arm_with_setter_failure(&self, inject_setter_failure: bool) { assert!(!self.active.swap(true, Ordering::SeqCst)); - self.queried.store(false, Ordering::SeqCst); - self.query_before_transition.store(false, Ordering::SeqCst); - self.query_after_transition.store(false, Ordering::SeqCst); + self.query_attempted.store(false, Ordering::SeqCst); + self.query_succeeded.store(false, Ordering::SeqCst); + self.query_attempted_before_setter + .store(false, Ordering::SeqCst); + self.query_succeeded_before_setter + .store(false, Ordering::SeqCst); + self.setter_attempted.store(false, Ordering::SeqCst); self.transitioned.store(false, Ordering::SeqCst); + self.query_attempted_after_transition + .store(false, Ordering::SeqCst); self.allocator_callback.store(false, Ordering::SeqCst); self.operation.store(false, Ordering::SeqCst); + self.inject_setter_failure + .store(inject_setter_failure, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn arm(&self) { + self.arm_with_setter_failure(false); + } + + #[cfg(feature = "job-object")] + pub(crate) fn arm_setter_failure(&self) { + self.arm_with_setter_failure(true); } #[cfg(feature = "job-object")] - pub(crate) fn observe_query(&self) { + pub(crate) fn take_setter_failure(&self) -> bool { + self.inject_setter_failure.swap(false, Ordering::SeqCst) + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_query_attempt(&self) { if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { - self.query_after_transition.store(true, Ordering::SeqCst); + self.query_attempted_after_transition + .store(true, Ordering::SeqCst); } - self.queried.store(true, Ordering::SeqCst); + self.query_attempted.store(true, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_query_success(&self) { + self.query_succeeded.store(true, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_setter_attempt(&self) { + self.query_attempted_before_setter.store( + self.query_attempted.load(Ordering::SeqCst), + Ordering::SeqCst, + ); + self.query_succeeded_before_setter.store( + self.query_succeeded.load(Ordering::SeqCst), + Ordering::SeqCst, + ); + self.setter_attempted.store(true, Ordering::SeqCst); } #[cfg(feature = "job-object")] pub(crate) fn observe_transition(&self) { - self.query_before_transition - .store(self.queried.load(Ordering::SeqCst), Ordering::SeqCst); self.transitioned.store(true, Ordering::SeqCst); } @@ -723,15 +780,26 @@ pub(crate) mod test_allocator { } #[cfg(feature = "job-object")] - pub(crate) fn finish(&self) -> (bool, bool, bool, bool, bool) { + pub(crate) fn finish(&self) -> PostTransitionProbeState { self.active.store(false, Ordering::SeqCst); - ( - self.query_before_transition.load(Ordering::SeqCst), - self.transitioned.load(Ordering::SeqCst), - self.query_after_transition.load(Ordering::SeqCst), - self.allocator_callback.load(Ordering::SeqCst), - self.operation.load(Ordering::SeqCst), - ) + self.inject_setter_failure.store(false, Ordering::SeqCst); + PostTransitionProbeState { + query_attempted: self.query_attempted.load(Ordering::SeqCst), + query_succeeded: self.query_succeeded.load(Ordering::SeqCst), + query_attempted_before_setter: self + .query_attempted_before_setter + .load(Ordering::SeqCst), + query_succeeded_before_setter: self + .query_succeeded_before_setter + .load(Ordering::SeqCst), + setter_attempted: self.setter_attempted.load(Ordering::SeqCst), + transitioned: self.transitioned.load(Ordering::SeqCst), + query_attempted_after_transition: self + .query_attempted_after_transition + .load(Ordering::SeqCst), + allocator_callback: self.allocator_callback.load(Ordering::SeqCst), + later_operation: self.operation.load(Ordering::SeqCst), + } } } diff --git a/src/std/job_object.rs b/src/std/job_object.rs index 8a15ecb..3177104 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -438,10 +438,11 @@ mod tests { use crate::windows::test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, - arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, assert_tree_terminated, - clear_extra_prepared_owners, clear_owner_events, clear_owner_failure, - finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, observe_descendant, - publish_process_guards, record_owner_event, spawn_cleanup_handle_close_count, + arm_owner_setter_failure_probe, arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, + assert_tree_terminated, clear_extra_prepared_owners, clear_owner_events, + clear_owner_failure, finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, + observe_descendant, publish_process_guards, record_owner_event, + spawn_cleanup_handle_close_count, }; use super::*; @@ -728,21 +729,22 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); - let ( - query_before_transition, - transitioned, - query_after_transition, - allocator_callback, - later_operation, - ) = finish_owner_transition_probe(); + let probe = finish_owner_transition_probe(); + assert!(!probe.setter_attempted); assert!( - !query_before_transition, - "the injected final-owner failure must occur before the native limit query" + !probe.transitioned, + "the native policy transition must not run" ); - assert!(!transitioned, "the native policy transition must not run"); - assert!(!query_after_transition); - assert!(!allocator_callback); - assert!(!later_operation); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!( + !probe.query_attempted, + "the injected final-owner failure must occur before any native limit query attempt" + ); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); assert_eq!(spawn_cleanup_handle_close_count(), 1); assert_eq!(finish_spawn_cleanup_handle_probe(), 1); assert!( @@ -765,6 +767,65 @@ mod tests { Ok(()) } + #[test] + fn final_owner_setter_failure_records_attempt_without_transition() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_owner_setter_failure_probe(); + if !provider { + arm_spawn_cleanup_handle_probe(); + } + + let error = command + .spawn() + .expect_err("the injected native setter failure must fail spawn"); + let probe = finish_owner_transition_probe(); + assert_eq!(error.kind(), ErrorKind::Other); + assert!(probe.query_attempted); + assert!(probe.query_succeeded); + assert!(probe.query_attempted_before_setter); + assert!(probe.query_succeeded_before_setter); + assert!(probe.setter_attempted); + assert!( + !probe.transitioned, + "a failed native setter must not report a successful transition" + ); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + if !provider { + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!( + state.residue_drops.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait()?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + #[test] fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() -> Result<()> { for provider in [false, true] { @@ -839,13 +900,7 @@ mod tests { arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); - let ( - query_before_transition, - transitioned, - query_after_transition, - allocator_callback, - later_operation, - ) = finish_owner_transition_probe(); + let probe = finish_owner_transition_probe(); let mut succeeded = false; match outcome { Ok(Ok(mut child)) => { @@ -869,20 +924,36 @@ mod tests { "the owner event barrier remained armed" ); assert!( - query_before_transition, - "the final-owner limit query must complete before the terminal native set" + probe.query_attempted, + "the native limit query was attempted" + ); + assert!(probe.query_succeeded, "the native limit query succeeded"); + assert!( + probe.query_attempted_before_setter, + "the native limit query attempt must precede the setter attempt" + ); + assert!( + probe.query_succeeded_before_setter, + "the native limit query must complete before the setter attempt" ); - assert!(transitioned, "the native final-owner transition completed"); assert!( - !query_after_transition, + probe.setter_attempted, + "the terminal native setter was attempted" + ); + assert!( + probe.transitioned, + "the native final-owner transition completed" + ); + assert!( + !probe.query_attempted_after_transition, "the terminal final-owner set must not be followed by another native limit query" ); assert!( - !allocator_callback, + !probe.allocator_callback, "an allocator callback ran after the native final-owner transition" ); assert!( - !later_operation, + !probe.later_operation, "a caller or test operation ran after the native final-owner transition" ); let tree_result = assert_tree_terminated(&paths, &state); @@ -920,6 +991,7 @@ mod tests { OwnerFailure::Error(Arc::clone(&identity)) }; arm_owner_failure(failure); + arm_owner_transition_probe(); let mut command = CommandWrap::new("provider-owned-program"); command @@ -935,8 +1007,19 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let probe = finish_owner_transition_probe(); let tree_result = assert_tree_terminated(&paths, &state); + assert!(!probe.setter_attempted); + assert!(!probe.transitioned); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!(!probe.query_attempted); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); + assert!( !failure_was_not_consumed, "the final owner hook did not run" diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index cc560c7..dfa8ace 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -452,10 +452,10 @@ mod tests { test_support::{ LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_job_port_close_probe, - arm_owner_events, arm_owner_failure, arm_owner_transition_probe, - arm_spawn_cleanup_handle_probe, assert_tree_terminated, clear_extra_prepared_owners, - clear_job_port_close_probe, clear_owner_events, clear_owner_failure, - finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, + arm_owner_events, arm_owner_failure, arm_owner_setter_failure_probe, + arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, assert_tree_terminated, + clear_extra_prepared_owners, clear_job_port_close_probe, clear_owner_events, + clear_owner_failure, finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, job_custodian_worker_starts, job_extraction_branch_counts, job_port_last_resort_retentions, observe_descendant, publish_process_guards, record_owner_event, reset_job_extraction_faults, serial_job_extraction, @@ -1326,21 +1326,22 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); - let ( - query_before_transition, - transitioned, - query_after_transition, - allocator_callback, - later_operation, - ) = finish_owner_transition_probe(); + let probe = finish_owner_transition_probe(); + assert!(!probe.setter_attempted); assert!( - !query_before_transition, - "the injected final-owner failure must occur before the native limit query" + !probe.transitioned, + "the native policy transition must not run" ); - assert!(!transitioned, "the native policy transition must not run"); - assert!(!query_after_transition); - assert!(!allocator_callback); - assert!(!later_operation); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!( + !probe.query_attempted, + "the injected final-owner failure must occur before any native limit query attempt" + ); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); assert_eq!(spawn_cleanup_handle_close_count(), 1); assert_eq!(finish_spawn_cleanup_handle_probe(), 1); assert!( @@ -1363,6 +1364,65 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn final_owner_setter_failure_records_attempt_without_transition() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_owner_setter_failure_probe(); + if !provider { + arm_spawn_cleanup_handle_probe(); + } + + let error = command + .spawn() + .expect_err("the injected native setter failure must fail spawn"); + let probe = finish_owner_transition_probe(); + assert_eq!(error.kind(), ErrorKind::Other); + assert!(probe.query_attempted); + assert!(probe.query_succeeded); + assert!(probe.query_attempted_before_setter); + assert!(probe.query_succeeded_before_setter); + assert!(probe.setter_attempted); + assert!( + !probe.transitioned, + "a failed native setter must not report a successful transition" + ); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + if !provider { + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!( + state.residue_drops.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait().await?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() -> Result<()> { @@ -1438,13 +1498,7 @@ mod tests { arm_owner_transition_probe(); arm_owner_events(Arc::clone(&events)); let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); - let ( - query_before_transition, - transitioned, - query_after_transition, - allocator_callback, - later_operation, - ) = finish_owner_transition_probe(); + let probe = finish_owner_transition_probe(); let mut succeeded = false; match outcome { Ok(Ok(mut child)) => { @@ -1468,20 +1522,36 @@ mod tests { "the owner event barrier remained armed" ); assert!( - query_before_transition, - "the final-owner limit query must complete before the terminal native set" + probe.query_attempted, + "the native limit query was attempted" + ); + assert!(probe.query_succeeded, "the native limit query succeeded"); + assert!( + probe.query_attempted_before_setter, + "the native limit query attempt must precede the setter attempt" + ); + assert!( + probe.query_succeeded_before_setter, + "the native limit query must complete before the setter attempt" ); - assert!(transitioned, "the native final-owner transition completed"); assert!( - !query_after_transition, + probe.setter_attempted, + "the terminal native setter was attempted" + ); + assert!( + probe.transitioned, + "the native final-owner transition completed" + ); + assert!( + !probe.query_attempted_after_transition, "the terminal final-owner set must not be followed by another native limit query" ); assert!( - !allocator_callback, + !probe.allocator_callback, "an allocator callback ran after the native final-owner transition" ); assert!( - !later_operation, + !probe.later_operation, "a caller or test operation ran after the native final-owner transition" ); let tree_result = assert_tree_terminated(&paths, &state); @@ -1520,6 +1590,7 @@ mod tests { OwnerFailure::Error(Arc::clone(&identity)) }; arm_owner_failure(failure); + arm_owner_transition_probe(); let mut command = CommandWrap::new("provider-owned-program"); command @@ -1535,8 +1606,19 @@ mod tests { let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); let failure_was_not_consumed = clear_owner_failure(); let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let probe = finish_owner_transition_probe(); let tree_result = assert_tree_terminated(&paths, &state); + assert!(!probe.setter_attempted); + assert!(!probe.transitioned); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!(!probe.query_attempted); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); + assert!( !failure_was_not_consumed, "the final owner hook did not run" diff --git a/src/windows.rs b/src/windows.rs index d7f1c1f..f6904ac 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -605,11 +605,15 @@ pub(crate) mod test_support { crate::test_allocator::current_probe().arm(); } + pub fn arm_owner_setter_failure_probe() { + crate::test_allocator::current_probe().arm_setter_failure(); + } + pub fn owner_transition_probe() -> &'static crate::test_allocator::PostTransitionProbe { crate::test_allocator::current_probe() } - pub fn finish_owner_transition_probe() -> (bool, bool, bool, bool, bool) { + pub fn finish_owner_transition_probe() -> crate::test_allocator::PostTransitionProbeState { crate::test_allocator::current_probe().finish() } @@ -974,12 +978,16 @@ fn query_job_extended_limits( let info_size = std::mem::size_of_val(&info) .try_into() .expect("extended JobObject limit information cannot exceed a DWORD"); + #[cfg(test)] + if let Some(probe) = probe { + probe.observe_query_attempt(); + } // SAFETY: // - `job` is borrowed from a live `JobPort` or newly created job owner and carries query access. // - `info` is fully initialized, writable `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` storage. // - `info_size` is its exact byte size, and the storage outlives this synchronous call. // On success the information-class contract initializes the current complete extended-limit state. - unsafe { + let result = unsafe { QueryInformationJobObject( Some(job.0), JobObjectExtendedLimitInformation, @@ -987,11 +995,11 @@ fn query_job_extended_limits( info_size, None, ) - } - .map_err(Error::other)?; + }; + result.map_err(Error::other)?; #[cfg(test)] if let Some(probe) = probe { - probe.observe_query(); + probe.observe_query_success(); } Ok(info) } @@ -1037,14 +1045,25 @@ fn set_job_extended_limits_native( let info_size = std::mem::size_of_val(info) .try_into() .expect("extended JobObject limit information cannot exceed a DWORD"); + #[cfg(test)] + let info_size = match probe { + Some(probe) if probe.take_setter_failure() => 0, + _ => info_size, + }; // No caller-controlled callback runs in this helper. All querying, formatting, tracing, test-probe // lookup, and size conversion have completed. After a successful native transition, this function // performs only test-only scalar/atomic observations and result moves before returning. + #[cfg(test)] + if let Some(probe) = probe { + probe.observe_setter_attempt(); + } // SAFETY: // - `job` is borrowed from a live `JobPort` or newly created job owner and carries set access. // - `info` is initialized readable `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` storage containing the // complete queried state with only the requested bit changed. - // - `info_size` is its exact byte size, and the storage outlives this synchronous call. + // - Production `info_size` is the storage's exact byte size. The test-only failure seam may reduce + // it to zero, which authorizes no structure bytes and cannot extend the native read beyond `info`. + // - The storage outlives this synchronous call. let result = unsafe { SetInformationJobObject( job.0, From 1224562a8bc8b0ec01c274debd30dc66b214fe29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 06:16:17 +1300 Subject: [PATCH 111/114] ci: check out exact triggering head Pin every workflow checkout to the immutable pull-request head SHA, falling back to the push SHA, while preserving checkout depth and credential settings. Co-Authored-By: Claude Code --- .github/workflows/test.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00844fd..5cea43d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -75,6 +76,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -91,6 +93,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -110,6 +113,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -142,6 +146,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -167,6 +172,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -205,6 +211,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -237,6 +244,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -262,6 +270,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -282,6 +291,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false @@ -309,6 +319,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust From a4a47de01f6dceb7cfcc14cd7f003f6692659557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 06:17:56 +1300 Subject: [PATCH 112/114] docs: distinguish live and spawned child identity Document Tokio child IDs as live-facing rather than universal liveness proof, and align the std and Tokio installation-only historical PID contracts for custom providers. Co-Authored-By: Claude Code --- src/std/core.rs | 12 ++++++++++++ src/tokio/core.rs | 29 ++++++++++++++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/std/core.rs b/src/std/core.rs index 7b184be..1c7c68f 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -220,6 +220,18 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { #[cfg(all(windows, feature = "job-object"))] fn retain_prepared_after_sidecar_removal_layer(&mut self) {} + /// Return the original PID retained by this exact child layer, if it advertises one. + /// + /// Process-wrap consults this historical identity only while installing `ProcessGroup` or + /// `ProcessSession` supervision, including when a post-spawn hook has already reaped the child. + /// It may preserve the PID observed at spawn for that construction step; it is not proof that the + /// child remains live and must not be used after spawn returns as a signal or kill target or as a + /// substitute for lower-child synchronized live signalling. + /// + /// A custom provider child whose live-facing [`ChildWrapper::id`] cannot supply the original PID + /// when process-wrap receives it must override this method if it advertises composition with + /// `ProcessGroup` or `ProcessSession`. Other children may retain the default; this contract does not + /// require every wrapper to preserve historical identity. #[doc(hidden)] #[cfg(all(unix, feature = "process-group"))] fn spawned_id_layer(&self) -> Option { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 684768f..1d3514e 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -222,10 +222,18 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { None } - /// Return the original PID retained by this exact child layer. + /// Return the original PID retained by this exact child layer, if it advertises one. /// - /// This historical identity is consumed only while installing process-group or session supervision. - /// Ordinary child operations and signalling must continue to use live child state. + /// Process-wrap consults this historical identity only while installing `ProcessGroup` or + /// `ProcessSession` supervision, including when a post-spawn hook has already reaped the child. + /// It may preserve the PID observed at spawn for that construction step; it is not proof that the + /// child remains live and must not be used after spawn returns as a signal or kill target or as a + /// substitute for lower-child synchronized live signalling. + /// + /// A custom provider child whose live-facing [`ChildWrapper::id`] cannot supply the original PID + /// when process-wrap receives it must override this method if it advertises composition with + /// `ProcessGroup` or `ProcessSession`. Other children may retain the default; this contract does not + /// require every wrapper to preserve historical identity. #[doc(hidden)] #[cfg(all(unix, feature = "process-group"))] fn spawned_id_layer(&self) -> Option { @@ -340,12 +348,19 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { self.inner_mut().stderr() } - /// Obtain the `Child`'s process ID. + /// Obtain the `Child`'s live-facing process ID. + /// + /// Raw Tokio children return `Some(pid)` while the child is live and can return `None` after + /// completion. Custom wrappers may vary according to their documented behavior, so even `Some(pid)` + /// is not by itself proof that the child remains live. /// - /// In general this should be the PID of the top-level spawned process that was spawned - /// However, that may vary depending on what a wrapper does. + /// This value is distinct from the historical spawned ID exposed by + /// [`ChildWrapper::spawned_id_layer`] only while installing `ProcessGroup` or `ProcessSession` + /// supervision. That installation-only identity must not be used as post-return liveness proof or as + /// a signal or kill target. /// - /// Returns an `Option` to resemble Tokio's API, but isn't expected to be `None` in practice. + /// In general this should identify the top-level spawned process. However, that may vary depending + /// on what a wrapper does. fn id(&self) -> Option { self.inner().id() } From 627a5a2754e7fb8f4bbf403e84bda95a390d1e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 30 Sep 2026 07:16:49 +1300 Subject: [PATCH 113/114] docs: require terminal spawned identity capability Clarify that every supervision-capable self-terminal custom child must advertise its historical installation PID, while transparent wrappers may traverse to a lower capability. Co-Authored-By: Claude Code --- src/std/core.rs | 10 ++++++---- src/tokio/core.rs | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/std/core.rs b/src/std/core.rs index 1c7c68f..2d8eb97 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -228,10 +228,12 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// child remains live and must not be used after spawn returns as a signal or kill target or as a /// substitute for lower-child synchronized live signalling. /// - /// A custom provider child whose live-facing [`ChildWrapper::id`] cannot supply the original PID - /// when process-wrap receives it must override this method if it advertises composition with - /// `ProcessGroup` or `ProcessSession`. Other children may retain the default; this contract does not - /// require every wrapper to preserve historical identity. + /// For this installation path, `try_spawned_id` consults this capability rather than the + /// live-facing [`ChildWrapper::id`]. Every self-terminal custom or provider child that advertises + /// composition with `ProcessGroup` or `ProcessSession` must override this method and retain the + /// original spawned PID. Transparent wrappers may retain the default so traversal can use a lower + /// layer's advertised capability. Children and wrappers that do not advertise this supervision + /// composition need not retain historical identity. #[doc(hidden)] #[cfg(all(unix, feature = "process-group"))] fn spawned_id_layer(&self) -> Option { diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 1d3514e..78a99c4 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -230,10 +230,12 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// child remains live and must not be used after spawn returns as a signal or kill target or as a /// substitute for lower-child synchronized live signalling. /// - /// A custom provider child whose live-facing [`ChildWrapper::id`] cannot supply the original PID - /// when process-wrap receives it must override this method if it advertises composition with - /// `ProcessGroup` or `ProcessSession`. Other children may retain the default; this contract does not - /// require every wrapper to preserve historical identity. + /// For this installation path, `try_spawned_id` consults this capability rather than the + /// live-facing [`ChildWrapper::id`]. Every self-terminal custom or provider child that advertises + /// composition with `ProcessGroup` or `ProcessSession` must override this method and retain the + /// original spawned PID. Transparent wrappers may retain the default so traversal can use a lower + /// layer's advertised capability. Children and wrappers that do not advertise this supervision + /// composition need not retain historical identity. #[doc(hidden)] #[cfg(all(unix, feature = "process-group"))] fn spawned_id_layer(&self) -> Option { From 474aefed9e8418a23c75218be07efcce4aabdcd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Thu, 1 Oct 2026 21:46:53 +1300 Subject: [PATCH 114/114] unplan: command API and PTY provider Co-Authored-By: Claude Code --- docs/plans/command-api-and-pty-provider.md | 157 --------------------- 1 file changed, 157 deletions(-) delete mode 100644 docs/plans/command-api-and-pty-provider.md diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md deleted file mode 100644 index 7ae84cc..0000000 --- a/docs/plans/command-api-and-pty-provider.md +++ /dev/null @@ -1,157 +0,0 @@ -# Unified command API and PTY provider - -Process-wrap 10.0 established a reliable ordered wrapper registry and child capability model. -The PTY prototypes proved the Unix and ConPTY transports but introduced `PtyCommand` as a second command-building and spawning API. -Process-wrap 11 will instead make command configuration exact and transport-independent, with PTY selected by the same `.wrap(Pty).spawn()` flow as every other concern. - -## Shared command family - -Introduce one generic command implementation selected by sealed blocking and Tokio frontend markers. -Re-export backend-selected `Command` aliases from the existing `std` and `tokio` modules. -Retain `CommandWrap` as an alias while preferring `Command` in new documentation. -Keep backend-specific `spawn` implementations and child traits because blocking and Tokio child operations have different contracts. -Keep both Cargo frontend features additive and independently usable. - -Move program, argument, environment, cwd, wrapper registry, and common construction logic into the shared command implementation. -Keep `with_new`, with its closure receiving the process-wrap command instead of a native command. -Expose native-shaped tracked configuration methods so inferred existing closure bodies remain valid. -Preserve compatibility command accessors as process-wrap facade views rather than native escape hatches. -Add explicitly named native mutation and conversion methods. - -## Exact and native-only state - -Represent ordinary command configuration as cloneable tracked intent. -Preserve ordered regular and raw Windows argument operations without Unicode normalization. -Preserve inherited versus cleared environment state, environment mutations, and cwd exactly. -Materialize a fresh native command from tracked intent for each spawn attempt. - -Retain conversion from native std and Tokio commands as a native-only compatibility state. -Transition tracked state to native-only when callers explicitly request native mutation or configure state that cannot be reconstructed, such as arbitrary `Stdio` handles or `pre_exec` callbacks. -Allow native-only commands to use exact native spawning and explicit custom spawners. -Reject native-only state from portable providers rather than reconstructing, ignoring, or guessing at it. - -## Attempt and wrapper lifecycle - -Create a per-spawn attempt facade from tracked command state. -Change `pre_spawn` to mutate that facade while retaining read-only access to live peer wrappers. -Change `post_spawn` to receive the attempt and the frontend child capability trait rather than requiring a native child. -Run `post_spawn` for native, custom, and provider children. -Retain ordered `wrap_child` application and typed duplicate-wrapper extension. -Preserve wrapper slots and command state across success, error, and panic paths. - -Move built-in process group, process session, signal-mask, creation-flag, and kill-on-drop setup onto tracked attempt policy where possible. -Use object-safe child capabilities for process handles and provider-owned suspension rather than concrete custom-child downcasts. -Let custom wrappers compose based on representable command state and available child capabilities instead of concrete type allowlists. - -## Spawn providers - -Add an object-safe frontend-specific provider capability exposed by a command wrapper. -Select the native provider when no alternate provider is registered. -Reject multiple alternate providers before hooks or operating-system allocation. -Run provider availability checks before other provider validation so unsupported-platform errors retain precedence. -Reject incompatible immutable configuration before hooks. -Run ordered pre-spawn hooks, then validate the resulting attempt before provider allocation. -Have providers return boxed frontend children and armed cleanup transactions. -Run ordered post-spawn and child-wrapping hooks before committing the provider transaction. -Ensure provider cleanup runs after every later error or panic without replacing the original failure. - -Treat `spawn_with` and `spawn_with_child` as explicit caller-selected transports. -Reject either method when a wrapper provider is registered instead of bypassing it. -Run capability-level post-spawn and ordinary child wrapping for both methods. - -## Tokio PTY wrapper - -Replace `PtyCommand`, its duplicate command intent and wrapper registry, `PtyMarker`, and tuple-returning spawn with a Tokio-only `Pty` provider wrapper. -Store terminal size on `Pty` and merge duplicate registration through typed extension. -Keep ordinary `spawn` returning the boxed Tokio child contract. -Install a private child layer which owns the PTY controller beneath arbitrary outer wrappers. -Add one-shot controller extraction by traversing the child chain without unwrapping it. -Keep PTY input and output on the controller and keep native Tokio pipe accessors absent. -Expose side-effect-free `Pty::check_supported` and `Pty::is_supported` associated functions for platform and runtime capability detection. -Keep command configuration, wrapper compatibility, and spawn failures outside that capability result so callers do not mistake availability for a spawn guarantee. - -## Unix PTY transport - -Reuse the existing PTY allocation and Tokio I/O implementation for Android, DragonFly BSD, FreeBSD, illumos, Linux, macOS, NetBSD, OpenBSD, and Solaris. -Preserve close-on-exec, nonblocking master I/O, slave duplication, controlling-terminal setup, foreground process groups, Linux EIO normalization, and cleanup behavior. -Apply slave stdio and terminal callbacks only to the fresh attempt command. -Close parent slave descriptors before post-spawn and child-wrapping hooks. -Preserve shared master ownership by input and output, weak resize ownership, merged terminal output, and independent child-wait and output-EOF lifecycles. - -Integrate real `Pty` registration with `ProcessGroup`, `ProcessSession`, `ResetSigmask`, and `KillOnDrop`. -Preserve group-aware supervision for leader/session modes. -Reject attached groups and simultaneous explicit group/session registration. -Return unsupported-platform errors before terminal-size validation or hook effects. - -## Exact Windows command model - -Reuse the existing Windows argument, environment, program-resolution, and cwd modules against shared tracked command intent. -Preserve interleaved regular and raw argument semantics, CRT quoting, WTF-16 data, and stable validation errors. -Preserve native environment inheritance, explicit Unicode blocks, case-insensitive key replacement, deterministic ordering, and drive pseudo-variables. -Preserve deterministic executable resolution and direct batch-script rejection. -Carry creation flags, explicit versus temporary suspension, JobObject, and KillOnDrop through tracked policy and child capabilities. - -## ConPTY transport - -Reuse the existing dynamic ConPTY API resolution, startup attributes, named pipes, manual `CreateProcessW`, custom child, controller, and cleanup modules. -Call the native ConPTY API through the `windows` crate rather than introducing another PTY or process abstraction. -Integrate the transport as the Tokio `Pty` provider rather than a separate spawn method. -Resolve `CreatePseudoConsole`, `ResizePseudoConsole`, `ReleasePseudoConsole`, and `ClosePseudoConsole` dynamically and preserve unsupported-runtime precedence. -Require Windows 11 24H2 build 26100 or Windows Server 2025 because descendant-aware output EOF depends on `ReleasePseudoConsole`. -Preserve exact command line, environment, cwd, flag, and handle-inheritance semantics. -Retain process and primary-thread handles through JobObject assignment and provider-owned suspension finalization. -Keep the cleanup guard armed through all wrapper hooks and disarm it only when the provider transaction commits. -Preserve cancellation-safe repeated waits, post-exit kill behavior, resize, merged I/O, direct-child and job-object kill-on-drop behavior, and off-reactor pseudoconsole closure. - -## Public documentation and migration - -Prefer the backend module `Command` aliases while retaining `CommandWrap` compatibility. -Show inferred `with_new` closures continuing to use native-shaped configuration calls. -Explain why backend typing remains at spawn and child boundaries even though command configuration is shared. -Use only `.wrap(Pty).spawn()` and one-shot controller extraction in PTY examples. -Preserve the crate’s adaptability motivation and complete supported Unix platform list. -Preserve the terminal stream, ownership, VEOF, draining, macOS lifecycle, and process-supervision rationale. -Explain that `pty` remains non-default because it selects Tokio and PTY dependencies. -Remove the rejected public PTY builder, tuple spawn, marker names, fallback-to-pipes wording, and CI-runner prose. -Document native-only escape behavior and the migration from the former PTY prototype. -Document ConPTY support, its Windows build floor, and the descendant-aware EOF semantics which require that floor. - -## Recovery and completion sequence - -### Task 1: Recover the historical ConPTY stack - -Treat the shared command family, exact state, attempt lifecycle, spawn providers, Tokio PTY wrapper, Unix transport, and exact Windows command model as the merged foundation on `main`. -Recover the unpublished ConPTY work by rebasing its logical commits onto that foundation rather than merging the obsolete branch or reconstructing one squashed change. -Preserve the feature sequence for executable resolution, dynamic API resolution, named pipes, startup ownership, child ownership, controller ownership, exact process creation, and provider integration. -Preserve the subsequent test and lifecycle-fix commits while omitting the historical correction merge whose other parent is already an ancestor of `main`. - -Keep the recovered Windows transport split by responsibility: - -- `src/tokio/pty/windows/api.rs` resolves and calls the required ConPTY entry points. -- `src/tokio/pty/windows/attributes.rs` owns the process-thread startup attribute list. -- `src/tokio/pty/windows/backend.rs` validates, allocates, and returns the provider product and transaction. -- `src/tokio/pty/windows/child.rs` owns process handles and cancellation-safe wait state. -- `src/tokio/pty/windows/console.rs` owns startup release, resize, and off-reactor pseudoconsole closure. -- `src/tokio/pty/windows/controller.rs` exposes PTY input, output, and resize ownership. -- `src/tokio/pty/windows/pipe.rs` pairs ConPTY-compatible synchronous endpoints with Tokio-compatible named-pipe endpoints. -- `src/tokio/pty/windows/program.rs` resolves executables deterministically and rejects direct batch execution. -- `src/tokio/pty/windows/spawn.rs` performs exact `CreateProcessW` startup and arms process cleanup. -- `src/tokio/pty/windows/mod.rs` composes those modules with the existing command and environment preparation. -- `src/tokio/pty.rs` selects the Windows backend. -- `Cargo.toml` enables only the additional Win32 API features required by the transport. - -### Task 2: Add PTY capability queries - -Add `Pty::check_supported() -> io::Result<()>` and `Pty::is_supported() -> bool` in `src/tokio/pty.rs` as side-effect-free associated functions. -Delegate the provider availability callback to the same check so the public query and spawn path cannot drift. -Keep size validation, command compatibility, wrapper compatibility, and spawn outcomes outside the capability result. - -### Task 3: Complete the Windows support documentation - -Update `README.md` and crate-level documentation to describe Windows ConPTY support, its Windows 11 24H2 build 26100 or Windows Server 2025 floor, and the same ownership and lifecycle contract as the Unix backend. -Document capability queries as the supported way to select PTY conditionally without weakening later validation and spawn errors. - -### Task 4: Close the implementation plan - -Resolve any current-`main` integration gaps in new focused commits rather than folding fixes into the recovered commits. -When every implementation promise in this plan is present, remove this plan in a standalone `unplan:` commit.