diff --git a/Cargo.toml b/Cargo.toml index ddb649c..f16192d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,10 +46,10 @@ default = ["creation-flags", "job-object", "kill-on-drop", "process-group", "pro ## Enable internal tracing logs tracing = ["dep:tracing"] -## Frontend: StdCommandWrap +## Frontend: std::Command std = ["dep:nix"] -## Frontend: TokioCommandWrap +## Frontend: tokio::Command tokio1 = ["dep:nix", "dep:futures", "dep:tokio"] ## Wrapper: Creation Flags diff --git a/README.md b/README.md index dfbb2be..7feded5 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,9 @@ Command::with_new("watch", |command| { command.arg("ls"); }) - Like command-group. - Feature: `creation-flags` (default) -This is a shim to allow setting Windows process creation flags with this API, as otherwise they'd be overwritten. +This wrapper records Windows creation flags as portable per-attempt policy. Calling the native-shaped +`Command::creation_flags` method instead makes the command native-only because wrappers and alternate +transports cannot query or reconstruct those flags. ```rust use windows::Win32::System::Threading::*; @@ -224,7 +226,9 @@ after assignment unless the caller explicitly requested `CREATE_SUSPENDED`. - Like command-group. - Feature: `kill-on-drop` (default) -This is a shim to allow wrappers to handle the kill-on-drop flag, as it can't be read from Command. +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. ```rust let child = Command::with_new("watch", |command| { command.arg("ls"); }) @@ -258,25 +262,68 @@ The trait provides extension or hook points into the lifecycle of a `Command`: incorporate all or part of the second, concretely typed wrapper. By default, this does nothing (that is, only the first registered wrapper instance of a type applies). -- **`fn pre_spawn(&mut self, command: &mut tokio::process::Command, core: &Command)`** is called - before the command is spawned, and gives mutable access to that attempt's native command. It also - gives mutable access to the wrapper instance, so state can be stored if needed. The `core` - reference gives access to data from other wrappers; for example, that's how `CreationFlags` on - Windows works along with `JobObject`. Noop by default. - -- **`fn post_spawn(&mut self, command: &mut tokio::process::Command, child: &mut tokio::process::Child, core: &Command)`** - is called after spawn, and should be used for any necessary cleanups. It is offered for completeness - but is expected to be less used than `wrap_child()`. Noop by default. - -- **`fn wrap_child(&mut self, child: Box, core: &Command)`** is - called after all `post_spawn()`s have run. If your wrapper needs to override the methods on Child, - then it should create an instance of its own type implementing `ChildWrapper` and return it - here. Child wraps are _in order_: you may end up with a `Foo(Bar(Child))` or a `Bar(Foo(Child))` - depending on if `.wrap(Foo).wrap(Bar)` or `.wrap(Bar).wrap(Foo)` was called. If your functionality - is order-dependent, make sure to specify so in your documentation! Default is noop: no wrapping is - performed and the input `child` is returned as-is. - -Refer to [the API documentation][docs] for more detail and the specifics of child wrapper traits. +- **`fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, command: &Command) -> io::Result<()>`** + is called before spawning. It can record portable configuration on this attempt and inspect peer + wrappers through `command`. For tracked commands those mutations apply to one attempt; native-only + commands retain native mutations. Calling `attempt.native_mut()` or its `stdin`/`stdout`/`stderr` + methods makes a tracked attempt incompatible with a portable provider. On Unix, recurring native + escapes from a reusable native-only command can retain inactive child-setup callbacks because the + native API does not expose callback insertion or command ownership; prefer portable attempt methods + for recurring configuration. + +- **`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 + 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 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; + 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 +`get_wrap`; peer wrappers remain visible. + +### Spawn providers + +Spawn providers let a wrapper replace only process creation while retaining the complete wrapper +lifecycle. This is what makes custom transports such as PTYs composable with process-wrap wrappers. +Callbacks run in this order: + +1. `check_available` +2. native-only base rejection +3. `validate_command` +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. transaction `commit` + +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. + +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. +`spawn_with` and `spawn_with_child` reject a registered provider instead of silently bypassing it. +On Unix, when wrappers request built-in child setup, a successful explicit spawner must create its +returned child from the native command before replacing that command. Whenever the spawner replaces +it, including before returning an error or unwinding, the displaced command must be dropped before +control leaves the spawner. A replacement is discarded with a tracked attempt or retained by a +native-only base. Process-wrap installs child setup before invoking the spawner and cannot apply it to +a replacement which the closure creates and immediately spawns. + +Refer to [the API documentation][docs] for the policy getters, platform child capabilities, and the +specifics of child wrapper traits. ## Features [the features list]: #features @@ -296,3 +343,7 @@ Both can exist at the same time, but generally you should use one or the other. - `process-group`: **default**, enables the [process group](#process-group) wrapper. - `process-session`: **default**, enables the [process session](#process-session) wrapper. - `reset-sigmask`: enables the [reset signal mask](#reset-signal-mask) wrapper. + +### Diagnostics + +- `tracing`: **default**, enables internal lifecycle diagnostics through the `tracing` crate. diff --git a/src/command.rs b/src/command.rs index c5ffe89..f5fe685 100644 --- a/src/command.rs +++ b/src/command.rs @@ -9,6 +9,9 @@ use std::{ process::Stdio, }; +#[cfg(windows)] +use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}; + /// Blocking standard-library process frontend. #[doc(hidden)] #[derive(Debug)] @@ -73,6 +76,21 @@ pub trait NativeCommand: fmt::Debug + Sized + 'static { /// Configure standard error. fn stderr(&mut self, stdio: Stdio); + /// Register a callback to run in the child after `fork`. + #[cfg(unix)] + unsafe fn pre_exec(&mut self, callback: F) + where + F: FnMut() -> std::io::Result<()> + Send + Sync + 'static; + + /// Configure whether dropping a child kills it, where the frontend supports that policy. + fn configure_kill_on_drop(&mut self, kill_on_drop: bool) { + debug_assert!(!kill_on_drop, "only Tokio commands support kill-on-drop"); + } + + /// Set Windows process creation flags. + #[cfg(windows)] + fn creation_flags(&mut self, flags: u32); + /// Get the program. fn get_program(&self) -> &OsStr; @@ -130,6 +148,22 @@ impl NativeCommand for std::process::Command { self.stderr(stdio); } + #[cfg(unix)] + unsafe fn pre_exec(&mut self, callback: F) + where + F: FnMut() -> std::io::Result<()> + Send + Sync + 'static, + { + use std::os::unix::process::CommandExt; + // SAFETY: the caller accepts the native `pre_exec` contract. + unsafe { CommandExt::pre_exec(self, callback) }; + } + + #[cfg(windows)] + fn creation_flags(&mut self, flags: u32) { + use std::os::windows::process::CommandExt; + CommandExt::creation_flags(self, flags); + } + fn get_program(&self) -> &OsStr { self.get_program() } @@ -190,6 +224,24 @@ impl NativeCommand for tokio::process::Command { self.stderr(stdio); } + #[cfg(unix)] + unsafe fn pre_exec(&mut self, callback: F) + where + F: FnMut() -> std::io::Result<()> + Send + Sync + 'static, + { + // SAFETY: the caller accepts the native `pre_exec` contract. + unsafe { self.pre_exec(callback) }; + } + + fn configure_kill_on_drop(&mut self, kill_on_drop: bool) { + self.kill_on_drop(kill_on_drop); + } + + #[cfg(windows)] + fn creation_flags(&mut self, flags: u32) { + self.creation_flags(flags); + } + fn get_program(&self) -> &OsStr { self.as_std().get_program() } @@ -207,15 +259,24 @@ impl NativeCommand for tokio::process::Command { } } -#[derive(Clone, Debug)] -pub(crate) enum CommandArg { +/// One losslessly tracked command-line argument. +/// +/// [`Command::get_args`] and [`SpawnAttempt::get_args`] provide native-shaped value iterators. This +/// type additionally preserves whether a Windows argument was supplied through `raw_arg`, which an +/// alternate spawn provider needs in order to reproduce or reject the exact command line. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommandArg { + /// A regular argument which the transport must quote according to its command-line model. Regular(OsString), + /// A raw Windows command-line fragment which the transport must not quote or escape. #[cfg(windows)] + #[cfg_attr(docsrs, doc(cfg(windows)))] Raw(OsString), } impl CommandArg { - fn value(&self) -> &OsStr { + /// Return the argument or raw fragment value. + pub fn value(&self) -> &OsStr { match self { Self::Regular(value) => value, #[cfg(windows)] @@ -463,6 +524,235 @@ 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. +/// +/// 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. +/// +/// Until a provider returns its `ProviderProduct`, cleanup for errors or 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<()>; + + /// Undo an uncommitted spawn. + fn rollback(&mut self) -> std::io::Result<()>; +} + +#[cfg(windows)] +const CREATE_SUSPENDED_FLAG: u32 = 0x0000_0004; + +/// Portable Windows process-creation policy for one spawn attempt. +/// +/// Alternate providers use this policy to preserve creation flags and compose with `JobObject` and +/// Tokio `KillOnDrop` without inspecting an opaque native command. +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct WindowsSpawnPolicy { + user_creation_flags: u32, + spawn_creation_flags: u32, + has_creation_flags: bool, + has_job_object: bool, + kill_on_drop: bool, +} + +#[cfg(windows)] +impl WindowsSpawnPolicy { + /// Return the flags explicitly requested through `CreationFlags`. + pub fn user_creation_flags(self) -> u32 { + self.user_creation_flags + } + + /// Return the complete flags the transport must use when creating the process. + /// + /// This includes process-wrap's temporary `CREATE_SUSPENDED` flag when a job object must be + /// assigned before the process starts running. + pub fn spawn_creation_flags(self) -> u32 { + self.spawn_creation_flags + } + + /// Return whether `CreationFlags` configured this attempt. + pub fn has_creation_flags(self) -> bool { + self.has_creation_flags + } + + /// Return whether this attempt must assign the child to a job object. + pub fn has_job_object(self) -> bool { + self.has_job_object + } + + /// Return whether the caller explicitly requested `CREATE_SUSPENDED`. + pub fn is_explicitly_suspended(self) -> bool { + self.user_creation_flags & CREATE_SUSPENDED_FLAG != 0 + } + + /// Return whether process-wrap added temporary suspension for job-object assignment. + pub fn is_temporarily_suspended(self) -> bool { + self.has_job_object && !self.is_explicitly_suspended() + } + + /// Return whether the child starts suspended for either reason. + pub fn starts_suspended(self) -> bool { + self.spawn_creation_flags & CREATE_SUSPENDED_FLAG != 0 + } + + /// Return whether dropping the direct Tokio child must terminate it. + pub fn kills_on_drop(self) -> bool { + self.kill_on_drop + } + + #[cfg(any(feature = "creation-flags", test))] + fn set_creation_flags(&mut self, flags: u32) { + self.user_creation_flags = flags; + self.has_creation_flags = true; + self.recompute_spawn_flags(); + } + + #[cfg(any(feature = "job-object", test))] + fn set_job_object(&mut self) { + self.has_job_object = true; + self.recompute_spawn_flags(); + } + + #[cfg(any(all(feature = "tokio1", feature = "kill-on-drop"), test))] + fn set_kill_on_drop(&mut self, kill_on_drop: bool) { + self.kill_on_drop = kill_on_drop; + } + + #[cfg(any(feature = "creation-flags", feature = "job-object", test))] + fn recompute_spawn_flags(&mut self) { + self.spawn_creation_flags = self.user_creation_flags; + if self.has_job_object { + self.spawn_creation_flags |= CREATE_SUSPENDED_FLAG; + } + } + + fn applies_creation_flags(self) -> bool { + self.has_creation_flags || self.has_job_object + } +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn TerminateProcess(process: *mut std::ffi::c_void, exit_code: u32) -> i32; + fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; +} + +#[cfg(windows)] +const WAIT_FAILED: u32 = u32::MAX; +#[cfg(windows)] +const WAIT_INFINITE: u32 = u32::MAX; + +#[cfg(windows)] +pub(crate) fn terminate_process_and_wait(process: BorrowedHandle<'_>) -> std::io::Result<()> { + let raw = process.as_raw_handle(); + // SAFETY: `raw` is a live process handle for both calls and remains borrowed until they finish. + if unsafe { TerminateProcess(raw, 1) } == 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: the process handle remains live for the duration of this call. + if unsafe { WaitForSingleObject(raw, WAIT_INFINITE) } == WAIT_FAILED { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(windows)] +#[derive(Debug)] +pub(crate) struct WindowsSpawnCleanup { + process: OwnedHandle, + armed: bool, +} + +#[cfg(windows)] +impl WindowsSpawnCleanup { + pub(crate) fn new(process: BorrowedHandle<'_>) -> std::io::Result { + Ok(Self { + process: process.try_clone_to_owned()?, + armed: true, + }) + } + + pub(crate) fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(windows)] +impl Drop for WindowsSpawnCleanup { + fn drop(&mut self) { + if self.armed { + let _ = terminate_process_and_wait(self.process.as_handle()); + } + } +} + +enum AttemptState { + Tracked { + intent: CommandIntent, + native: Option, + }, + NativeOnly(N), +} + +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(), + } + } +} + +#[derive(Clone, Debug, Default)] +struct PlatformCommandState { + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + unix: crate::unix::CommandState, +} + +/// The command configuration for one spawn attempt. +/// +/// Each call to a spawn method creates a fresh attempt. Hooks may modify an attempt copied from a +/// tracked base [`Command`] without changing that base. A native-only command instead lends its exact +/// native command to the attempt and retains hook mutations when the command is restored afterward. +/// 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, + #[cfg_attr(not(unix), allow(dead_code))] + platform: PlatformCommandState, + #[cfg_attr(not(unix), allow(dead_code))] + native_only_base: bool, + kill_on_drop: Option, + #[cfg(windows)] + windows_policy: WindowsSpawnPolicy, + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + unix_policy: crate::unix::SpawnPolicy, + backend: PhantomData B>, +} + +impl fmt::Debug for SpawnAttempt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SpawnAttempt") + .field("state", &self.state) + .finish() + } +} + /// A configurable process command with composable wrappers. /// /// The backend type is normally selected through `process_wrap::std::Command` or @@ -471,6 +761,7 @@ impl fmt::Debug for CommandState { pub struct Command { state: CommandState, wrappers: Box, + platform: PlatformCommandState, backend: PhantomData B>, } @@ -488,6 +779,7 @@ impl Command { Self { state: CommandState::Tracked(CommandIntent::new(program)), wrappers: B::new_registry(), + platform: PlatformCommandState::default(), backend: PhantomData, } } @@ -648,6 +940,17 @@ impl Command { } } + /// Get the losslessly tracked portable arguments in command-line order. + /// + /// Unlike [`get_args`](Self::get_args), this preserves regular versus raw Windows arguments. Returns + /// `None` for a native-only command because its native API cannot recover that distinction. + pub fn get_portable_args(&self) -> Option<&[CommandArg]> { + match &self.state { + CommandState::Tracked(intent) => Some(&intent.args), + CommandState::NativeOnly(_) => None, + } + } + /// Get explicitly configured environment changes. pub fn get_envs(&self) -> Box)> + '_> { match &self.state { @@ -659,6 +962,17 @@ impl Command { } } + /// Return whether this portable command inherits the parent environment. + /// + /// Returns `Some(true)` for normal inheritance, `Some(false)` after `env_clear`, and `None` for a + /// native-only command because native command APIs do not expose that state. + pub fn inherits_environment(&self) -> Option { + match &self.state { + CommandState::Tracked(intent) => Some(!intent.env_clear), + CommandState::NativeOnly(_) => None, + } + } + /// Get the configured current directory. pub fn get_current_dir(&self) -> Option<&Path> { match &self.state { @@ -673,13 +987,23 @@ impl Command { /// Mutably access the frontend's native command. /// /// Calling this permanently makes the command native-only. Alternate portable transports cannot - /// recover exact portable intent after arbitrary native mutation. + /// recover exact portable intent after arbitrary native mutation. On Unix, process-wrap reinstalls + /// any built-in child setup when it next spawns the command, so replacing the native value does not + /// discard that setup. + /// + /// On Unix, every mutable native escape must conservatively invalidate process-wrap's installed + /// child-setup callback because the native API does not reveal whether a callback was added or the + /// command was moved out. Repeated escapes from the same native-only command can therefore retain + /// 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)); } + #[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"), @@ -698,6 +1022,7 @@ impl Command { Self { state: CommandState::NativeOnly(NativeOnlyCommand::new(command)), wrappers: B::new_registry(), + platform: PlatformCommandState::default(), backend: PhantomData, } } @@ -714,20 +1039,64 @@ impl Command { .expect("the backend always creates its matching wrapper registry") } - pub(crate) fn with_native( + /// Return whether this command contains opaque native-only state. + pub fn is_native_only(&self) -> bool { + matches!(self.state, CommandState::NativeOnly(_)) + } + + pub(crate) fn with_spawn_attempt( &mut self, - invoke: impl FnOnce(&mut Self, &mut B::NativeCommand) -> std::io::Result, + invoke: impl FnOnce(&mut Self, &mut SpawnAttempt) -> std::io::Result, ) -> std::io::Result { + let platform = self.platform.clone(); match &mut self.state { CommandState::Tracked(intent) => { - let mut native = intent.materialize::(); - invoke(self, &mut native) + 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), + } } CommandState::NativeOnly(command) => { - let mut native = command.take(); + 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 native) + 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(_) => { @@ -743,6 +1112,494 @@ impl Command { } } +impl SpawnAttempt { + /// Add an argument for this spawn attempt. + 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), + } + self + } + + /// Add multiple arguments for this spawn attempt. + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + S: AsRef, + { + for arg in args { + self.arg(arg); + } + self + } + + /// Add a raw command-line fragment without quoting or escaping. + /// + /// This method is only available on Windows. + #[cfg(windows)] + 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), + } + self + } + + /// Set an environment variable for this spawn attempt. + pub fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + let key = key.as_ref(); + let value = value.as_ref(); + match &mut self.state { + AttemptState::Tracked { intent, .. } => intent + .env + .push(EnvChange::Set(key.to_owned(), value.to_owned())), + AttemptState::NativeOnly(command) => command.env(key, value), + } + self + } + + /// Set multiple environment variables for this spawn attempt. + pub fn envs(&mut self, vars: I) -> &mut Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + for (key, value) in vars { + self.env(key, value); + } + self + } + + /// Remove an environment variable for this spawn attempt. + 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), + } + self + } + + /// 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, .. } => { + intent.env_clear = true; + intent.env.clear(); + } + AttemptState::NativeOnly(command) => command.env_clear(), + } + self + } + + /// Set the child process's current directory for this spawn attempt. + 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), + } + self + } + + /// Configure standard input and make this spawn attempt native-only. + pub fn stdin(&mut self, stdio: Stdio) -> &mut Self { + self.native_mut().stdin(stdio); + self + } + + /// Configure standard output and make this spawn attempt native-only. + pub fn stdout(&mut self, stdio: Stdio) -> &mut Self { + self.native_mut().stdout(stdio); + self + } + + /// Configure standard error and make this spawn attempt native-only. + pub fn stderr(&mut self, stdio: Stdio) -> &mut Self { + self.native_mut().stderr(stdio); + self + } + + /// 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(), + } + } + + /// 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(), + } + } + + /// Get the losslessly tracked portable arguments in command-line order. + /// + /// Unlike [`get_args`](Self::get_args), this preserves regular versus raw Windows arguments. Returns + /// `None` for a native-only attempt. Process-wrap performs that rejection before a provider's + /// `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, + } + } + + /// 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(), + } + } + + /// Return whether this portable attempt inherits the parent environment. + /// + /// Returns `Some(true)` for normal inheritance, `Some(false)` after `env_clear`, and `None` for a + /// native-only attempt. Process-wrap performs that rejection before a provider's `validate_attempt` + /// 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, + } + } + + /// 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(), + } + } + + /// Return whether dropping the direct child must terminate it. + /// + /// This is currently a Tokio policy. Alternate Tokio providers use it to preserve the behavior of + /// the `KillOnDrop` wrapper without requiring a native Tokio command. + pub fn kills_on_drop(&self) -> bool { + self.kill_on_drop.unwrap_or(false) + } + + /// Return the portable Windows creation policy for this attempt. + #[cfg(windows)] + pub fn windows_spawn_policy(&self) -> WindowsSpawnPolicy { + self.windows_policy + } + + #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] + pub(crate) fn set_kill_on_drop(&mut self, kill_on_drop: bool) { + self.kill_on_drop = Some(kill_on_drop); + #[cfg(windows)] + self.windows_policy.set_kill_on_drop(kill_on_drop); + } + + #[cfg(all(windows, feature = "creation-flags"))] + pub(crate) fn set_windows_creation_flags(&mut self, flags: u32) { + self.windows_policy.set_creation_flags(flags); + } + + #[cfg(all(windows, feature = "job-object"))] + pub(crate) fn set_job_object(&mut self) { + self.windows_policy.set_job_object(); + } + + #[cfg(windows)] + pub(crate) fn starts_suspended(&self) -> bool { + self.windows_policy.starts_suspended() + } + + /// Return the process-group setup requested for this attempt. + /// + /// Alternate spawn providers use this to apply process-group intent without making the attempt + /// native-only. This method remains available when the `process-group` wrapper feature is disabled + /// and returns `None` in that configuration. + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + pub fn process_group_target(&self) -> Option { + self.unix_policy.process_group + } + + /// Return whether this attempt must create a new process session. + /// + /// This method remains available when the `process-session` wrapper feature is disabled and returns + /// `false` in that configuration. + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + pub fn creates_process_session(&self) -> bool { + self.unix_policy.process_session + } + + /// Return whether this attempt must reset the child signal mask. + /// + /// This method remains available when the `reset-sigmask` wrapper feature is disabled and returns + /// `false` in that configuration. + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + pub fn resets_sigmask(&self) -> bool { + self.unix_policy.reset_sigmask + } + + #[cfg(all( + unix, + any(feature = "std", feature = "tokio1"), + feature = "process-group" + ))] + pub(crate) fn set_process_group( + &mut self, + target: crate::unix::ProcessGroupTarget, + ) -> std::io::Result<()> { + self.unix_policy.set_process_group(target) + } + + #[cfg(all( + unix, + any(feature = "std", feature = "tokio1"), + feature = "process-session" + ))] + pub(crate) fn set_process_session(&mut self) -> std::io::Result<()> { + self.unix_policy.set_process_session() + } + + #[cfg(all( + unix, + any(feature = "std", feature = "tokio1"), + feature = "reset-sigmask" + ))] + pub(crate) fn set_reset_sigmask(&mut self) { + self.unix_policy.reset_sigmask = true; + } + + fn prepare_platform(&mut self) { + let kill_on_drop = self.kill_on_drop; + #[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"), + }; + if let Some(kill_on_drop) = kill_on_drop { + command.configure_kill_on_drop(kill_on_drop); + } + #[cfg(windows)] + if windows_policy.applies_creation_flags() { + command.creation_flags(windows_policy.spawn_creation_flags()); + } + } + + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + { + 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"), + }; + self.platform + .unix + .prepare(command, native_only_base, policy); + } + } + + 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::()); + } + } + } + + fn make_native_only(&mut self) { + 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); + } + + 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, + } + } + + fn invalidate_platform(&mut self) { + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + self.platform.unix.invalidate(); + } + + pub(crate) fn native_for_spawn(&mut self) -> &mut B::NativeCommand { + self.materialize_native(); + self.prepare_platform(); + self.native_command_mut() + } + + pub(crate) fn native_for_explicit_spawn(&mut self) -> &mut B::NativeCommand { + self.make_native_only(); + self.prepare_platform(); + self.native_command_mut() + } + + /// Mutably access the frontend's native command for this spawn attempt. + /// + /// Calling this makes only this attempt native-only. An alternate portable provider rejects that + /// attempt because it cannot recover exact portable intent after arbitrary native mutation. On + /// Unix, built-in child setup is installed after all pre-spawn hooks have run, so replacing the + /// native value here does not discard that setup. + /// + /// Each Unix native escape conservatively invalidates any dispatcher callback retained from an + /// earlier attempt because process-wrap cannot observe native callback insertion or ownership + /// changes. A wrapper which does this on every reuse of a native-only command can therefore leave + /// inactive callbacks attached; prefer portable attempt methods for recurring configuration. + pub fn native_mut(&mut self) -> &mut B::NativeCommand { + self.make_native_only(); + self.invalidate_platform(); + self.native_command_mut() + } + + /// Return whether this spawn attempt contains opaque native-only state. + pub fn is_native_only(&self) -> bool { + matches!(self.state, AttemptState::NativeOnly(_)) + } +} + +#[cfg(all(feature = "std", unix))] +impl SpawnAttempt { + /// Set the child process's user ID and make this spawn attempt native-only. + pub fn uid(&mut self, id: u32) -> &mut Self { + use ::std::os::unix::process::CommandExt; + CommandExt::uid(self.native_mut(), id); + self + } + + /// Set the child process's group ID and make this spawn attempt native-only. + pub fn gid(&mut self, id: u32) -> &mut Self { + use ::std::os::unix::process::CommandExt; + CommandExt::gid(self.native_mut(), id); + self + } + + /// Set the child process's `argv[0]` and make this spawn attempt native-only. + pub fn arg0(&mut self, arg: impl AsRef) -> &mut Self { + use ::std::os::unix::process::CommandExt; + CommandExt::arg0(self.native_mut(), arg); + self + } + + /// Set the child process's process group and make this spawn attempt native-only. + pub fn process_group(&mut self, pgroup: i32) -> &mut Self { + use ::std::os::unix::process::CommandExt; + CommandExt::process_group(self.native_mut(), pgroup); + self + } + + /// Register a callback to run in the child after `fork` and make this attempt native-only. + /// + /// # Safety + /// + /// The callback runs in the child process after `fork` and before `exec`. It may only perform + /// operations which are valid in that constrained environment. + pub unsafe fn pre_exec(&mut self, f: F) -> &mut Self + where + F: FnMut() -> ::std::io::Result<()> + Send + Sync + 'static, + { + use ::std::os::unix::process::CommandExt; + // SAFETY: the caller accepts the native `pre_exec` contract documented above. + unsafe { CommandExt::pre_exec(self.native_mut(), f) }; + self + } +} + +#[cfg(all(feature = "std", windows))] +impl SpawnAttempt { + /// Set Windows process creation flags and make this spawn attempt native-only. + pub fn creation_flags(&mut self, flags: u32) -> &mut Self { + use ::std::os::windows::process::CommandExt; + CommandExt::creation_flags(self.native_mut(), flags); + self + } +} + +#[cfg(feature = "tokio1")] +impl SpawnAttempt { + /// Configure whether dropping the Tokio child kills it and make this attempt native-only. + pub fn kill_on_drop(&mut self, kill_on_drop: bool) -> &mut Self { + self.native_mut().kill_on_drop(kill_on_drop); + self + } +} + +#[cfg(all(feature = "tokio1", unix))] +impl SpawnAttempt { + /// Set the child process's user ID and make this spawn attempt native-only. + pub fn uid(&mut self, id: u32) -> &mut Self { + self.native_mut().uid(id); + self + } + + /// Set the child process's group ID and make this spawn attempt native-only. + pub fn gid(&mut self, id: u32) -> &mut Self { + self.native_mut().gid(id); + self + } + + /// Set the child process's `argv[0]` and make this spawn attempt native-only. + pub fn arg0(&mut self, arg: impl AsRef) -> &mut Self { + self.native_mut().arg0(arg); + self + } + + /// Register a callback to run in the child after `fork` and make this attempt native-only. + /// + /// # Safety + /// + /// The callback runs in the child process after `fork` and before `exec`. It may only perform + /// operations which are valid in that constrained environment. + pub unsafe fn pre_exec(&mut self, f: F) -> &mut Self + where + F: FnMut() -> ::std::io::Result<()> + Send + Sync + 'static, + { + // SAFETY: the caller accepts the native `pre_exec` contract documented above. + unsafe { self.native_mut().pre_exec(f) }; + self + } +} + +#[cfg(all(feature = "tokio1", windows))] +impl SpawnAttempt { + /// Set Windows process creation flags and make this spawn attempt native-only. + pub fn creation_flags(&mut self, flags: u32) -> &mut Self { + self.native_mut().creation_flags(flags); + self + } +} + #[cfg(all(feature = "std", unix))] impl Command { /// Set the child process's user ID and make the command native-only. @@ -810,20 +1667,6 @@ impl Command { } } -#[cfg(all(feature = "tokio1", feature = "process-group", unix))] -pub(crate) fn tokio_process_group(command: &mut tokio::process::Command, pgroup: i32) { - let set_process_group = move || { - // SAFETY: `setpgid` is called in the child with its own PID and does not retain pointers. - if unsafe { nix::libc::setpgid(0, pgroup) } == -1 { - Err(::std::io::Error::last_os_error()) - } else { - Ok(()) - } - }; - // SAFETY: the callback only invokes `setpgid`, which is valid between `fork` and `exec`. - unsafe { command.pre_exec(set_process_group) }; -} - #[cfg(all(feature = "tokio1", unix))] impl Command { /// Set the child process's user ID and make the command native-only. @@ -879,7 +1722,9 @@ mod windows_tests { process::Stdio, }; - use super::{CommandArg, CommandIntent, NativeCommand}; + use super::{ + CREATE_SUSPENDED_FLAG, CommandArg, CommandIntent, NativeCommand, WindowsSpawnPolicy, + }; #[derive(Debug, Eq, PartialEq)] enum RecordedArg { @@ -923,6 +1768,8 @@ mod windows_tests { fn stderr(&mut self, _stdio: Stdio) {} + fn creation_flags(&mut self, _flags: u32) {} + fn get_program(&self) -> &OsStr { &self.program } @@ -969,4 +1816,50 @@ mod windows_tests { ] ); } + + #[test] + fn windows_policy_preserves_flags_without_a_job() { + let flags = 0x0000_0200 | 0x0800_0000; + let mut policy = WindowsSpawnPolicy::default(); + policy.set_creation_flags(flags); + + assert!(policy.has_creation_flags()); + assert_eq!(policy.user_creation_flags(), flags); + assert_eq!(policy.spawn_creation_flags(), flags); + assert!(!policy.has_job_object()); + assert!(!policy.is_explicitly_suspended()); + assert!(!policy.is_temporarily_suspended()); + assert!(!policy.starts_suspended()); + } + + #[test] + fn windows_policy_adds_only_temporary_job_suspension() { + let flags = 0x0000_0200 | 0x0800_0000; + let mut policy = WindowsSpawnPolicy::default(); + policy.set_job_object(); + policy.set_creation_flags(flags); + + assert_eq!(policy.user_creation_flags(), flags); + assert_eq!(policy.spawn_creation_flags(), flags | CREATE_SUSPENDED_FLAG); + assert!(policy.has_job_object()); + assert!(!policy.is_explicitly_suspended()); + assert!(policy.is_temporarily_suspended()); + assert!(policy.starts_suspended()); + } + + #[test] + fn windows_policy_preserves_explicit_suspension_and_kill_on_drop() { + let flags = 0x0800_0000 | CREATE_SUSPENDED_FLAG; + let mut policy = WindowsSpawnPolicy::default(); + policy.set_creation_flags(flags); + policy.set_job_object(); + policy.set_kill_on_drop(true); + + assert_eq!(policy.user_creation_flags(), flags); + assert_eq!(policy.spawn_creation_flags(), flags); + assert!(policy.is_explicitly_suspended()); + assert!(!policy.is_temporarily_suspended()); + assert!(policy.starts_suspended()); + assert!(policy.kills_on_drop()); + } } diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index 3c0114f..e0d798f 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -4,381 +4,782 @@ )] macro_rules! Wrap { - ($backend:ty, $command:ty, $child:ty, $childer:ident, $first_child_wrapper:expr) => { - trait ErasedCommandWrapper: ::std::fmt::Debug + Send + Sync { - fn as_command_wrapper_mut(&mut self) -> &mut dyn CommandWrapper; - fn as_any(&self) -> &dyn ::std::any::Any; - fn as_any_mut(&mut self) -> &mut dyn ::std::any::Any; - } - - impl ErasedCommandWrapper for W { - fn as_command_wrapper_mut(&mut self) -> &mut dyn CommandWrapper { - self - } - - fn as_any(&self) -> &dyn ::std::any::Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn ::std::any::Any { - self - } - } - - #[derive(Debug, Default)] - struct WrapperRegistry { - wrappers: ::indexmap::IndexMap< - ::std::any::TypeId, - Option>, - >, - } - - impl crate::command::Backend for $backend { - type NativeCommand = $command; - - fn new_registry() -> Box { - Box::new(WrapperRegistry::default()) - } - } - - /// A configurable process command with composable wrappers. - pub type Command = crate::command::Command<$backend>; - - /// Backwards-compatible name for [`Command`]. - pub type CommandWrap = Command; - - impl crate::command::Command<$backend> { - fn wrapper_registry(&self) -> &WrapperRegistry { - self.registry() - } - - fn wrapper_registry_mut(&mut self) -> &mut WrapperRegistry { - self.registry_mut() - } - - /// Add a wrapper to the command. - /// - /// This is a lazy method, and the wrapper is not actually applied until `spawn` is - /// called. - /// - /// Only one wrapper of a given type can be applied to a command. If `wrap` is called - /// twice with the same type, the existing wrapper receives the newly registered wrapper - /// through its typed `extend` hook and can merge its configuration. If the hook does - /// nothing, the _new_ wrapper is silently discarded. - /// - /// Returns `&mut self` for chaining. - pub fn wrap(&mut self, wrapper: W) -> &mut Self { - let typeid = ::std::any::TypeId::of::(); - let mut wrapper = Some(wrapper); - let extant = self - .wrapper_registry_mut() - .wrappers - .entry(typeid) - .or_insert_with(|| { - Some(Box::new(wrapper.take().unwrap()) as Box) - }); - if let Some(wrapper) = wrapper { - extant - .as_mut() - .expect("wrap() cannot run while the matching wrapper's hook is active") - .as_any_mut() - .downcast_mut::() - .expect("downcasting is guaranteed to succeed due to wrap()'s internals") - .extend(wrapper); - } - - self - } - - #[inline] - fn with_wrapper_at( - &mut self, - index: usize, - invoke: impl FnOnce(&mut dyn CommandWrapper, &CommandWrap) -> ::std::io::Result, - ) -> ::std::io::Result { - let mut wrapper = self - .wrapper_registry_mut() - .wrappers - .get_index_mut(index) - .expect("wrapper indices cannot disappear during ordered hook traversal") - .1 - .take() - .expect("each wrapper is present when its lifecycle hook begins"); - - let result = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - invoke(wrapper.as_command_wrapper_mut(), self) - })); - - let slot = self - .wrapper_registry_mut() - .wrappers - .get_index_mut(index) - .expect("wrapper registrations cannot disappear while their hooks run") - .1; - debug_assert!(slot.is_none()); - *slot = Some(wrapper); - - match result { - Ok(result) => result, - Err(payload) => ::std::panic::resume_unwind(payload), - } - } - - #[inline] - fn run_pre_spawn(&mut self, command: &mut $command) -> ::std::io::Result<()> { - 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, "pre_spawn"); - } - self.with_wrapper_at(index, |wrapper, core| { - wrapper.pre_spawn(command, core) - })?; - } - - Ok(()) - } - - #[inline] - fn run_wrap_child( - &mut self, - mut child: Box, - ) -> ::std::io::Result> { - 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"); - } - child = self.with_wrapper_at(index, |wrapper, core| { - wrapper.wrap_child(child, core) - })?; - } - - Ok(child) - } - - #[inline] - fn spawn_inner( - &mut self, - command: &mut $command, - spawner: impl FnOnce(&mut $command) -> ::std::io::Result<$child>, - ) -> ::std::io::Result> { - self.run_pre_spawn(command)?; - - let mut child = spawner(command)?; - 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.with_wrapper_at(index, |wrapper, core| { - wrapper.post_spawn(command, &mut child, core) - })?; - } - - let child = Box::new( - #[allow(clippy::redundant_closure_call)] - $first_child_wrapper(child), - ) as Box; - - self.run_wrap_child(child) - } - - #[inline] - fn spawn_with_child_inner( - &mut self, - command: &mut $command, - spawner: impl FnOnce( - &mut $command, - ) -> ::std::io::Result>, - ) -> ::std::io::Result> { - self.run_pre_spawn(command)?; - let child = spawner(command)?; - self.run_wrap_child(child) - } - - /// Spawn the command, returning a child that can be interacted with. - /// - /// In order, this runs all the `pre_spawn` hooks, then spawns the command, then runs - /// all the `post_spawn` hooks, then stacks all the `wrap_child`s. As it returns a boxed - /// trait object, only the methods from the trait are available directly; however you - /// may downcast to the concrete type of the last applied wrapper if you need to. - pub fn spawn(&mut self) -> ::std::io::Result> { - self.spawn_with(|command| command.spawn()) - } - - /// Spawn the command using a custom native-child spawner function. - /// - /// This is like [`spawn`](Self::spawn), but instead of calling `command.spawn()` - /// directly, it calls the provided closure to create the native child process. This is - /// useful when you need to use a platform-specific spawning mechanism that still returns - #[doc = concat!("a [`", stringify!($child), "`].")] - /// - /// The lifecycle is the same as `spawn`: all `pre_spawn` hooks run first, then - /// the provided closure is called, then `post_spawn` hooks, then `wrap_child`. - pub fn spawn_with( - &mut self, - spawner: impl FnOnce(&mut $command) -> ::std::io::Result<$child>, - ) -> ::std::io::Result> { - self.with_native(|core, command| core.spawn_inner(command, spawner)) - } - - /// Spawn the command using a custom boxed-child spawner function. - /// - /// This is the spawning path for custom child implementations which do not return the - #[doc = concat!("native [`", stringify!($child), "`] type. The closure must return a boxed [`", stringify!($childer), "`] trait object.")] - /// - /// All `pre_spawn` hooks run first, then the provided closure is called, then - /// `wrap_child` hooks are applied. `post_spawn` is intentionally skipped because that - #[doc = concat!("hook requires a native [`", stringify!($child), "`]. Use [`spawn_with`](Self::spawn_with) when the spawner returns one.")] - pub fn spawn_with_child( - &mut self, - spawner: impl FnOnce( - &mut $command, - ) -> ::std::io::Result>, - ) -> ::std::io::Result> { - self.with_native(|core, command| { - core.spawn_with_child_inner(command, spawner) - }) - } - - /// Check if a wrapper of a given type is present. - pub fn has_wrap(&self) -> bool { - let typeid = ::std::any::TypeId::of::(); - self.wrapper_registry().wrappers.contains_key(&typeid) - } - - /// Get a reference to a wrapper of a given type. - /// - /// This is useful for getting access to the state of a wrapper, generally from within - /// another wrapper. - /// - /// Returns `None` if the wrapper is not present. While a wrapper's lifecycle hook is - /// running, that active wrapper remains registered but is temporarily unavailable through - /// this method; peer wrappers remain available. To merely check registration, use - /// `has_wrap` instead. - pub fn get_wrap(&self) -> Option<&W> { - let typeid = ::std::any::TypeId::of::(); - self.wrapper_registry() - .wrappers - .get(&typeid) - .and_then(Option::as_deref) - .map(|wrapper| { - wrapper - .as_any() - .downcast_ref() - .expect("downcasting is guaranteed to succeed due to wrap()'s internals") - }) - } - } - - impl From<$command> for crate::command::Command<$backend> { - fn from(command: $command) -> Self { - Self::from_native(command) - } - } - - /// A trait for adding functionality to a command. - /// - /// This trait provides extension or hook points into the lifecycle of a command. See the - /// [crate-level doc](crate) for an overview. - /// - /// All methods are optional, so a minimal impl may be: - /// - /// ```rust,ignore - /// #[derive(Debug)] - /// pub struct YourWrapper; - #[doc = concat!("impl ", stringify!(CommandWrapper), " for YourWrapper {}\n```")] - pub trait CommandWrapper: ::std::fmt::Debug + Send + Sync { - /// Called on a first instance if a second of the same type is added. - /// - /// Only one wrapper of a given type can exist within a Wrap at a time. By default, - /// later registrations are discarded. In some cases it is useful to merge their - /// configuration instead. This method is called on the stored wrapper with the newly - /// registered wrapper of the same concrete type. - /// - /// Because `other` is `Self`, implementations can inspect or move its type-specific - /// fields directly without downcasting. - /// - /// Default impl: no-op. - fn extend(&mut self, _other: Self) - where - Self: Sized, - { - } - - /// Called before the command is spawned, to mutate it as needed. - /// - /// This is where to modify the native command for one spawn attempt. It also gives mutable - /// access to the wrapper instance, so state can be stored if needed. The `core` - /// reference gives access to data from other wrappers; for example, that's how - /// `CreationFlags` on Windows works along with `JobObject`. - /// - /// Default impl: no-op. - fn pre_spawn( - &mut self, - _command: &mut $command, - _core: &CommandWrap, - ) -> ::std::io::Result<()> { - Ok(()) - } - - /// Called after spawn, but before the child is wrapped. - /// - /// The `core` reference gives access to data from other wrappers; for example, that's - /// how `CreationFlags` on Windows works along with `JobObject`. - /// - /// Default: no-op. - fn post_spawn( - &mut self, - _command: &mut $command, - _child: &mut $child, - _core: &CommandWrap, - ) -> ::std::io::Result<()> { - Ok(()) - } - - /// Called to wrap a child into this command wrapper's child wrapper. - /// - /// If the wrapper needs to override the methods on Child, then it should create an - /// instance of its own type implementing `ChildWrapper` and return it here. Child wraps - /// are _in order_: you may end up with a `Foo(Bar(Child))` or a `Bar(Foo(Child))` - /// depending on if `.wrap(Foo).wrap(Bar)` or `.wrap(Bar).wrap(Foo)` was called. - /// - /// The `core` reference gives access to data from other wrappers; for example, that's - /// how `CreationFlags` on Windows works along with `JobObject`. - /// - /// Default: no-op (ie, returns the child unchanged). - fn wrap_child( - &mut self, - child: Box, - _core: &CommandWrap, - ) -> ::std::io::Result> { - Ok(child) - } - } - }; + ($backend:ty, $command:ty, $child:ty, $childer:ident, $first_child_wrapper:expr) => { + trait ErasedCommandWrapper: ::std::fmt::Debug + Send + Sync { + fn as_command_wrapper(&self) -> &dyn CommandWrapper; + fn as_command_wrapper_mut(&mut self) -> &mut dyn CommandWrapper; + fn as_any(&self) -> &dyn ::std::any::Any; + fn as_any_mut(&mut self) -> &mut dyn ::std::any::Any; + } + + impl ErasedCommandWrapper for W { + fn as_command_wrapper(&self) -> &dyn CommandWrapper { + self + } + + fn as_command_wrapper_mut(&mut self) -> &mut dyn CommandWrapper { + self + } + + fn as_any(&self) -> &dyn ::std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn ::std::any::Any { + self + } + } + + #[derive(Debug, Default)] + struct WrapperRegistry { + wrappers: ::indexmap::IndexMap< + ::std::any::TypeId, + Option>, + >, + } + + impl crate::command::Backend for $backend { + type NativeCommand = $command; + + fn new_registry() -> Box { + Box::new(WrapperRegistry::default()) + } + } + + /// A configurable process command with composable wrappers. + pub type Command = crate::command::Command<$backend>; + + /// Backwards-compatible name for [`Command`]. + pub type CommandWrap = Command; + + /// The command configuration for one spawn attempt. + pub type SpawnAttempt = crate::command::SpawnAttempt<$backend>; + + /// A child and armed cleanup transaction returned by a [`SpawnProvider`]. + /// + /// 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. + #[derive(Debug)] + pub struct ProviderProduct { + child: Box, + transaction: Box, + } + + impl ProviderProduct { + /// Create a provider product from its child and armed cleanup transaction. + /// + /// 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. + pub fn new( + child: Box, + transaction: Box, + ) -> Self { + Self { child, transaction } + } + + fn into_parts( + self, + ) -> (Box, Box) { + (self.child, self.transaction) + } + } + + /// An alternate transport for spawning this frontend's child contract. + /// + /// Providers are exposed by command wrappers through [`CommandWrapper::spawn_provider`]. A + /// command may register only one provider. The same provider and wrapper instances are reused + /// across repeated spawn attempts, so callbacks take `&self` and must not consume persistent + /// configuration. + /// + /// 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. + pub trait SpawnProvider: ::std::fmt::Debug + Send + Sync + 'static { + /// Check whether this provider is available on the current platform and runtime. + /// + /// This runs before command validation or native-only rejection so an unsupported provider + /// retains error precedence. It must not allocate per-spawn operating-system resources. + fn check_available(&self) -> ::std::io::Result<()> { + Ok(()) + } + + /// Validate immutable command and wrapper configuration before hooks run. + /// + /// This must not allocate per-spawn operating-system resources. The provider-owning wrapper is + /// registered but temporarily unavailable through `Command::get_wrap` during this callback; + /// peer wrappers remain available. + fn validate_command(&self, _command: &Command) -> ::std::io::Result<()> { + Ok(()) + } + + /// Validate the completed portable attempt before operating-system allocation. + /// + /// Providers should inspect `get_portable_args`, `inherits_environment`, `get_envs`, the current + /// directory, and platform policy getters here, then reject any policy they cannot preserve. + /// Process-wrap has already rejected an opaque attempt before this callback. + fn validate_attempt( + &self, + _attempt: &SpawnAttempt, + _command: &Command, + ) -> ::std::io::Result<()> { + Ok(()) + } + + /// Spawn a child and return it with a fresh armed cleanup transaction. + /// + /// 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. + fn spawn( + &self, + attempt: &mut SpawnAttempt, + command: &Command, + ) -> ::std::io::Result; + } + + impl crate::command::Command<$backend> { + fn wrapper_registry(&self) -> &WrapperRegistry { + self.registry() + } + + fn wrapper_registry_mut(&mut self) -> &mut WrapperRegistry { + self.registry_mut() + } + + /// Add a wrapper to the command. + /// + /// This is a lazy method, and the wrapper is not actually applied until `spawn` is called. + /// + /// Only one wrapper of a given type can be applied to a command. If `wrap` is called twice + /// with the same type, the existing wrapper receives the newly registered wrapper through + /// its typed `extend` hook and can merge its configuration. If the hook does nothing, the + /// _new_ wrapper is silently discarded. + /// + /// Returns `&mut self` for chaining. + pub fn wrap(&mut self, wrapper: W) -> &mut Self { + let typeid = ::std::any::TypeId::of::(); + let mut wrapper = Some(wrapper); + let extant = self + .wrapper_registry_mut() + .wrappers + .entry(typeid) + .or_insert_with(|| { + Some(Box::new(wrapper.take().unwrap()) as Box) + }); + if let Some(wrapper) = wrapper { + extant + .as_mut() + .expect("wrap() cannot run while the matching wrapper's hook is active") + .as_any_mut() + .downcast_mut::() + .expect("downcasting is guaranteed to succeed due to wrap()'s internals") + .extend(wrapper); + } + + self + } + + #[inline] + fn with_wrapper_at( + &mut self, + index: usize, + invoke: impl FnOnce(&mut dyn CommandWrapper, &Command) -> ::std::io::Result, + ) -> ::std::io::Result { + let mut wrapper = self + .wrapper_registry_mut() + .wrappers + .get_index_mut(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .1 + .take() + .expect("each wrapper is present when its lifecycle hook begins"); + + let result = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { + invoke(wrapper.as_command_wrapper_mut(), self) + })); + + let slot = self + .wrapper_registry_mut() + .wrappers + .get_index_mut(index) + .expect("wrapper registrations cannot disappear while their hooks run") + .1; + debug_assert!(slot.is_none()); + *slot = Some(wrapper); + + match result { + Ok(result) => result, + Err(payload) => ::std::panic::resume_unwind(payload), + } + } + + fn select_spawn_provider(&self) -> ::std::io::Result> { + let mut selected = None; + for (index, wrapper) in self.wrapper_registry().wrappers.values().enumerate() { + let wrapper = wrapper + .as_ref() + .expect("provider selection runs outside wrapper lifecycle hooks"); + if wrapper + .as_command_wrapper() + .spawn_provider() + .is_some() + { + if selected.replace(index).is_some() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "multiple spawn providers are registered", + )); + } + } + } + Ok(selected) + } + + fn with_spawn_provider_at( + &mut self, + index: usize, + invoke: impl FnOnce(&dyn SpawnProvider, &Command) -> ::std::io::Result, + ) -> ::std::io::Result { + self.with_wrapper_at(index, |wrapper, command| { + let provider = wrapper + .spawn_provider() + .expect("the selected wrapper continues to expose its spawn provider"); + invoke(provider, command) + }) + } + + fn reject_explicit_provider(&self) -> ::std::io::Result<()> { + if self.select_spawn_provider()?.is_some() { + Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "an explicit spawner cannot bypass a registered spawn provider", + )) + } else { + Ok(()) + } + } + + #[inline] + fn run_pre_spawn(&mut self, attempt: &mut SpawnAttempt) -> ::std::io::Result<()> { + 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, "pre_spawn"); + } + self.with_wrapper_at(index, |wrapper, command| { + wrapper.pre_spawn(attempt, command) + })?; + } + + Ok(()) + } + + #[cfg(windows)] + #[inline] + fn run_prepare_child( + &mut self, + attempt: &mut SpawnAttempt, + child: &mut dyn $childer, + ) -> ::std::io::Result< + Vec>>, + > { + let len = self.wrapper_registry().wrappers.len(); + let mut prepared = Vec::with_capacity(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"); + } + prepared.push(self.with_wrapper_at(index, |wrapper, command| { + wrapper.prepare_child(attempt, child, command) + })?); + } + + Ok(prepared) + } + + #[inline] + fn run_post_spawn( + &mut self, + attempt: &mut SpawnAttempt, + child: &mut dyn $childer, + ) -> ::std::io::Result<()> { + 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.with_wrapper_at(index, |wrapper, command| { + wrapper.post_spawn(attempt, child, command) + })?; + } + + Ok(()) + } + + #[inline] + fn run_wrap_child( + &mut self, + mut child: Box, + #[cfg(windows)] mut prepared: Vec< + Option>, + >, + ) -> ::std::io::Result> { + 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"); + } + child = self.with_wrapper_at(index, |wrapper, command| { + #[cfg(windows)] + { + wrapper.wrap_prepared_child( + child, + prepared[index].take(), + command, + ) + } + #[cfg(not(windows))] + { + wrapper.wrap_child(child, command) + } + })?; + } + + Ok(child) + } + + fn finish_spawn( + &mut self, + attempt: &mut SpawnAttempt, + mut child: Box, + ) -> ::std::io::Result> { + #[cfg(windows)] + let mut cleanup = if attempt.starts_suspended() { + let handle = match child.as_ref().try_process_handle() { + Some(handle) => handle, + None => { + let _ = child.start_kill(); + return Err(::std::io::Error::new( + ::std::io::ErrorKind::Unsupported, + "child wrapper does not expose a Windows process handle", + )); + } + }; + match crate::command::WindowsSpawnCleanup::new(handle) { + Ok(cleanup) => Some(cleanup), + Err(error) => { + let _ = crate::command::terminate_process_and_wait(handle); + return Err(error); + } + } + } else { + None + }; + + let result = (|| { + #[cfg(windows)] + let prepared = self.run_prepare_child(attempt, child.as_mut())?; + self.run_post_spawn(attempt, child.as_mut())?; + #[cfg(windows)] + { + self.run_wrap_child(child, prepared) + } + #[cfg(not(windows))] + { + self.run_wrap_child(child) + } + })(); + #[cfg(windows)] + let result = result.and_then(|mut child| { + child.finalize_spawn()?; + if let Some(cleanup) = cleanup.as_mut() { + cleanup.disarm(); + } + Ok(child) + }); + result + } + + fn rollback_transaction(transaction: Box) { + let _ = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { + let mut transaction = transaction; + let _ = transaction.rollback(); + })); + } + + fn finish_provider_spawn( + &mut self, + attempt: &mut SpawnAttempt, + product: ProviderProduct, + ) -> ::std::io::Result> { + let (mut child, transaction) = product.into_parts(); + let mut transaction = Some(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 child = self.run_wrap_child(child, prepared)?; + #[cfg(not(windows))] + let child = self.run_wrap_child(child)?; + transaction + .as_mut() + .expect("the provider transaction remains armed until commit") + .commit()?; + drop( + transaction + .take() + .expect("a committed provider transaction is still present"), + ); + #[cfg(windows)] + let child = { + let mut child = child; + child.finalize_spawn()?; + child + }; + Ok(child) + })); + + match result { + Ok(Ok(child)) => Ok(child), + Ok(Err(error)) => { + if let Some(transaction) = transaction.take() { + Self::rollback_transaction(transaction); + } + Err(error) + } + Err(payload) => { + if let Some(transaction) = transaction.take() { + Self::rollback_transaction(transaction); + } + ::std::panic::resume_unwind(payload) + } + } + } + + fn spawn_with_provider( + &mut self, + provider_index: usize, + ) -> ::std::io::Result> { + self.with_spawn_provider_at(provider_index, |provider, _| { + provider.check_available() + })?; + if self.is_native_only() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a spawn provider cannot use a native-only command", + )); + } + self.with_spawn_provider_at(provider_index, |provider, command| { + provider.validate_command(command) + })?; + + self.with_spawn_attempt(|command, attempt| { + command.run_pre_spawn(attempt)?; + if attempt.is_native_only() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a spawn provider cannot use a native-only spawn attempt", + )); + } + command.with_spawn_provider_at(provider_index, |provider, command| { + provider.validate_attempt(attempt, command) + })?; + let product = command.with_spawn_provider_at( + provider_index, + |provider, command| provider.spawn(attempt, command), + )?; + command.finish_provider_spawn(attempt, product) + }) + } + + /// 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 and commits its + /// cleanup transaction after the same complete hook chain succeeds. + pub fn spawn(&mut self) -> ::std::io::Result> { + if let Some(provider_index) = self.select_spawn_provider()? { + return self.spawn_with_provider(provider_index); + } + + self.with_spawn_attempt(|command, attempt| { + command.run_pre_spawn(attempt)?; + let child = attempt.native_for_spawn().spawn()?; + let child = Box::new( + #[allow(clippy::redundant_closure_call)] + $first_child_wrapper(child), + ) as Box; + command.finish_spawn(attempt, child) + }) + } + + /// Spawn the command using a custom native-child spawner function. + /// + /// This explicit transport cannot be combined with a registered spawn provider. Without a + /// provider, it runs the same pre-spawn, capability-level post-spawn, and child-wrapping + /// lifecycle as [`spawn`](Self::spawn). + /// + /// On Unix, when wrappers request built-in child setup, a successful spawner must create the + /// returned child from the native value before replacing that value. Whenever the spawner + /// replaces it, including before returning an error or unwinding, the displaced command must be + /// dropped before control leaves the spawner. A replacement is discarded with a tracked attempt + /// or retained by a native-only base. Process-wrap installs child setup before invoking the + /// spawner and cannot apply it to a replacement which the spawner creates and immediately spawns. + pub fn spawn_with( + &mut self, + spawner: impl FnOnce(&mut $command) -> ::std::io::Result<$child>, + ) -> ::std::io::Result> { + self.reject_explicit_provider()?; + self.with_spawn_attempt(|command, attempt| { + command.run_pre_spawn(attempt)?; + let child = spawner(attempt.native_for_explicit_spawn())?; + let child = Box::new( + #[allow(clippy::redundant_closure_call)] + $first_child_wrapper(child), + ) as Box; + command.finish_spawn(attempt, child) + }) + } + + /// Spawn the command using a custom boxed-child spawner function. + /// + /// This is the spawning path for custom child implementations which do not return the + #[doc = concat!("native [`", stringify!($child), "`] type. The closure must return a boxed [`", stringify!($childer), "`] trait object.")] + /// + /// This explicit transport cannot be combined with a registered spawn provider. Without a + /// provider, all `pre_spawn`, capability-level `post_spawn`, and `wrap_child` hooks run. + /// + /// On Unix, when wrappers request built-in child setup, a successful spawner must create the + /// returned child from the native value before replacing that value. Whenever the spawner + /// replaces it, including before returning an error or unwinding, the displaced command must be + /// dropped before control leaves the spawner. A replacement is discarded with a tracked attempt + /// or retained by a native-only base. Process-wrap installs child setup before invoking the + /// spawner and cannot apply it to a replacement which the spawner creates and immediately spawns. + pub fn spawn_with_child( + &mut self, + spawner: impl FnOnce( + &mut $command, + ) -> ::std::io::Result>, + ) -> ::std::io::Result> { + self.reject_explicit_provider()?; + self.with_spawn_attempt(|command, attempt| { + command.run_pre_spawn(attempt)?; + let child = spawner(attempt.native_for_explicit_spawn())?; + command.finish_spawn(attempt, child) + }) + } + + /// Check if a wrapper of a given type is present. + pub fn has_wrap(&self) -> bool { + let typeid = ::std::any::TypeId::of::(); + self.wrapper_registry().wrappers.contains_key(&typeid) + } + + /// Get a reference to a wrapper of a given type. + /// + /// This is useful for getting access to the state of a wrapper, generally from within + /// another wrapper. + /// + /// Returns `None` if the wrapper is not present. While a wrapper's lifecycle hook or provider + /// callback is running, that active wrapper remains registered but is temporarily unavailable + /// through this method; peer wrappers remain available. To merely check registration, use + /// `has_wrap` instead. + pub fn get_wrap(&self) -> Option<&W> { + let typeid = ::std::any::TypeId::of::(); + self.wrapper_registry() + .wrappers + .get(&typeid) + .and_then(Option::as_deref) + .map(|wrapper| { + wrapper + .as_any() + .downcast_ref() + .expect("downcasting is guaranteed to succeed due to wrap()'s internals") + }) + } + } + + impl From<$command> for crate::command::Command<$backend> { + fn from(command: $command) -> Self { + Self::from_native(command) + } + } + + /// A trait for adding functionality to a command. + /// + /// This trait provides extension and hook points into the lifecycle of a command. See the + /// [crate-level documentation](crate) for an overview. + /// + /// All methods are optional, so a minimal implementation may be: + /// + /// ```rust,ignore + /// #[derive(Debug)] + /// pub struct YourWrapper; + #[doc = concat!("impl ", stringify!(CommandWrapper), " for YourWrapper {}\n```")] + pub trait CommandWrapper: ::std::fmt::Debug + Send + Sync { + /// Called on a first instance if a second of the same type is added. + /// + /// Only one wrapper of a given type can exist within a command at a time. By default, later + /// registrations are discarded. In some cases it is useful to merge their configuration + /// instead. This method is called on the stored wrapper with the newly registered wrapper of + /// the same concrete type. + /// + /// Because `other` is `Self`, implementations can inspect or move its type-specific fields + /// directly without downcasting. + /// + /// Default implementation: no-op. + fn extend(&mut self, _other: Self) + where + Self: Sized, + { + } + + /// 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. + /// + /// 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 + /// and before `validate_attempt` or operating-system allocation. Portable policy setters remain + /// representable; a transport may apply their policy only after every hook has run and in the + /// order required by the platform. On Unix, recurring native escapes from a native-only base can + /// retain inactive dispatcher callbacks because the native API does not expose callback insertion + /// or command ownership; use portable attempt methods for recurring configuration. + /// + /// The `command` reference provides read-only access to peer wrappers and persistent base + /// configuration. The active wrapper remains registered but is temporarily unavailable through + /// `Command::get_wrap`. + /// + /// Default implementation: no-op. + fn pre_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> ::std::io::Result<()> { + Ok(()) + } + + /// 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. + #[doc(hidden)] + #[cfg(windows)] + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn $childer, + _command: &Command, + ) -> ::std::io::Result>> { + Ok(None) + } + + /// 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 + /// 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 + /// transports do not promise equivalent child cleanup on every platform. + /// + /// Default implementation: no-op. + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn $childer, + _command: &Command, + ) -> ::std::io::Result<()> { + Ok(()) + } + + /// Called to wrap a child into this command wrapper's child wrapper. + /// + /// 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. + /// + /// Default implementation: no-op (returns the child unchanged). + fn wrap_child( + &mut self, + child: Box, + _command: &Command, + ) -> ::std::io::Result> { + Ok(child) + } + + /// Install a child wrapper using state returned by `prepare_child`. + #[doc(hidden)] + #[cfg(windows)] + fn wrap_prepared_child( + &mut self, + child: Box, + prepared: Option>, + command: &Command, + ) -> ::std::io::Result> { + debug_assert!( + prepared.is_none(), + "the default child preparation does not produce wrapper state" + ); + self.wrap_child(child, command) + } + + /// Expose an alternate spawn provider implemented by this wrapper. + /// + /// If this returns `Some` during provider selection, it must continue returning `Some` for every + /// callback in that spawn lifecycle. The returned provider must refer to the same persistent + /// provider state. Only one registered wrapper may expose a provider. + /// + /// During a provider callback, this owning wrapper remains registered but is temporarily + /// unavailable through `Command::get_wrap`; peer wrappers remain available. + /// + /// Default implementation: no provider. + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + None + } + } + }; } pub(crate) use Wrap; diff --git a/src/lib.rs b/src/lib.rs index fdfef00..f2bbadd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,16 +8,20 @@ //! ``` //! //! ```rust,no_run -//! # fn main() -> std::io::Result<()> { +//! # #[cfg(feature = "std")] +//! # mod example { +//! # fn run() -> std::io::Result<()> { //! use process_wrap::std::*; //! //! let mut command = Command::with_new("watch", |command| { command.arg("ls"); }); -//! #[cfg(unix)] { command.wrap(ProcessGroup::leader()); } -//! #[cfg(windows)] { command.wrap(JobObject); } +//! #[cfg(all(unix, feature = "process-group"))] { command.wrap(ProcessGroup::leader()); } +//! #[cfg(all(windows, feature = "job-object"))] { command.wrap(JobObject); } //! let mut child = command.spawn()?; //! let status = child.wait()?; //! dbg!(status); //! # Ok(()) } +//! # } +//! # fn main() {} //! ``` //! //! ## Migrating from command-group @@ -42,21 +46,33 @@ //! modules for compatibility. //! //! ```rust +//! # #[cfg(feature = "std")] +//! # mod example { //! use process_wrap::std::*; +//! # fn run() { //! let mut command = Command::new("ls"); //! command.arg("-l"); -//! #[cfg(unix)] { command.wrap(ProcessGroup::leader()); } -//! #[cfg(windows)] { command.wrap(JobObject); } +//! #[cfg(all(unix, feature = "process-group"))] { command.wrap(ProcessGroup::leader()); } +//! #[cfg(all(windows, feature = "job-object"))] { command.wrap(JobObject); } +//! # } +//! # } +//! # fn main() {} //! ``` //! //! The closure constructor remains available, and its inferred argument is now process-wrap's //! command: //! //! ```rust +//! # #[cfg(feature = "std")] +//! # mod example { //! use process_wrap::std::*; +//! # fn run() { //! let mut command = Command::with_new("ls", |command| { command.arg("-l"); }); -//! #[cfg(unix)] { command.wrap(ProcessGroup::leader()); } -//! #[cfg(windows)] { command.wrap(JobObject); } +//! #[cfg(all(unix, feature = "process-group"))] { command.wrap(ProcessGroup::leader()); } +//! #[cfg(all(windows, feature = "job-object"))] { command.wrap(JobObject); } +//! # } +//! # } +//! # fn main() {} //! ``` //! //! Existing native commands can still be converted with `Command::from`. They retain exact native @@ -74,9 +90,15 @@ //! If targetting a single platform, then a fluent style is possible: //! //! ```rust +//! # #[cfg(all(unix, feature = "std", feature = "process-group"))] +//! # mod example { //! use process_wrap::std::*; +//! # fn run() { //! Command::with_new("ls", |command| { command.arg("-l"); }) //! .wrap(ProcessGroup::leader()); +//! # } +//! # } +//! # fn main() {} //! ``` //! //! The `wrap` method can be called multiple times to add multiple wrappers. The order of the @@ -89,21 +111,26 @@ //! //! # KillOnDrop and CreationFlags //! -//! The options set on an underlying `Command` are not queryable from library or user code. In most -//! cases this is not an issue; however on Windows, the `JobObject` wrapper needs to know the value -//! of `.kill_on_drop()` and any `.creation_flags()` set. The `KillOnDrop` and `CreationFlags` are -//! "shims" that _should_ be used instead of the aforementioned methods on `Command`. They will -//! internally set the values on the `Command` and also store them in the wrapper, so that wrappers -//! are able to access them. +//! Calling native `.kill_on_drop()` or `.creation_flags()` makes a command native-only: those +//! settings cannot be queried or reconstructed by wrappers and alternate transports. `JobObject` +//! and spawn providers nevertheless need those policies in order to compose correctly. The +//! `KillOnDrop` and `CreationFlags` wrappers therefore record portable policy on each spawn attempt +//! and _should_ be used instead of the native-only methods when composition is required. //! //! In practice: //! //! ## Instead of `.kill_on_drop(true)` (Tokio-only): //! //! ```rust +//! # #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +//! # mod example { //! use process_wrap::tokio::*; +//! # fn run() { //! let mut command = Command::with_new("ls", |command| { command.arg("-l"); }); //! command.wrap(KillOnDrop); +//! # } +//! # } +//! # fn main() {} //! ``` //! //! ## Instead of `.creation_flags(CREATE_NO_WINDOW)` (Windows-only): @@ -132,10 +159,14 @@ //! Here's the most basic impl (shown for Tokio): //! //! ```rust +//! # #[cfg(feature = "tokio1")] +//! # mod example { //! use process_wrap::tokio::*; //! #[derive(Debug)] //! pub struct YourWrapper; //! impl CommandWrapper for YourWrapper {} +//! # } +//! # fn main() {} //! ``` //! //! The trait provides extension or hook points into the lifecycle of a `Command`: @@ -145,23 +176,60 @@ //! incorporate all or part of the second, concretely typed wrapper. By default, this does nothing //! (that is, only the first registered wrapper instance of a type applies). //! -//! - **`fn pre_spawn(&mut self, command: &mut tokio::process::Command, core: &Command)`** is called -//! before the command is spawned, and gives mutable access to that attempt's native command. It -//! also gives mutable access to the wrapper instance, so state can be stored if needed. The `core` -//! reference gives access to data from other wrappers; for example, that's how `CreationFlags` on -//! Windows works along with `JobObject`. By default does nothing. -//! -//! - **`fn post_spawn(&mut self, command: &mut tokio::process::Command, child: &mut tokio::process::Child, core: &Command)`** -//! is called after spawn, and should be used for any necessary cleanups. It is offered for -//! completeness but is expected to be less used than `wrap_child()`. By default does nothing. -//! -//! - **`fn wrap_child(&mut self, child: Box, core: &Command)`** is -//! called after all `post_spawn()`s have run. If your wrapper needs to override the methods on -//! Child, then it should create an instance of its own type implementing `ChildWrapper` and -//! return it here. Child wraps are _in order_: you may end up with a `Foo(Bar(Child))` or a -//! `Bar(Foo(Child))` depending on if `.wrap(Foo).wrap(Bar)` or `.wrap(Bar).wrap(Foo)` was called. -//! If your functionality is order-dependent, make sure to specify so in your documentation! By -//! default does nothing: no wrapping is performed and the input `child` is returned as-is. +//! - **`fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, command: &Command)`** is called before +//! spawning. It can record portable policy for this attempt and inspect peer wrappers through +//! `command`. Mutations copied from a tracked command apply to one attempt; native-only commands +//! retain native mutations. Calling `attempt.native_mut()` or `stdin`/`stdout`/`stderr` makes a +//! tracked attempt incompatible with a portable provider. By default does nothing. +//! +//! - **`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 +//! 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 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 +//! 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. +//! +//! ## Spawn providers +//! +//! A spawn provider replaces process creation while retaining the complete wrapper lifecycle, making +//! custom transports such as PTYs composable with other wrappers. The provider path runs: +//! +//! 1. `check_available` +//! 2. native-only base rejection +//! 3. `validate_command` +//! 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. transaction `commit` +//! +//! 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. +//! +//! 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 +//! `spawn_with_child` reject a registered provider instead of bypassing it. On Unix, when wrappers +//! request built-in child setup, a successful explicit spawner must create its returned child before +//! replacing the native command. Whenever it replaces that command, including before returning an +//! error or unwinding, the displaced command must be dropped before control leaves the spawner. A +//! replacement is discarded with a tracked attempt or retained by a native-only base. Process-wrap +//! cannot apply setup to a replacement which the closure creates and immediately spawns. //! //! ## An Example Logging Wrapper //! @@ -171,8 +239,10 @@ //! in. //! //! ```rust -//! # use process_wrap::std::{CommandWrap, CommandWrapper}; -//! # use std::{fs::File, io, path::PathBuf, process::Command, thread}; +//! # #[cfg(feature = "std")] +//! # mod example { +//! # use process_wrap::std::{CommandWrap, CommandWrapper, SpawnAttempt}; +//! # use std::{fs::File, io, path::PathBuf, thread}; //! #[derive(Debug)] //! struct LogFile { //! path: PathBuf, @@ -185,7 +255,7 @@ //! } //! //! impl CommandWrapper for LogFile { -//! fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> io::Result<()> { +//! fn pre_spawn(&mut self, command: &mut SpawnAttempt, _core: &CommandWrap) -> io::Result<()> { //! let mut logfile = File::create(&self.path)?; //! let (mut rx, tx) = io::pipe()?; //! @@ -193,10 +263,14 @@ //! io::copy(&mut rx, &mut logfile).unwrap(); //! }); //! -//! command.stdout(tx.try_clone()?).stderr(tx); +//! command +//! .stdout(tx.try_clone()?.into()) +//! .stderr(tx.into()); //! Ok(()) //! } //! } +//! # } +//! # fn main() {} //! ``` //! //! That's a great start, but it's actually introduced a resource leak: if the main thread of your @@ -206,12 +280,16 @@ //! when calling `.wait()` on the `ChildWrapper`. //! //! ```rust -//! # use process_wrap::std::{ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper}; +//! # #[cfg(feature = "std")] +//! # mod example { +//! # use process_wrap::std::{ +//! # ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper, SpawnAttempt, +//! # }; //! # use std::{ //! # fs::File, //! # io, mem, //! # path::PathBuf, -//! # process::{Command, ExitStatus}, +//! # process::ExitStatus, //! # thread::{self, JoinHandle}, //! # }; //! #[derive(Debug)] @@ -230,7 +308,7 @@ //! } //! //! impl CommandWrapper for LogFile { -//! fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> io::Result<()> { +//! fn pre_spawn(&mut self, command: &mut SpawnAttempt, _core: &CommandWrap) -> io::Result<()> { //! let mut logfile = File::create(&self.path)?; //! let (mut rx, tx) = io::pipe()?; //! @@ -238,7 +316,9 @@ //! io::copy(&mut rx, &mut logfile).unwrap(); //! })); //! -//! command.stdout(tx.try_clone()?).stderr(tx); +//! command +//! .stdout(tx.try_clone()?.into()) +//! .stderr(tx.into()); //! Ok(()) //! } //! @@ -291,8 +371,14 @@ //! exit_status //! } //! } +//! # } +//! # fn main() {} //! ``` //! +//! Calling `stdout` and `stderr` makes this attempt native-only, so this particular wrapper is for the +//! native or explicit spawning paths. A registered portable provider rejects the opaque attempt before +//! its validation or allocation callbacks. +//! //! The tracked process-wrap command does not retain the `tx` handles from this hook. Each spawn uses //! a fresh native attempt command, and that attempt is dropped before `spawn()` returns. The child has //! already inherited the descriptors it needs, so the background reader sees EOF once the child and @@ -302,13 +388,17 @@ //! Finally, we can test that our new command-wrapper works: //! //! ```rust -//! # use process_wrap::std::{ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper}; +//! # #[cfg(feature = "std")] +//! # mod example { +//! # use process_wrap::std::{ +//! # ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper, SpawnAttempt, +//! # }; //! # use std::{ //! # error::Error, //! # fs::{self, File}, //! # io, mem, //! # path::PathBuf, -//! # process::{Child, Command, ExitStatus}, +//! # process::ExitStatus, //! # thread::{self, JoinHandle}, //! # }; //! # use tempfile::NamedTempFile; @@ -328,7 +418,7 @@ //! # } //! # //! # impl CommandWrapper for LogFile { -//! # fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> io::Result<()> { +//! # fn pre_spawn(&mut self, command: &mut SpawnAttempt, _core: &CommandWrap) -> io::Result<()> { //! # let mut logfile = File::create(&self.path)?; //! # let (mut rx, tx) = io::pipe()?; //! # @@ -336,7 +426,9 @@ //! # io::copy(&mut rx, &mut logfile).unwrap(); //! # })); //! # -//! # command.stdout(tx.try_clone()?).stderr(tx); +//! # command +//! # .stdout(tx.try_clone()?.into()) +//! # .stderr(tx.into()); //! # Ok(()) //! # } //! # @@ -413,6 +505,8 @@ //! //! Ok(()) //! } +//! # } +//! # fn main() {} //! ``` //! //! # Features @@ -435,6 +529,10 @@ //! - `process-session`: **default**, enables the process session wrapper (Unix-only). //! - `reset-sigmask`: enables the sigmask reset wrapper (Unix-only). //! +//! ## Diagnostics +//! +//! - `tracing`: **default**, enables internal lifecycle diagnostics through the `tracing` crate. +//! #![doc(html_favicon_url = "https://watchexec.github.io/logo:command-group.svg")] #![doc(html_logo_url = "https://watchexec.github.io/logo:command-group.svg")] #![cfg_attr(docsrs, feature(doc_cfg))] @@ -442,15 +540,25 @@ mod command; pub(crate) mod generic_wrap; +#[cfg(all(unix, any(feature = "std", feature = "tokio1")))] +pub(crate) mod unix; +#[cfg(all(unix, any(feature = "std", feature = "tokio1")))] +#[cfg_attr(docsrs, doc(cfg(all(unix, any(feature = "std", feature = "tokio1")))))] +pub use unix::ProcessGroupTarget; -pub use command::Command; +#[cfg(windows)] +#[cfg_attr(docsrs, doc(cfg(windows)))] +pub use command::WindowsSpawnPolicy; #[doc(hidden)] pub use command::{Backend, Blocking, NativeCommand, Tokio1}; +pub use command::{Command, CommandArg, SpawnAttempt, SpawnTransaction}; #[cfg(feature = "std")] +#[cfg_attr(docsrs, doc(cfg(feature = "std")))] pub mod std; #[cfg(feature = "tokio1")] +#[cfg_attr(docsrs, doc(cfg(feature = "tokio1")))] pub mod tokio; #[cfg(all( @@ -462,7 +570,7 @@ mod windows; /// Internal memoization of the exit status of a child process. #[allow(dead_code)] // easier than listing exactly which featuresets use it -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub(crate) enum ChildExitStatus { Running, Exited(::std::process::ExitStatus), diff --git a/src/std.rs b/src/std.rs index f05b6aa..4dbfc1f 100644 --- a/src/std.rs +++ b/src/std.rs @@ -9,20 +9,40 @@ //! ``` #[doc(inline)] -pub use core::{ChildWrapper, Command, CommandWrap, CommandWrapper}; +pub use crate::CommandArg; +#[cfg(unix)] +#[cfg_attr(docsrs, doc(cfg(unix)))] +#[doc(inline)] +pub use crate::ProcessGroupTarget; +#[doc(inline)] +pub use crate::SpawnTransaction; +#[cfg(windows)] +#[cfg_attr(docsrs, doc(cfg(windows)))] +#[doc(inline)] +pub use crate::WindowsSpawnPolicy; +#[doc(inline)] +pub use core::{ + ChildWrapper, Command, CommandWrap, CommandWrapper, ProviderProduct, SpawnAttempt, + SpawnProvider, +}; #[cfg(all(windows, feature = "creation-flags"))] +#[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] #[doc(inline)] pub use creation_flags::CreationFlags; #[cfg(all(windows, feature = "job-object"))] +#[cfg_attr(docsrs, doc(cfg(all(windows, feature = "job-object"))))] #[doc(inline)] pub use job_object::{JobObject, JobObjectChild}; #[cfg(all(unix, feature = "process-group"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "process-group"))))] #[doc(inline)] pub use process_group::{ProcessGroup, ProcessGroupChild}; #[cfg(all(unix, feature = "process-session"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "process-session"))))] #[doc(inline)] pub use process_session::ProcessSession; #[cfg(all(unix, feature = "reset-sigmask"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "reset-sigmask"))))] #[doc(inline)] pub use reset_sigmask::ResetSigmask; diff --git a/src/std/core.rs b/src/std/core.rs index e01ceeb..8ec0be4 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -94,12 +94,55 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// default implementation. /// /// Implementations returning `Some` must return a process handle, rather than another kind of - /// Windows object. + /// Windows object. A provider child used with `JobObject` must expose this capability; otherwise + /// process-wrap returns `Unsupported` and makes a best-effort attempt to terminate the child. #[cfg(windows)] fn process_handle(&self) -> Option> { None } + /// Resume the exact thread which process-wrap temporarily suspended for job-object assignment. + /// + /// This method is only available on Windows. A provider which creates the process temporarily + /// suspended according to `WindowsSpawnPolicy` should retain its primary-thread handle and return + /// `Some(result)` after attempting one exact resume. `Some(Err(_))` is authoritative and fails the + /// spawn lifecycle; process-wrap does not then try another resume mechanism. Return `None` only when + /// no exact capability exists, which lets `JobObject` use its process-wide thread-enumeration + /// compatibility fallback. + #[cfg(windows)] + fn resume_after_job_assignment(&mut self) -> Option> { + None + } + + /// Finalize Windows spawn state owned by this child layer. + /// + /// Process-wrap invokes this internal lifecycle hook after all child wrappers have been installed. + /// Implementations act only on their own layer; process-wrap traverses the complete chain. + #[doc(hidden)] + #[cfg(windows)] + fn finalize_spawn_layer(&mut self) -> Result<()> { + Ok(()) + } + + /// 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. + #[doc(hidden)] + #[cfg(windows)] + fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { + Ok(()) + } + + /// Disarm the JobObject cleanup state owned by this child layer. + /// + /// This process-wrap-internal phase runs after every other fallible finalizer and cleanup disarm. + #[doc(hidden)] + #[cfg(windows)] + fn disarm_job_object_layer(&mut self) -> Result<()> { + Ok(()) + } + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -298,6 +341,79 @@ impl dyn ChildWrapper + '_ { self.downcast_ref::().is_some() } + /// Find the first Windows process-handle capability in this wrapper chain. + /// + /// Unlike [`ChildWrapper::process_handle`], this traverses legacy transparent layers which do not + /// explicitly delegate the capability. It returns `None` at a self-terminal custom child. + #[cfg(windows)] + pub fn try_process_handle(&self) -> Option> { + let mut inner = self; + loop { + if let Some(handle) = inner.process_handle() { + return Some(handle); + } + + let next = inner.inner(); + if same_child(inner, next) { + return None; + } + inner = next; + } + } + + /// Try the first exact post-assignment resume capability in this wrapper chain. + /// + /// Returns `None` only when no layer owns an exact primary-thread resume operation, allowing callers + /// to use a thread-enumeration compatibility fallback. A returned `Some(Err(_))` is authoritative + /// and must fail the lifecycle rather than fall back. + #[cfg(windows)] + pub fn try_resume_after_job_assignment(&mut self) -> Option> { + let mut inner = self; + loop { + if let Some(result) = inner.resume_after_job_assignment() { + return Some(result); + } + + 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; + } + } + + #[cfg(windows)] + fn visit_spawn_layers( + &mut self, + mut visit: impl FnMut(&mut dyn ChildWrapper) -> Result<()>, + ) -> Result<()> { + let mut inner = self; + loop { + visit(inner)?; + + 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()) + } + /// 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/creation_flags.rs b/src/std/creation_flags.rs index 5ee9a81..de31f80 100644 --- a/src/std/creation_flags.rs +++ b/src/std/creation_flags.rs @@ -1,21 +1,15 @@ -use std::{io::Result, os::windows::process::CommandExt, process::Command}; +use std::io::Result; use windows::Win32::System::Threading::PROCESS_CREATION_FLAGS; -use super::{CommandWrap, CommandWrapper}; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; -#[cfg(feature = "job-object")] -use super::JobObject; -#[cfg(feature = "job-object")] -use crate::windows::job_creation_flags; - -/// Shim wrapper which sets Windows process creation flags. -/// -/// This wrapper is only available on Windows. +/// Portable wrapper for Windows process creation flags. /// -/// It exists to be able to set creation flags on a `Command` and also store them in the wrapper, so -/// that they're no overwritten by other wrappers. Notably this is the only way to use creation -/// flags and the `JobObject` wrapper together. +/// This wrapper is only available on Windows. Calling the native-shaped `Command::creation_flags` +/// method makes the command native-only because those flags cannot be queried afterward. This wrapper +/// instead records them on each `SpawnAttempt`, allowing `JobObject` and alternate spawn providers to +/// preserve and inspect the policy. /// /// When both `CreationFlags` and `JobObject` are used, process-wrap preserves these flags while /// temporarily adding `CREATE_SUSPENDED`; registration order does not matter. @@ -23,20 +17,8 @@ use crate::windows::job_creation_flags; pub struct CreationFlags(pub PROCESS_CREATION_FLAGS); impl CommandWrapper for CreationFlags { - fn pre_spawn(&mut self, command: &mut Command, core: &CommandWrap) -> Result<()> { - #[cfg(feature = "job-object")] - let flags = if core.has_wrap::() { - job_creation_flags(self.0).flags - } else { - self.0 - }; - #[cfg(not(feature = "job-object"))] - let flags = { - let _ = core; - self.0 - }; - - command.creation_flags(flags.0); + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_windows_creation_flags(self.0.0); Ok(()) } } diff --git a/src/std/job_object.rs b/src/std/job_object.rs index ad10743..f101bf7 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -1,10 +1,8 @@ use std::{ + any::Any, io::{Error, ErrorKind, Result}, - os::windows::{ - io::{AsRawHandle, BorrowedHandle}, - process::CommandExt, - }, - process::{Command, ExitStatus}, + os::windows::io::{AsRawHandle, BorrowedHandle}, + process::ExitStatus, time::Duration, }; @@ -18,19 +16,20 @@ use windows::Win32::{ use crate::{ ChildExitStatus, windows::{ - JobPort, job_creation_flags, make_job_object, resume_threads, terminate_job, wait_on_job, + JobPort, job_creation_flags, make_job_object, resume_threads, set_job_kill_on_drop, + terminate_job, wait_on_job, }, }; #[cfg(feature = "creation-flags")] use super::CreationFlags; -use super::{ChildWrapper, CommandWrap, CommandWrapper}; +use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which creates a job object context for a `Command`. /// /// This wrapper is only available on Windows. /// -/// It creates a Windows Job Object and associates the [`Command`] to it. This behaves analogously +/// It creates a Windows Job Object and associates the [`Command`](super::Command) to it. This behaves analogously /// to process groups on Unix or even cgroups on Linux, with the ability to restrict resource use. /// See [Job Objects](https://docs.microsoft.com/en-us/windows/win32/procthread/job-objects). /// @@ -60,28 +59,17 @@ fn terminate_child(child: &mut dyn ChildWrapper) { } } -fn child_process_handle(child: &dyn ChildWrapper) -> Option> { - child.process_handle().or_else(|| { - child - .try_inner_child() - .and_then(|child| child.process_handle()) - }) +#[derive(Debug)] +struct PreparedJobObject { + job_port: JobPort, } -impl CommandWrapper for JobObject { - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn pre_spawn(&mut self, command: &mut Command, core: &CommandWrap) -> Result<()> { - let policy = job_creation_flags(user_creation_flags(core)); - command.creation_flags(policy.flags.0); - Ok(()) - } - - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn wrap_child( - &mut self, - mut inner: Box, +impl JobObject { + fn prepare_job( + &self, + child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> Result> { + ) -> Result { let policy = job_creation_flags(user_creation_flags(core)); #[cfg(feature = "tracing")] @@ -92,10 +80,10 @@ impl CommandWrapper for JobObject { // Prefer the explicit capability, while preserving composition with transparent wrappers // written before `process_handle` was added. - let handle = match child_process_handle(inner.as_ref()) { + let handle = match child.try_process_handle() { Some(handle) => HANDLE(handle.as_raw_handle()), None => { - terminate_child(&mut *inner); + terminate_child(child); return Err(Error::new( ErrorKind::Unsupported, "child wrapper does not expose a Windows process handle", @@ -103,23 +91,74 @@ impl CommandWrapper for JobObject { } }; - let job_port = match make_job_object(handle, false) { + let job_port = match make_job_object(handle, true) { Ok(job_port) => job_port, Err(error) => { - terminate_child(&mut *inner); + terminate_child(child); return Err(error); } }; if policy.resume_after_assignment { - if let Err(error) = resume_threads(handle) { + let resumed = child + .try_resume_after_job_assignment() + .unwrap_or_else(|| resume_threads(handle)); + if let Err(error) = resumed { let _ = terminate_job(job_port.job, 1); - terminate_child(&mut *inner); + terminate_child(child); return Err(error); } } - Ok(Box::new(JobObjectChild::new(inner, job_port))) + Ok(PreparedJobObject { job_port }) + } +} + +impl CommandWrapper for JobObject { + #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_job_object(); + Ok(()) + } + + #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self, child)))] + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + core: &CommandWrap, + ) -> Result>> { + Ok(Some(Box::new(self.prepare_job(child, core)?))) + } + + fn wrap_prepared_child( + &mut self, + inner: Box, + prepared: Option>, + _core: &CommandWrap, + ) -> 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, + 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)) } } @@ -129,15 +168,23 @@ pub struct JobObjectChild { inner: Box, exit_status: ChildExitStatus, 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) -> Self { + pub(crate) fn new( + inner: Box, + job_port: JobPort, + final_kill_on_drop: bool, + ) -> Self { Self { inner, exit_status: ChildExitStatus::Running, job_port, + final_kill_on_drop, + spawn_finalized: false, } } } @@ -150,16 +197,32 @@ impl ChildWrapper for JobObjectChild { self.inner.as_mut() } fn into_inner(self: Box) -> Box { - // manually drop the completion port - let its = std::mem::ManuallyDrop::new(self.job_port); - unsafe { CloseHandle(its.completion_port.0) }.ok(); - // we leave the job handle unclosed, otherwise the Child is useless - // (as closing it will terminate the job) + 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); + unsafe { CloseHandle(its.completion_port.0) }.ok(); + // we leave the job handle unclosed, otherwise the Child is useless + // (as closing it may terminate the job) + } + // Before spawn finalization, dropping the still-armed job instead guarantees that removing this + // layer cannot let descendants escape a later lifecycle failure. - self.inner + inner } fn process_handle(&self) -> Option> { - child_process_handle(self.inner.as_ref()) + self.inner.try_process_handle() + } + 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; + Ok(()) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] diff --git a/src/std/process_group.rs b/src/std/process_group.rs index f8d9d3c..2722df1 100644 --- a/src/std/process_group.rs +++ b/src/std/process_group.rs @@ -1,8 +1,8 @@ use std::{ io::{Error, Result}, ops::ControlFlow, - os::unix::process::{CommandExt, ExitStatusExt}, - process::{Command, ExitStatus}, + os::unix::process::ExitStatusExt, + process::ExitStatus, }; use nix::{ @@ -17,15 +17,15 @@ use nix::{ #[cfg(feature = "tracing")] use tracing::instrument; -use crate::ChildExitStatus; +use crate::{ChildExitStatus, unix::ProcessGroupTarget}; -use super::{ChildWrapper, CommandWrap, CommandWrapper}; +use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; -/// Wrapper which sets the process group of a `Command`. +/// Wrapper which sets the process group of a [`Command`](super::Command). /// /// This wrapper is only available on Unix. /// -/// It sets the process group of a [`Command`], either to itself as the leader of a new group, or to +/// It sets the process group of a [`Command`](super::Command), either to itself as the leader of a new group, or to /// an existing one by its PGID. See [setpgid(2)](https://pubs.opengroup.org/onlinepubs/9699919799/functions/setpgid.html). /// /// Process groups direct signals to all members of the group, and also serve to control job @@ -34,21 +34,21 @@ use super::{ChildWrapper, CommandWrap, CommandWrapper}; /// This wrapper provides a child wrapper: [`ProcessGroupChild`]. #[derive(Clone, Copy, Debug)] pub struct ProcessGroup { - leader: Pid, + target: ProcessGroupTarget, } impl ProcessGroup { /// Create a process group wrapper setting up a new process group with the command as the leader. pub fn leader() -> Self { Self { - leader: Pid::from_raw(0), + target: ProcessGroupTarget::Leader, } } /// Create a process group wrapper attaching the command to an existing process group ID. pub fn attach_to(leader: u32) -> Self { Self { - leader: Pid::from_raw(leader as _), + target: ProcessGroupTarget::AttachTo(leader), } } } @@ -58,16 +58,20 @@ impl ProcessGroup { pub struct ProcessGroupChild { inner: Box, exit_status: ChildExitStatus, + direct_pid: Pid, pgid: Pid, + group_drained: bool, } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - pub(crate) fn new(inner: Box, pgid: Pid) -> Self { + pub(crate) fn new(inner: Box, direct_pid: Pid, pgid: Pid) -> Self { Self { inner, exit_status: ChildExitStatus::Running, + direct_pid, pgid, + group_drained: false, } } @@ -81,9 +85,8 @@ impl ProcessGroupChild { impl CommandWrapper for ProcessGroup { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - command.process_group(self.leader.as_raw()); - Ok(()) + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_process_group(self.target) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -92,9 +95,15 @@ impl CommandWrapper for ProcessGroup { inner: Box, _core: &CommandWrap, ) -> Result> { - let pgid = Pid::from_raw(i32::try_from(inner.id()).expect("Command PID > i32::MAX")); - - Ok(Box::new(ProcessGroupChild::new(inner, pgid))) + let direct_pid = Pid::from_raw(i32::try_from(inner.id()).expect("Command PID > i32::MAX")); + 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"), + ), + }; + + Ok(Box::new(ProcessGroupChild::new(inner, direct_pid, pgid))) } } @@ -105,7 +114,11 @@ impl ProcessGroupChild { } #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - fn wait_imp(pgid: Pid, flag: WaitPidFlag) -> Result>> { + fn wait_imp( + direct_pid: Pid, + pgid: Pid, + flag: WaitPidFlag, + ) -> Result, Option>> { // wait for processes in a loop until every process in this group has // exited (this ensures that we reap any zombies that may have been // created if the parent exited after spawning children, but didn't wait @@ -121,7 +134,7 @@ impl ProcessGroupChild { 0 => { // zero should only happen if WNOHANG was passed in, // and means that no processes have yet to exit - return Ok(ControlFlow::Continue(())); + return Ok(ControlFlow::Continue(parent_exit_status)); } -1 => { match Errno::last() { @@ -138,7 +151,7 @@ impl ProcessGroupChild { // a process exited. was it the parent process that we // started? if so, collect the exit signal, otherwise we // reaped a zombie process and should continue looping - if pgid == Pid::from_raw(pid) { + if direct_pid == Pid::from_raw(pid) { parent_exit_status = Some(ExitStatus::from_raw(status)); } else { // reaped a zombie child; keep looping @@ -167,41 +180,69 @@ impl ChildWrapper for ProcessGroupChild { #[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); + let status = match self.exit_status { + ChildExitStatus::Running => { + let status = self.inner.wait()?; + self.exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; + + if !self.group_drained { + if let ControlFlow::Break(reaped) = + Self::wait_imp(self.direct_pid, self.pgid, WaitPidFlag::empty())? + { + if let Some(reaped) = reaped { + self.exit_status = ChildExitStatus::Exited(reaped); + } + self.group_drained = true; + } } - // always wait for parent to exit first, as by the time it does, - // it's likely that all its children have already been reaped. - let status = self.inner.wait()?; - self.exit_status = ChildExitStatus::Exited(status); - - // nevertheless, now wait and make sure we reap all children. - let _ = Self::wait_imp(self.pgid, WaitPidFlag::empty())?; - Ok(status) + match self.exit_status { + ChildExitStatus::Exited(status) => Ok(status), + ChildExitStatus::Running => Ok(status), + } } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - if let ChildExitStatus::Exited(status) = &self.exit_status { - return Ok(Some(*status)); + if self.group_drained { + return match self.exit_status { + ChildExitStatus::Exited(status) => Ok(Some(status)), + ChildExitStatus::Running => { + let status = self.inner.try_wait()?; + if let Some(status) = status { + self.exit_status = ChildExitStatus::Exited(status); + } + Ok(status) + } + }; } - match Self::wait_imp(self.pgid, WaitPidFlag::WNOHANG)? { - ControlFlow::Break(res) => { - if let Some(status) = res { - self.exit_status = ChildExitStatus::Exited(status); - } - Ok(res) - } - ControlFlow::Continue(()) => { - let exited = self.inner.try_wait()?; - if let Some(exited) = exited { - self.exit_status = ChildExitStatus::Exited(exited); - } - Ok(exited) + let (drained, reaped) = + match Self::wait_imp(self.direct_pid, self.pgid, WaitPidFlag::WNOHANG)? { + ControlFlow::Break(status) => (true, status), + ControlFlow::Continue(status) => (false, status), + }; + if let Some(status) = reaped { + self.exit_status = ChildExitStatus::Exited(status); + } + if matches!(self.exit_status, ChildExitStatus::Running) { + if let Some(status) = self.inner.try_wait()? { + self.exit_status = ChildExitStatus::Exited(status); } } + self.group_drained = drained; + + if !self.group_drained { + return Ok(None); + } + match self.exit_status { + ChildExitStatus::Exited(status) => Ok(Some(status)), + ChildExitStatus::Running => Ok(None), + } } fn signal(&self, sig: i32) -> Result<()> { diff --git a/src/std/process_session.rs b/src/std/process_session.rs index 4d42a4d..a005e48 100644 --- a/src/std/process_session.rs +++ b/src/std/process_session.rs @@ -1,20 +1,16 @@ -use std::{ - io::{Error, Result}, - os::unix::process::CommandExt, - process::Command, -}; +use std::io::Result; -use nix::unistd::{Pid, setsid}; +use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; -use super::{CommandWrap, CommandWrapper}; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which creates a new session and group for the `Command`. /// /// This wrapper is only available on Unix. /// -/// It creates a new session and new process group and sets the [`Command`] as its leader. +/// It creates a new session and new process group and sets the [`Command`](super::Command) as its leader. /// See [setsid(2)](https://pubs.opengroup.org/onlinepubs/9699919799/functions/setsid.html). /// /// You may find that some programs behave differently or better when running in a session rather @@ -27,12 +23,8 @@ pub struct ProcessSession; impl CommandWrapper for ProcessSession { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - unsafe { - command.pre_exec(move || setsid().map_err(Error::from).map(|_| ())); - } - - Ok(()) + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_process_session() } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -41,8 +33,10 @@ impl CommandWrapper for ProcessSession { inner: Box, _core: &CommandWrap, ) -> Result> { - let pgid = Pid::from_raw(i32::try_from(inner.id()).expect("Command PID > i32::MAX")); + let direct_pid = Pid::from_raw(i32::try_from(inner.id()).expect("Command PID > i32::MAX")); - Ok(Box::new(super::ProcessGroupChild::new(inner, pgid))) + Ok(Box::new(super::ProcessGroupChild::new( + inner, direct_pid, direct_pid, + ))) } } diff --git a/src/std/reset_sigmask.rs b/src/std/reset_sigmask.rs index a5a2d9c..d4c51f5 100644 --- a/src/std/reset_sigmask.rs +++ b/src/std/reset_sigmask.rs @@ -1,10 +1,9 @@ -use std::{io::Result, os::unix::process::CommandExt, process::Command}; +use std::io::Result; -use nix::sys::signal::{SigSet, SigmaskHow, sigprocmask}; #[cfg(feature = "tracing")] use tracing::trace; -use super::{CommandWrap, CommandWrapper}; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which resets the process signal mask. /// @@ -14,22 +13,10 @@ use super::{CommandWrap, CommandWrapper}; pub struct ResetSigmask; impl CommandWrapper for ResetSigmask { - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - unsafe { - command.pre_exec(|| { - let mut oldset = SigSet::empty(); - let newset = SigSet::all(); - - #[cfg(feature = "tracing")] - trace!(unblocking=?newset, "resetting process sigmask"); - - sigprocmask(SigmaskHow::SIG_UNBLOCK, Some(&newset), Some(&mut oldset))?; - - #[cfg(feature = "tracing")] - trace!(?oldset, "sigmask reset"); - Ok(()) - }); - } + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + #[cfg(feature = "tracing")] + trace!("configuring process sigmask reset"); + attempt.set_reset_sigmask(); Ok(()) } } diff --git a/src/tokio.rs b/src/tokio.rs index 36bd61d..ade45c2 100644 --- a/src/tokio.rs +++ b/src/tokio.rs @@ -9,23 +9,44 @@ //! ``` #[doc(inline)] -pub use core::{ChildWrapper, Command, CommandWrap, CommandWrapper}; +pub use crate::CommandArg; +#[cfg(unix)] +#[cfg_attr(docsrs, doc(cfg(unix)))] +#[doc(inline)] +pub use crate::ProcessGroupTarget; +#[doc(inline)] +pub use crate::SpawnTransaction; +#[cfg(windows)] +#[cfg_attr(docsrs, doc(cfg(windows)))] +#[doc(inline)] +pub use crate::WindowsSpawnPolicy; +#[doc(inline)] +pub use core::{ + ChildWrapper, Command, CommandWrap, CommandWrapper, ProviderProduct, SpawnAttempt, + SpawnProvider, +}; #[cfg(all(windows, feature = "creation-flags"))] +#[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] #[doc(inline)] pub use creation_flags::CreationFlags; #[cfg(all(windows, feature = "job-object"))] +#[cfg_attr(docsrs, doc(cfg(all(windows, feature = "job-object"))))] #[doc(inline)] pub use job_object::{JobObject, JobObjectChild}; #[cfg(feature = "kill-on-drop")] +#[cfg_attr(docsrs, doc(cfg(feature = "kill-on-drop")))] #[doc(inline)] pub use kill_on_drop::KillOnDrop; #[cfg(all(unix, feature = "process-group"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "process-group"))))] #[doc(inline)] pub use process_group::{ProcessGroup, ProcessGroupChild}; #[cfg(all(unix, feature = "process-session"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "process-session"))))] #[doc(inline)] pub use process_session::ProcessSession; #[cfg(all(unix, feature = "reset-sigmask"))] +#[cfg_attr(docsrs, doc(cfg(all(unix, feature = "reset-sigmask"))))] #[doc(inline)] pub use reset_sigmask::ResetSigmask; diff --git a/src/tokio/core.rs b/src/tokio/core.rs index c7e40c1..4f9f165 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -95,12 +95,55 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// default implementation. /// /// Implementations returning `Some` must return a process handle, rather than another kind of - /// Windows object. + /// Windows object. A provider child used with `JobObject` must expose this capability; otherwise + /// process-wrap returns `Unsupported` and makes a best-effort attempt to terminate the child. #[cfg(windows)] fn process_handle(&self) -> Option> { None } + /// Resume the exact thread which process-wrap temporarily suspended for job-object assignment. + /// + /// This method is only available on Windows. A provider which creates the process temporarily + /// suspended according to `WindowsSpawnPolicy` should retain its primary-thread handle and return + /// `Some(result)` after attempting one exact resume. `Some(Err(_))` is authoritative and fails the + /// spawn lifecycle; process-wrap does not then try another resume mechanism. Return `None` only when + /// no exact capability exists, which lets `JobObject` use its process-wide thread-enumeration + /// compatibility fallback. + #[cfg(windows)] + fn resume_after_job_assignment(&mut self) -> Option> { + None + } + + /// Finalize Windows spawn state owned by this child layer. + /// + /// Process-wrap invokes this internal lifecycle hook after all child wrappers have been installed. + /// Implementations act only on their own layer; process-wrap traverses the complete chain. + #[doc(hidden)] + #[cfg(windows)] + fn finalize_spawn_layer(&mut self) -> Result<()> { + Ok(()) + } + + /// 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. + #[doc(hidden)] + #[cfg(windows)] + fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { + Ok(()) + } + + /// Disarm the JobObject cleanup state owned by this child layer. + /// + /// This process-wrap-internal phase runs after every other fallible finalizer and cleanup disarm. + #[doc(hidden)] + #[cfg(windows)] + fn disarm_job_object_layer(&mut self) -> Result<()> { + Ok(()) + } + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -297,6 +340,79 @@ impl dyn ChildWrapper + '_ { self.downcast_ref::().is_some() } + /// Find the first Windows process-handle capability in this wrapper chain. + /// + /// Unlike [`ChildWrapper::process_handle`], this traverses legacy transparent layers which do not + /// explicitly delegate the capability. It returns `None` at a self-terminal custom child. + #[cfg(windows)] + pub fn try_process_handle(&self) -> Option> { + let mut inner = self; + loop { + if let Some(handle) = inner.process_handle() { + return Some(handle); + } + + let next = inner.inner(); + if same_child(inner, next) { + return None; + } + inner = next; + } + } + + /// Try the first exact post-assignment resume capability in this wrapper chain. + /// + /// Returns `None` only when no layer owns an exact primary-thread resume operation, allowing callers + /// to use a thread-enumeration compatibility fallback. A returned `Some(Err(_))` is authoritative + /// and must fail the lifecycle rather than fall back. + #[cfg(windows)] + pub fn try_resume_after_job_assignment(&mut self) -> Option> { + let mut inner = self; + loop { + if let Some(result) = inner.resume_after_job_assignment() { + return Some(result); + } + + 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; + } + } + + #[cfg(windows)] + fn visit_spawn_layers( + &mut self, + mut visit: impl FnMut(&mut dyn ChildWrapper) -> Result<()>, + ) -> Result<()> { + let mut inner = self; + loop { + visit(inner)?; + + 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()) + } + /// 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/creation_flags.rs b/src/tokio/creation_flags.rs index 13295f3..de31f80 100644 --- a/src/tokio/creation_flags.rs +++ b/src/tokio/creation_flags.rs @@ -1,22 +1,15 @@ use std::io::Result; -use tokio::process::Command; use windows::Win32::System::Threading::PROCESS_CREATION_FLAGS; -use super::{CommandWrap, CommandWrapper}; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; -#[cfg(feature = "job-object")] -use super::JobObject; -#[cfg(feature = "job-object")] -use crate::windows::job_creation_flags; - -/// Shim wrapper which sets Windows process creation flags. -/// -/// This wrapper is only available on Windows. +/// Portable wrapper for Windows process creation flags. /// -/// It exists to be able to set creation flags on a `Command` and also store them in the wrapper, so -/// that they're no overwritten by other wrappers. Notably this is the only way to use creation -/// flags and the `JobObject` wrapper together. +/// This wrapper is only available on Windows. Calling the native-shaped `Command::creation_flags` +/// method makes the command native-only because those flags cannot be queried afterward. This wrapper +/// instead records them on each `SpawnAttempt`, allowing `JobObject` and alternate spawn providers to +/// preserve and inspect the policy. /// /// When both `CreationFlags` and `JobObject` are used, process-wrap preserves these flags while /// temporarily adding `CREATE_SUSPENDED`; registration order does not matter. @@ -24,20 +17,8 @@ use crate::windows::job_creation_flags; pub struct CreationFlags(pub PROCESS_CREATION_FLAGS); impl CommandWrapper for CreationFlags { - fn pre_spawn(&mut self, command: &mut Command, core: &CommandWrap) -> Result<()> { - #[cfg(feature = "job-object")] - let flags = if core.has_wrap::() { - job_creation_flags(self.0).flags - } else { - self.0 - }; - #[cfg(not(feature = "job-object"))] - let flags = { - let _ = core; - self.0 - }; - - command.creation_flags(flags.0); + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_windows_creation_flags(self.0.0); Ok(()) } } diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 4433ef9..a8e8d87 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -1,4 +1,5 @@ use std::{ + any::Any, future::Future, io::{Error, ErrorKind, Result}, os::windows::io::{AsRawHandle, BorrowedHandle}, @@ -7,7 +8,7 @@ use std::{ time::Duration, }; -use tokio::{process::Command, task::spawn_blocking}; +use tokio::task::spawn_blocking; #[cfg(feature = "tracing")] use tracing::{debug, instrument}; use windows::Win32::{ @@ -18,7 +19,8 @@ use windows::Win32::{ use crate::{ ChildExitStatus, windows::{ - JobPort, job_creation_flags, make_job_object, resume_threads, terminate_job, wait_on_job, + JobPort, job_creation_flags, make_job_object, resume_threads, set_job_kill_on_drop, + terminate_job, wait_on_job, }, }; @@ -26,13 +28,13 @@ use crate::{ use super::CreationFlags; #[cfg(feature = "kill-on-drop")] use super::KillOnDrop; -use super::{ChildWrapper, CommandWrap, CommandWrapper}; +use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which creates a job object context for a `Command`. /// /// This wrapper is only available on Windows. /// -/// It creates a Windows Job Object and associates the [`Command`] to it. This behaves analogously +/// It creates a Windows Job Object and associates the [`Command`](super::Command) to it. This behaves analogously /// to process groups on Unix or even cgroups on Linux, with the ability to restrict resource use. /// See [Job Objects](https://docs.microsoft.com/en-us/windows/win32/procthread/job-objects). /// @@ -60,28 +62,18 @@ fn terminate_child(child: &mut dyn ChildWrapper) { let _ = child.start_kill(); } -fn child_process_handle(child: &dyn ChildWrapper) -> Option> { - child.process_handle().or_else(|| { - child - .try_inner_child() - .and_then(|child| child.process_handle()) - }) +#[derive(Debug)] +struct PreparedJobObject { + job_port: JobPort, + final_kill_on_drop: bool, } -impl CommandWrapper for JobObject { - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn pre_spawn(&mut self, command: &mut Command, core: &CommandWrap) -> Result<()> { - let policy = job_creation_flags(user_creation_flags(core)); - command.creation_flags(policy.flags.0); - Ok(()) - } - - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn wrap_child( - &mut self, - mut inner: Box, +impl JobObject { + fn prepare_job( + &self, + child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> Result> { + ) -> Result { #[cfg(feature = "kill-on-drop")] let kill_on_drop = core.has_wrap::(); #[cfg(not(feature = "kill-on-drop"))] @@ -98,10 +90,10 @@ impl CommandWrapper for JobObject { // Prefer the explicit capability, while preserving composition with transparent wrappers // written before `process_handle` was added. - let handle = match child_process_handle(inner.as_ref()) { + let handle = match child.try_process_handle() { Some(handle) => HANDLE(handle.as_raw_handle()), None => { - terminate_child(&mut *inner); + terminate_child(child); return Err(Error::new( ErrorKind::Unsupported, "child wrapper does not expose a Windows process handle", @@ -109,23 +101,77 @@ impl CommandWrapper for JobObject { } }; - let job_port = match make_job_object(handle, kill_on_drop) { + let job_port = match make_job_object(handle, true) { Ok(job_port) => job_port, Err(error) => { - terminate_child(&mut *inner); + terminate_child(child); return Err(error); } }; if policy.resume_after_assignment { - if let Err(error) = resume_threads(handle) { + let resumed = child + .try_resume_after_job_assignment() + .unwrap_or_else(|| resume_threads(handle)); + if let Err(error) = resumed { let _ = terminate_job(job_port.job, 1); - terminate_child(&mut *inner); + terminate_child(child); return Err(error); } } - Ok(Box::new(JobObjectChild::new(inner, job_port))) + Ok(PreparedJobObject { + job_port, + final_kill_on_drop: kill_on_drop, + }) + } +} + +impl CommandWrapper for JobObject { + #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_job_object(); + Ok(()) + } + + #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self, child)))] + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + core: &CommandWrap, + ) -> Result>> { + Ok(Some(Box::new(self.prepare_job(child, core)?))) + } + + fn wrap_prepared_child( + &mut self, + inner: Box, + prepared: Option>, + _core: &CommandWrap, + ) -> 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)) } } @@ -135,15 +181,23 @@ pub struct JobObjectChild { inner: Box, exit_status: ChildExitStatus, 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) -> Self { + pub(crate) fn new( + inner: Box, + job_port: JobPort, + final_kill_on_drop: bool, + ) -> Self { Self { inner, exit_status: ChildExitStatus::Running, job_port, + final_kill_on_drop, + spawn_finalized: false, } } } @@ -156,16 +210,32 @@ impl ChildWrapper for JobObjectChild { self.inner.as_mut() } fn into_inner(self: Box) -> Box { - // manually drop the completion port - let its = std::mem::ManuallyDrop::new(self.job_port); - unsafe { CloseHandle(its.completion_port.0) }.ok(); - // we leave the job handle unclosed, otherwise the Child is useless - // (as closing it will terminate the job) + 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); + unsafe { CloseHandle(its.completion_port.0) }.ok(); + // we leave the job handle unclosed, otherwise the Child is useless + // (as closing it may terminate the job) + } + // Before spawn finalization, dropping the still-armed job instead guarantees that removing this + // layer cannot let descendants escape a later lifecycle failure. - self.inner + inner } fn process_handle(&self) -> Option> { - child_process_handle(self.inner.as_ref()) + self.inner.try_process_handle() + } + 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; + Ok(()) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] diff --git a/src/tokio/kill_on_drop.rs b/src/tokio/kill_on_drop.rs index 0eed5cf..7d0ae3d 100644 --- a/src/tokio/kill_on_drop.rs +++ b/src/tokio/kill_on_drop.rs @@ -1,20 +1,18 @@ use std::io::Result; -use tokio::process::Command; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; -use super::{CommandWrap, CommandWrapper}; - -/// Shim wrapper which sets kill-on-drop on a `Command`. +/// Portable kill-on-drop policy wrapper for a [`Command`](super::Command). /// -/// This wrapper exists to be able to set the kill-on-drop flag on a `Command` and also store that -/// fact in the wrapper, so that it can be used by other wrappers. Notably this is used by the -/// `JobObject` wrapper. +/// Calling the native-shaped `Command::kill_on_drop` method makes the command native-only because the +/// setting cannot be queried afterward. This wrapper instead records the policy on each `SpawnAttempt`, +/// allowing `JobObject` and alternate spawn providers to preserve it. #[derive(Clone, Copy, Debug)] pub struct KillOnDrop; impl CommandWrapper for KillOnDrop { - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - command.kill_on_drop(true); + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_kill_on_drop(true); Ok(()) } } diff --git a/src/tokio/process_group.rs b/src/tokio/process_group.rs index 6b380a5..f6bd5cf 100644 --- a/src/tokio/process_group.rs +++ b/src/tokio/process_group.rs @@ -16,19 +16,19 @@ use nix::{ }, unistd::Pid, }; -use tokio::{process::Command, task::spawn_blocking}; +use tokio::task::spawn_blocking; #[cfg(feature = "tracing")] use tracing::instrument; -use crate::ChildExitStatus; +use crate::{ChildExitStatus, unix::ProcessGroupTarget}; -use super::{ChildWrapper, CommandWrap, CommandWrapper}; +use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which sets the process group of a `Command`. /// /// This wrapper is only available on Unix. /// -/// It sets the process group of a [`Command`], either to itself as the leader of a new group, or to +/// It sets the process group of a [`Command`](super::Command), either to itself as the leader of a new group, or to /// an existing one by its PGID. See [setpgid(2)](https://pubs.opengroup.org/onlinepubs/9699919799/functions/setpgid.html). /// /// Process groups direct signals to all members of the group, and also serve to control job @@ -37,21 +37,21 @@ use super::{ChildWrapper, CommandWrap, CommandWrapper}; /// This wrapper provides a child wrapper: [`ProcessGroupChild`]. #[derive(Clone, Copy, Debug)] pub struct ProcessGroup { - leader: Pid, + target: ProcessGroupTarget, } impl ProcessGroup { /// Create a process group wrapper setting up a new process group with the command as the leader. pub fn leader() -> Self { Self { - leader: Pid::from_raw(0), + target: ProcessGroupTarget::Leader, } } /// Create a process group wrapper attaching the command to an existing process group ID. pub fn attach_to(leader: u32) -> Self { Self { - leader: Pid::from_raw(leader as i32), + target: ProcessGroupTarget::AttachTo(leader), } } } @@ -61,16 +61,20 @@ impl ProcessGroup { pub struct ProcessGroupChild { inner: Box, exit_status: ChildExitStatus, + direct_pid: Pid, pgid: Pid, + group_drained: bool, } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - pub(crate) fn new(inner: Box, pgid: Pid) -> Self { + pub(crate) fn new(inner: Box, direct_pid: Pid, pgid: Pid) -> Self { Self { inner, exit_status: ChildExitStatus::Running, + direct_pid, pgid, + group_drained: false, } } @@ -84,9 +88,8 @@ impl ProcessGroupChild { impl CommandWrapper for ProcessGroup { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - crate::command::tokio_process_group(command, self.leader.as_raw()); - Ok(()) + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_process_group(self.target) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -95,7 +98,7 @@ impl CommandWrapper for ProcessGroup { inner: Box, _core: &CommandWrap, ) -> Result> { - let pgid = Pid::from_raw( + let direct_pid = Pid::from_raw( i32::try_from( inner .id() @@ -103,8 +106,14 @@ impl CommandWrapper for ProcessGroup { ) .expect("Command PID > i32::MAX"), ); + 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"), + ), + }; - Ok(Box::new(ProcessGroupChild::new(inner, pgid))) + Ok(Box::new(ProcessGroupChild::new(inner, direct_pid, pgid))) } } @@ -115,7 +124,11 @@ impl ProcessGroupChild { } #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - fn wait_imp(pgid: Pid, flag: WaitPidFlag) -> Result>> { + fn wait_imp( + direct_pid: Pid, + pgid: Pid, + flag: WaitPidFlag, + ) -> Result, Option>> { // wait for processes in a loop until every process in this group has // exited (this ensures that we reap any zombies that may have been // created if the parent exited after spawning children, but didn't wait @@ -131,7 +144,7 @@ impl ProcessGroupChild { 0 => { // zero should only happen if WNOHANG was passed in, // and means that no processes have yet to exit - return Ok(ControlFlow::Continue(())); + return Ok(ControlFlow::Continue(parent_exit_status)); } -1 => { match Errno::last() { @@ -148,7 +161,7 @@ impl ProcessGroupChild { // a process exited. was it the parent process that we // started? if so, collect the exit signal, otherwise we // reaped a zombie process and should continue looping - if pgid == Pid::from_raw(pid) { + if direct_pid == Pid::from_raw(pid) { parent_exit_status = Some(ExitStatus::from_raw(status)); } else { // reaped a zombie child; keep looping @@ -178,53 +191,93 @@ impl ChildWrapper for ProcessGroupChild { #[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; - let pgid = self.pgid; + let status = match self.exit_status { + ChildExitStatus::Running => { + let status = self.inner.wait().await?; + self.exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; - // always wait for parent to exit first, as by the time it does, - // it's likely that all its children have already been reaped. - let status = self.inner.wait().await?; - self.exit_status = ChildExitStatus::Exited(status); + if !self.group_drained { + const MAX_RETRY_ATTEMPT: usize = 10; + for _ in 1..MAX_RETRY_ATTEMPT { + match Self::wait_imp(self.direct_pid, self.pgid, WaitPidFlag::WNOHANG)? { + ControlFlow::Break(reaped) => { + if let Some(reaped) = reaped { + self.exit_status = ChildExitStatus::Exited(reaped); + } + self.group_drained = true; + break; + } + ControlFlow::Continue(reaped) => { + if let Some(reaped) = reaped { + self.exit_status = ChildExitStatus::Exited(reaped); + } + } + } + } + } - // nevertheless, now try reaping all children a few times... - for _ in 1..MAX_RETRY_ATTEMPT { - if Self::wait_imp(pgid, WaitPidFlag::WNOHANG)?.is_break() { - return Ok(status); + if !self.group_drained { + let direct_pid = self.direct_pid; + let pgid = self.pgid; + let result = + spawn_blocking(move || Self::wait_imp(direct_pid, pgid, WaitPidFlag::empty())) + .await??; + if let ControlFlow::Break(reaped) = result { + if let Some(reaped) = reaped { + self.exit_status = ChildExitStatus::Exited(reaped); + } + self.group_drained = true; } } - // ...finally, if there are some that are still alive, - // block in the background to reap them fully. - let _ = spawn_blocking(move || Self::wait_imp(pgid, WaitPidFlag::empty())).await??; - Ok(status) + match self.exit_status { + ChildExitStatus::Exited(status) => Ok(status), + ChildExitStatus::Running => Ok(status), + } }) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - if let ChildExitStatus::Exited(status) = &self.exit_status { - return Ok(Some(*status)); + if self.group_drained { + return match self.exit_status { + ChildExitStatus::Exited(status) => Ok(Some(status)), + ChildExitStatus::Running => { + let status = self.inner.try_wait()?; + if let Some(status) = status { + self.exit_status = ChildExitStatus::Exited(status); + } + Ok(status) + } + }; } - match Self::wait_imp(self.pgid, WaitPidFlag::WNOHANG)? { - ControlFlow::Break(res) => { - if let Some(status) = res { - self.exit_status = ChildExitStatus::Exited(status); - } - Ok(res) - } - ControlFlow::Continue(()) => { - let exited = self.inner.try_wait()?; - if let Some(exited) = exited { - self.exit_status = ChildExitStatus::Exited(exited); - } - Ok(exited) + let (drained, reaped) = + match Self::wait_imp(self.direct_pid, self.pgid, WaitPidFlag::WNOHANG)? { + ControlFlow::Break(status) => (true, status), + ControlFlow::Continue(status) => (false, status), + }; + if let Some(status) = reaped { + self.exit_status = ChildExitStatus::Exited(status); + } + if matches!(self.exit_status, ChildExitStatus::Running) { + if let Some(status) = self.inner.try_wait()? { + self.exit_status = ChildExitStatus::Exited(status); } } + self.group_drained = drained; + + if !self.group_drained { + return Ok(None); + } + match self.exit_status { + ChildExitStatus::Exited(status) => Ok(Some(status)), + ChildExitStatus::Running => Ok(None), + } } fn signal(&self, sig: i32) -> Result<()> { diff --git a/src/tokio/process_session.rs b/src/tokio/process_session.rs index 89c00fa..61b5077 100644 --- a/src/tokio/process_session.rs +++ b/src/tokio/process_session.rs @@ -1,17 +1,16 @@ -use std::io::{Error, Result}; +use std::io::Result; -use nix::unistd::{Pid, setsid}; -use tokio::process::Command; +use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; -use super::{CommandWrap, CommandWrapper}; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which creates a new session and group for the `Command`. /// /// This wrapper is only available on Unix. /// -/// It creates a new session and new process group and sets the [`Command`] as its leader. +/// It creates a new session and new process group and sets the [`Command`](super::Command) as its leader. /// See [setsid(2)](https://pubs.opengroup.org/onlinepubs/9699919799/functions/setsid.html). /// /// You may find that some programs behave differently or better when running in a session rather @@ -24,12 +23,8 @@ pub struct ProcessSession; impl CommandWrapper for ProcessSession { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - unsafe { - command.pre_exec(move || setsid().map_err(Error::from).map(|_| ())); - } - - Ok(()) + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + attempt.set_process_session() } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] @@ -38,7 +33,7 @@ impl CommandWrapper for ProcessSession { inner: Box, _core: &CommandWrap, ) -> Result> { - let pgid = Pid::from_raw( + let direct_pid = Pid::from_raw( i32::try_from( inner .id() @@ -47,6 +42,8 @@ impl CommandWrapper for ProcessSession { .expect("Command PID > i32::MAX"), ); - Ok(Box::new(super::ProcessGroupChild::new(inner, pgid))) + Ok(Box::new(super::ProcessGroupChild::new( + inner, direct_pid, direct_pid, + ))) } } diff --git a/src/tokio/reset_sigmask.rs b/src/tokio/reset_sigmask.rs index 58b32bc..d4c51f5 100644 --- a/src/tokio/reset_sigmask.rs +++ b/src/tokio/reset_sigmask.rs @@ -1,11 +1,9 @@ use std::io::Result; -use nix::sys::signal::{SigSet, SigmaskHow, sigprocmask}; -use tokio::process::Command; #[cfg(feature = "tracing")] use tracing::trace; -use super::{CommandWrap, CommandWrapper}; +use super::{CommandWrap, CommandWrapper, SpawnAttempt}; /// Wrapper which resets the process signal mask. /// @@ -15,22 +13,10 @@ use super::{CommandWrap, CommandWrapper}; pub struct ResetSigmask; impl CommandWrapper for ResetSigmask { - fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> Result<()> { - unsafe { - command.pre_exec(|| { - let mut oldset = SigSet::empty(); - let newset = SigSet::all(); - - #[cfg(feature = "tracing")] - trace!(unblocking=?newset, "resetting process sigmask"); - - sigprocmask(SigmaskHow::SIG_UNBLOCK, Some(&newset), Some(&mut oldset))?; - - #[cfg(feature = "tracing")] - trace!(?oldset, "sigmask reset"); - Ok(()) - }); - } + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _core: &CommandWrap) -> Result<()> { + #[cfg(feature = "tracing")] + trace!("configuring process sigmask reset"); + attempt.set_reset_sigmask(); Ok(()) } } diff --git a/src/unix.rs b/src/unix.rs new file mode 100644 index 0000000..4814ce5 --- /dev/null +++ b/src/unix.rs @@ -0,0 +1,365 @@ +use std::{ + io, ptr, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicI32, Ordering}, + }, +}; + +use nix::libc; + +use crate::command::NativeCommand; + +const NO_PROCESS_GROUP: i32 = -1; +const LEADER_PROCESS_GROUP: i32 = 0; + +/// Process-group setup requested for one spawn attempt. +#[cfg_attr(not(feature = "process-group"), allow(dead_code))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProcessGroupTarget { + /// Make the spawned process the leader of a new process group. + Leader, + /// Attach the spawned process to the existing process group with this ID. + AttachTo(u32), +} + +impl ProcessGroupTarget { + fn as_raw(self) -> io::Result { + match self { + Self::Leader => Ok(LEADER_PROCESS_GROUP), + Self::AttachTo(0) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "an existing process group ID must be positive", + )), + Self::AttachTo(pgid) => i32::try_from(pgid).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "process group ID exceeds the platform range", + ) + }), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct SpawnPolicy { + pub(crate) process_group: Option, + pub(crate) process_session: bool, + pub(crate) reset_sigmask: bool, +} + +impl SpawnPolicy { + #[cfg(feature = "process-group")] + pub(crate) fn set_process_group(&mut self, target: ProcessGroupTarget) -> io::Result<()> { + if self.process_session && matches!(target, ProcessGroupTarget::AttachTo(_)) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a process cannot join an existing process group and create a new session", + )); + } + target.as_raw()?; + self.process_group = Some(target); + Ok(()) + } + + #[cfg(feature = "process-session")] + pub(crate) fn set_process_session(&mut self) -> io::Result<()> { + if matches!(self.process_group, Some(ProcessGroupTarget::AttachTo(_))) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a process cannot join an existing process group and create a new session", + )); + } + self.process_session = true; + Ok(()) + } + + fn is_empty(self) -> bool { + self.process_group.is_none() && !self.process_session && !self.reset_sigmask + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct CommandState(Arc); + +impl CommandState { + pub(crate) fn prepare( + &self, + command: &mut N, + native_only_base: bool, + policy: SpawnPolicy, + ) { + if policy.is_empty() { + self.0.disarm(); + return; + } + + if !native_only_base || !self.0.has_native_only_callback() { + self.install(command); + if native_only_base { + self.0 + .installed_on_native_only + .store(true, Ordering::Release); + } + } + + self.0.arm(policy); + } + + fn install(&self, command: &mut N) { + let active = Arc::new(AtomicBool::new(true)); + let mut installed = self + .0 + .active_callback + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(previous) = installed.replace(Arc::clone(&active)) { + previous.store(false, Ordering::Release); + } + drop(installed); + + let dispatcher = Arc::clone(&self.0); + // SAFETY: the callback only invokes async-signal-safe Unix process setup functions and + // reads atomics populated before spawning. It never accesses `active_callback`'s mutex. + unsafe { + command.pre_exec(move || { + if active.load(Ordering::Acquire) { + dispatcher.run() + } else { + Ok(()) + } + }) + }; + } + + pub(crate) fn invalidate(&self) { + self.0.invalidate(); + } + + pub(crate) fn disarm(&self) { + self.0.disarm(); + } +} + +#[derive(Debug)] +struct Dispatcher { + installed_on_native_only: AtomicBool, + active_callback: Mutex>>, + process_group: AtomicI32, + process_session: AtomicBool, + reset_sigmask: AtomicBool, +} + +impl Default for Dispatcher { + fn default() -> Self { + Self { + installed_on_native_only: AtomicBool::new(false), + active_callback: Mutex::new(None), + process_group: AtomicI32::new(NO_PROCESS_GROUP), + process_session: AtomicBool::new(false), + reset_sigmask: AtomicBool::new(false), + } + } +} + +impl Dispatcher { + fn has_native_only_callback(&self) -> bool { + if !self.installed_on_native_only.load(Ordering::Acquire) { + return false; + } + + let installed = self + .active_callback + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // `active_callback` owns one strong reference and the native command's callback owns + // another. The second reference disappears if an explicit spawner replaces the command. + installed + .as_ref() + .is_some_and(|active| Arc::strong_count(active) > 1) + } + + fn arm(&self, policy: SpawnPolicy) { + let process_group = policy + .process_group + .map(ProcessGroupTarget::as_raw) + .transpose() + .expect("process group targets are validated when configured") + .unwrap_or(NO_PROCESS_GROUP); + self.process_group.store(process_group, Ordering::SeqCst); + self.process_session + .store(policy.process_session, Ordering::SeqCst); + self.reset_sigmask + .store(policy.reset_sigmask, Ordering::SeqCst); + } + + fn invalidate(&self) { + self.installed_on_native_only + .store(false, Ordering::Release); + if let Some(active) = self + .active_callback + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + active.store(false, Ordering::Release); + } + self.disarm(); + } + + fn disarm(&self) { + self.process_group.store(NO_PROCESS_GROUP, Ordering::SeqCst); + self.process_session.store(false, Ordering::SeqCst); + self.reset_sigmask.store(false, Ordering::SeqCst); + } + + fn run(&self) -> io::Result<()> { + let reset_sigmask = self.reset_sigmask.load(Ordering::SeqCst); + let process_session = self.process_session.load(Ordering::SeqCst); + let process_group = self.process_group.load(Ordering::SeqCst); + + if reset_sigmask { + let mut empty = std::mem::MaybeUninit::::uninit(); + // SAFETY: `empty` points to writable storage for one signal set. + if unsafe { libc::sigemptyset(empty.as_mut_ptr()) } == -1 { + return Err(io::Error::last_os_error()); + } + // SAFETY: `sigemptyset` initialized `empty`; the old mask is not requested. + let error = unsafe { + libc::pthread_sigmask(libc::SIG_SETMASK, empty.as_ptr(), ptr::null_mut()) + }; + if error != 0 { + return Err(io::Error::from_raw_os_error(error)); + } + } + + if process_session { + // SAFETY: `setsid` takes no pointers and runs in the child before exec. + if unsafe { libc::setsid() } == -1 { + return Err(io::Error::last_os_error()); + } + } + + if process_group != NO_PROCESS_GROUP + && !(process_session && process_group == LEADER_PROCESS_GROUP) + { + // SAFETY: `setpgid` is applied to the current child and retains no pointers. + if unsafe { libc::setpgid(0, process_group) } == -1 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + ffi::{OsStr, OsString}, + fmt, + path::{Path, PathBuf}, + process::Stdio, + }; + + use super::*; + + #[derive(Default)] + struct FakeCommand { + program: OsString, + args: Vec, + env: Vec<(OsString, Option)>, + current_dir: Option, + callbacks: Vec io::Result<()> + Send + Sync>>, + } + + impl fmt::Debug for FakeCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FakeCommand") + .field("program", &self.program) + .field("callbacks", &self.callbacks.len()) + .finish_non_exhaustive() + } + } + + impl NativeCommand for FakeCommand { + fn new(program: &OsStr) -> Self { + Self { + program: program.to_owned(), + ..Self::default() + } + } + + fn arg(&mut self, arg: &OsStr) { + self.args.push(arg.to_owned()); + } + + fn env(&mut self, key: &OsStr, value: &OsStr) { + self.env.push((key.to_owned(), Some(value.to_owned()))); + } + + fn env_remove(&mut self, key: &OsStr) { + self.env.push((key.to_owned(), None)); + } + + fn env_clear(&mut self) { + self.env.clear(); + } + + fn current_dir(&mut self, dir: &Path) { + self.current_dir = Some(dir.to_owned()); + } + + fn stdin(&mut self, _stdio: Stdio) {} + + fn stdout(&mut self, _stdio: Stdio) {} + + fn stderr(&mut self, _stdio: Stdio) {} + + unsafe fn pre_exec(&mut self, callback: F) + where + F: FnMut() -> io::Result<()> + Send + Sync + 'static, + { + self.callbacks.push(Box::new(callback)); + } + + fn get_program(&self) -> &OsStr { + &self.program + } + + fn get_args(&self) -> Box + '_> { + Box::new(self.args.iter().map(OsString::as_os_str)) + } + + fn get_envs(&self) -> Box)> + '_> { + Box::new( + self.env + .iter() + .map(|(key, value)| (key.as_os_str(), value.as_deref())), + ) + } + + fn get_current_dir(&self) -> Option<&Path> { + self.current_dir.as_deref() + } + } + + #[test] + fn native_only_dispatcher_is_reused_until_the_command_is_replaced() { + let state = CommandState::default(); + let policy = SpawnPolicy { + process_group: Some(ProcessGroupTarget::Leader), + ..SpawnPolicy::default() + }; + let mut command = FakeCommand::new(OsStr::new("test")); + + for _ in 0..4 { + state.prepare(&mut command, true, policy); + assert_eq!(command.callbacks.len(), 1); + } + + command = FakeCommand::new(OsStr::new("replacement")); + state.prepare(&mut command, true, policy); + assert_eq!(command.callbacks.len(), 1); + } +} diff --git a/src/windows.rs b/src/windows.rs index 890bdb5..368ba25 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -113,6 +113,29 @@ 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; + } + + unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + &info as *const _ as _, + std::mem::size_of_val(&info) + .try_into() + .expect("cannot safely cast to DWORD"), + ) + }?; + #[cfg(feature = "tracing")] + debug!(?info, "done SetInformationJobObject(limit)"); + Ok(()) +} + /// Create a JobObject and an associated completion port. /// /// If `kill_on_drop` is true, we opt into the `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag, which @@ -149,24 +172,7 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res "done SetInformationJobObject(completion)" ); - let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - - if kill_on_drop { - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - } - - unsafe { - SetInformationJobObject( - job.0, - JobObjectExtendedLimitInformation, - &info as *const _ as _, - std::mem::size_of_val(&info) - .try_into() - .expect("cannot safely cast to DWORD"), - ) - }?; - #[cfg(feature = "tracing")] - debug!(?info, "done SetInformationJobObject(limit)"); + set_job_kill_on_drop(JobHandle(job.0), kill_on_drop)?; unsafe { AssignProcessToJobObject(job.0, process_handle) }?; #[cfg(feature = "tracing")] diff --git a/tests/command_facade.rs b/tests/command_facade.rs index 09bfcf9..3131124 100644 --- a/tests/command_facade.rs +++ b/tests/command_facade.rs @@ -2,17 +2,13 @@ mod std_frontend { use std::{ffi::OsStr, io}; - use process_wrap::std::{Command, CommandWrap, CommandWrapper}; + use process_wrap::std::{Command, CommandArg, CommandWrap, CommandWrapper, SpawnAttempt}; #[derive(Debug)] struct AttemptArgument; impl CommandWrapper for AttemptArgument { - fn pre_spawn( - &mut self, - command: &mut std::process::Command, - _core: &CommandWrap, - ) -> io::Result<()> { + fn pre_spawn(&mut self, command: &mut SpawnAttempt, _core: &CommandWrap) -> io::Result<()> { command.arg("attempt"); Ok(()) } @@ -22,11 +18,7 @@ mod std_frontend { struct InspectFacade; impl CommandWrapper for InspectFacade { - fn pre_spawn( - &mut self, - _command: &mut std::process::Command, - core: &CommandWrap, - ) -> io::Result<()> { + fn pre_spawn(&mut self, _command: &mut SpawnAttempt, core: &CommandWrap) -> io::Result<()> { assert_eq!(core.command().get_program(), OsStr::new("tool")); assert_eq!( core.command().get_args().collect::>(), @@ -53,6 +45,17 @@ mod std_frontend { command.get_args().collect::>(), [OsStr::new("first"), OsStr::new("second")] ); + assert_eq!( + command.get_portable_args(), + Some( + [ + CommandArg::Regular("first".into()), + CommandArg::Regular("second".into()), + ] + .as_slice() + ) + ); + assert_eq!(command.inherits_environment(), Some(true)); let alias: CommandWrap = command; assert_eq!(alias.get_program(), OsStr::new("tool")); @@ -112,6 +115,8 @@ mod std_frontend { let mut command = Command::from(native); command.arg("facade"); command.native_mut().arg("escape"); + assert!(command.get_portable_args().is_none()); + assert_eq!(command.inherits_environment(), None); command .spawn_with(|native| { @@ -156,6 +161,7 @@ mod std_frontend { .current_dir(&cwd); let tracked_env = command.get_envs().collect::>(); + assert_eq!(command.inherits_environment(), Some(false)); assert_eq!( tracked_env, [( @@ -210,6 +216,17 @@ mod std_frontend { OsStr::new("regular-2") ] ); + assert_eq!( + command.get_portable_args(), + Some( + [ + CommandArg::Regular("regular-1".into()), + CommandArg::Raw(" raw ".into()), + CommandArg::Regular("regular-2".into()), + ] + .as_slice() + ) + ); } #[cfg(windows)] @@ -233,17 +250,13 @@ mod std_frontend { mod tokio_frontend { use std::{ffi::OsStr, io}; - use process_wrap::tokio::{Command, CommandWrap, CommandWrapper}; + use process_wrap::tokio::{Command, CommandArg, CommandWrap, CommandWrapper, SpawnAttempt}; #[derive(Debug)] struct AttemptArgument; impl CommandWrapper for AttemptArgument { - fn pre_spawn( - &mut self, - command: &mut tokio::process::Command, - _core: &CommandWrap, - ) -> io::Result<()> { + fn pre_spawn(&mut self, command: &mut SpawnAttempt, _core: &CommandWrap) -> io::Result<()> { command.arg("attempt"); Ok(()) } @@ -253,11 +266,7 @@ mod tokio_frontend { struct InspectFacade; impl CommandWrapper for InspectFacade { - fn pre_spawn( - &mut self, - _command: &mut tokio::process::Command, - core: &CommandWrap, - ) -> io::Result<()> { + fn pre_spawn(&mut self, _command: &mut SpawnAttempt, core: &CommandWrap) -> io::Result<()> { assert_eq!(core.command().get_program(), OsStr::new("tool")); assert_eq!( core.command().get_args().collect::>(), @@ -284,6 +293,17 @@ mod tokio_frontend { command.get_args().collect::>(), [OsStr::new("first"), OsStr::new("second")] ); + assert_eq!( + command.get_portable_args(), + Some( + [ + CommandArg::Regular("first".into()), + CommandArg::Regular("second".into()), + ] + .as_slice() + ) + ); + assert_eq!(command.inherits_environment(), Some(true)); let alias: CommandWrap = command; assert_eq!(alias.get_program(), OsStr::new("tool")); @@ -343,6 +363,8 @@ mod tokio_frontend { let mut command = Command::from(native); command.arg("facade"); command.native_mut().arg("escape"); + assert!(command.get_portable_args().is_none()); + assert_eq!(command.inherits_environment(), None); command .spawn_with(|native| { @@ -387,6 +409,7 @@ mod tokio_frontend { .current_dir(&cwd); let tracked_env = command.get_envs().collect::>(); + assert_eq!(command.inherits_environment(), Some(false)); assert_eq!( tracked_env, [( @@ -454,6 +477,17 @@ mod tokio_frontend { OsStr::new("regular-2") ] ); + assert_eq!( + command.get_portable_args(), + Some( + [ + CommandArg::Regular("regular-1".into()), + CommandArg::Raw(" raw ".into()), + CommandArg::Regular("regular-2".into()), + ] + .as_slice() + ) + ); } #[cfg(windows)] diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs new file mode 100644 index 0000000..11af142 --- /dev/null +++ b/tests/spawn_provider.rs @@ -0,0 +1,947 @@ +#![cfg(all(any(feature = "std", feature = "tokio1"), any(unix, windows)))] + +macro_rules! spawn_provider_tests { + ( + $module:ident, + $command_wrap:path, + $spawn_attempt:path, + $command_wrapper:path, + $child_wrapper:path, + $provider_product:path, + $spawn_provider:path, + $runtime:expr + ) => { + mod $module { + use std::{ + any::TypeId, + ffi::OsStr, + io, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use process_wrap::{CommandArg, 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); + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Point { + Available, + ValidateCommand, + Pre, + ValidateAttempt, + Spawn, + Post, + PeerPost, + Wrap, + PeerWrap, + Commit, + Rollback, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Failure { + Error(io::ErrorKind, &'static str), + Panic(&'static str), + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Event { + Extend(&'static str, &'static str), + Available(&'static str), + ValidateCommand(&'static str), + Pre(&'static str), + ValidateAttempt(&'static str), + Spawn(&'static str), + Post(&'static str), + Wrap(&'static str), + Commit, + Rollback, + } + + #[derive(Debug, Default)] + struct Shared { + events: Mutex>, + failures: Mutex>, + make_attempt_native_only: AtomicBool, + } + + impl Shared { + fn event(&self, event: Event) { + self.events.lock().unwrap().push(event); + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + fn clear_events(&self) { + self.events.lock().unwrap().clear(); + } + + fn fail_once(&self, point: Point, failure: Failure) { + self.failures.lock().unwrap().push((point, failure)); + } + + fn fail(&self, point: Point) -> io::Result<()> { + let failure = { + let mut failures = self.failures.lock().unwrap(); + failures + .iter() + .position(|(candidate, _)| *candidate == point) + .map(|index| failures.remove(index).1) + }; + + match failure { + Some(Failure::Error(kind, message)) => Err(io::Error::new(kind, message)), + Some(Failure::Panic(message)) => panic_any(message), + None => Ok(()), + } + } + } + + #[derive(Debug)] + struct CustomChild; + + impl ChildWrapper for CustomChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + } + + #[derive(Debug)] + struct Transaction(Arc); + + impl SpawnTransaction for Transaction { + fn commit(&mut self) -> io::Result<()> { + self.0.event(Event::Commit); + self.0.fail(Point::Commit) + } + + fn rollback(&mut self) -> io::Result<()> { + self.0.event(Event::Rollback); + self.0.fail(Point::Rollback) + } + } + + #[derive(Debug)] + struct Provider { + name: &'static str, + shared: Arc, + } + + impl Provider { + fn assert_callback_visibility(&self, command: &CommandWrap) { + assert!(command.has_wrap::()); + assert!(command.get_wrap::().is_none()); + assert!(command.has_wrap::()); + assert!(command.get_wrap::().is_some()); + } + } + + impl SpawnProvider for Provider { + fn check_available(&self) -> io::Result<()> { + self.shared.event(Event::Available(self.name)); + self.shared.fail(Point::Available) + } + + fn validate_command(&self, command: &CommandWrap) -> io::Result<()> { + self.assert_callback_visibility(command); + assert_eq!(command.inherits_environment(), Some(false)); + let args = command + .get_portable_args() + .expect("provider validation receives tracked portable arguments"); + assert!(matches!(args.first(), Some(CommandArg::Regular(_)))); + #[cfg(windows)] + assert!(matches!( + args.last(), + Some(CommandArg::Raw(arg)) if arg == OsStr::new(" provider-raw-fragment") + )); + #[cfg(unix)] + assert!(args.iter().all(|arg| matches!(arg, CommandArg::Regular(_)))); + assert!(command.get_envs().any(|(key, value)| { + key == OsStr::new("PROCESS_WRAP_PROVIDER_BASE") + && value == Some(OsStr::new("set")) + })); + assert!( + !command + .get_envs() + .any(|(key, _)| key == OsStr::new("PROCESS_WRAP_PROVIDER_HOOK")) + ); + self.shared.event(Event::ValidateCommand(self.name)); + self.shared.fail(Point::ValidateCommand) + } + + fn validate_attempt( + &self, + attempt: &SpawnAttempt, + command: &CommandWrap, + ) -> io::Result<()> { + self.assert_callback_visibility(command); + assert!(!attempt.is_native_only()); + #[cfg(unix)] + { + assert_eq!(attempt.process_group_target(), None); + assert!(!attempt.creates_process_session()); + assert!(!attempt.resets_sigmask()); + } + assert_eq!(attempt.inherits_environment(), Some(false)); + let args = attempt + .get_portable_args() + .expect("provider validation receives tracked portable arguments"); + assert!(matches!(args.first(), Some(CommandArg::Regular(_)))); + #[cfg(windows)] + assert!(matches!( + args.last(), + Some(CommandArg::Raw(arg)) if arg == OsStr::new(" provider-raw-fragment") + )); + #[cfg(unix)] + assert!(args.iter().all(|arg| matches!(arg, CommandArg::Regular(_)))); + assert!(attempt.get_envs().any(|(key, value)| { + key == OsStr::new("PROCESS_WRAP_PROVIDER_HOOK") + && value == Some(OsStr::new(self.name)) + })); + assert!(attempt.get_envs().any(|(key, value)| { + key == OsStr::new("PROCESS_WRAP_PROVIDER_PEER") + && value == Some(OsStr::new("set")) + })); + self.shared.event(Event::ValidateAttempt(self.name)); + self.shared.fail(Point::ValidateAttempt) + } + + fn spawn( + &self, + attempt: &mut SpawnAttempt, + command: &CommandWrap, + ) -> io::Result { + self.assert_callback_visibility(command); + assert!(!attempt.is_native_only()); + self.shared.event(Event::Spawn(self.name)); + self.shared.fail(Point::Spawn)?; + Ok(ProviderProduct::new( + Box::new(CustomChild), + Box::new(Transaction(Arc::clone(&self.shared))), + )) + } + } + + #[derive(Debug)] + struct ProviderWrapper { + name: &'static str, + provider: Provider, + } + + impl ProviderWrapper { + fn new(name: &'static str, shared: Arc) -> Self { + Self { + name, + provider: Provider { name, shared }, + } + } + + fn shared(&self) -> &Arc { + &self.provider.shared + } + + fn assert_hook_visibility(&self, command: &CommandWrap) { + assert!(command.has_wrap::()); + assert!(command.get_wrap::().is_none()); + assert!(command.has_wrap::()); + assert!(command.get_wrap::().is_some()); + } + } + + impl CommandWrapper for ProviderWrapper { + fn extend(&mut self, other: Self) { + self.shared().event(Event::Extend(self.name, other.name)); + self.name = other.name; + self.provider = other.provider; + } + + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + command: &CommandWrap, + ) -> io::Result<()> { + self.assert_hook_visibility(command); + self.shared().event(Event::Pre(self.name)); + attempt.env("PROCESS_WRAP_PROVIDER_HOOK", self.name); + if self + .shared() + .make_attempt_native_only + .swap(false, Ordering::SeqCst) + { + let _ = attempt.native_mut(); + } + self.shared().fail(Point::Pre) + } + + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + command: &CommandWrap, + ) -> io::Result<()> { + self.assert_hook_visibility(command); + assert_eq!(child.type_id(), TypeId::of::()); + self.shared().event(Event::Post(self.name)); + self.shared().fail(Point::Post) + } + + fn wrap_child( + &mut self, + child: Box, + command: &CommandWrap, + ) -> io::Result> { + self.assert_hook_visibility(command); + assert_eq!(child.as_ref().type_id(), TypeId::of::()); + self.shared().event(Event::Wrap(self.name)); + self.shared().fail(Point::Wrap)?; + Ok(child) + } + + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.provider) + } + } + + #[derive(Debug)] + struct Peer { + shared: Arc, + expect_provider: bool, + expect_custom_child: bool, + } + + impl Peer { + fn assert_hook_visibility(&self, command: &CommandWrap) { + assert!(command.has_wrap::()); + assert!(command.get_wrap::().is_none()); + if self.expect_provider { + assert!(command.has_wrap::()); + assert!(command.get_wrap::().is_some()); + } + } + } + + impl CommandWrapper for Peer { + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + command: &CommandWrap, + ) -> io::Result<()> { + self.assert_hook_visibility(command); + self.shared.event(Event::Pre("peer")); + attempt.env("PROCESS_WRAP_PROVIDER_PEER", "set"); + Ok(()) + } + + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + command: &CommandWrap, + ) -> io::Result<()> { + self.assert_hook_visibility(command); + if self.expect_custom_child { + assert_eq!(child.type_id(), TypeId::of::()); + } + self.shared.event(Event::Post("peer")); + self.shared.fail(Point::PeerPost) + } + + fn wrap_child( + &mut self, + child: Box, + command: &CommandWrap, + ) -> io::Result> { + self.assert_hook_visibility(command); + if self.expect_custom_child { + assert_eq!(child.as_ref().type_id(), TypeId::of::()); + } + self.shared.event(Event::Wrap("peer")); + self.shared.fail(Point::PeerWrap)?; + Ok(child) + } + } + + #[derive(Debug)] + struct OtherProviderWrapper(Provider); + + impl CommandWrapper for OtherProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + fn runtime() -> Option { + $runtime + } + + fn command() -> CommandWrap { + #[cfg(unix)] + let mut command = CommandWrap::with_new("sh", |command| { + command.args(["-c", "exit 0"]); + }); + + #[cfg(windows)] + let mut command = CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }); + + command.env("PROCESS_WRAP_PROVIDER_BASE", "set"); + command + } + + fn configure_provider_intent(command: &mut CommandWrap) { + command + .env_clear() + .env("PROCESS_WRAP_PROVIDER_BASE", "set"); + #[cfg(windows)] + command.raw_arg(" provider-raw-fragment"); + } + + fn provider_command(shared: Arc, name: &'static str) -> CommandWrap { + let mut command = command(); + configure_provider_intent(&mut command); + command + .wrap(ProviderWrapper::new(name, Arc::clone(&shared))) + .wrap(Peer { + shared, + expect_provider: true, + expect_custom_child: true, + }); + command + } + + fn successful_events(name: &'static str) -> Vec { + vec![ + Event::Available(name), + Event::ValidateCommand(name), + Event::Pre(name), + Event::Pre("peer"), + Event::ValidateAttempt(name), + Event::Spawn(name), + Event::Post(name), + Event::Post("peer"), + Event::Wrap(name), + Event::Wrap("peer"), + Event::Commit, + ] + } + + fn expected_before(point: Point, name: &'static str) -> Vec { + let mut events = vec![Event::Available(name)]; + if point == Point::Available { + return events; + } + events.push(Event::ValidateCommand(name)); + if point == Point::ValidateCommand { + return events; + } + events.push(Event::Pre(name)); + if point == Point::Pre { + return events; + } + events.push(Event::Pre("peer")); + if point == Point::ValidateAttempt { + events.push(Event::ValidateAttempt(name)); + return events; + } + events.push(Event::ValidateAttempt(name)); + events.push(Event::Spawn(name)); + events + } + + fn assert_failure( + command: &mut CommandWrap, + failure: Failure, + expected_message: &'static str, + ) { + match failure { + Failure::Error(kind, _) => { + let error = command.spawn().expect_err("the configured phase must fail"); + assert_eq!(error.kind(), kind); + assert_eq!(error.to_string(), expected_message); + } + Failure::Panic(_) => { + let panic = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("the configured phase must panic"); + assert_eq!( + *panic + .downcast::<&'static str>() + .expect("the test panic payload is a static string"), + expected_message + ); + } + } + } + + fn wait_for_exit(mut child: Box) { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if child.try_wait().unwrap().is_some() { + return; + } + assert!( + Instant::now() < deadline, + "child did not exit before timeout" + ); + sleep(Duration::from_millis(10)); + } + } + + #[test] + fn native_fallback_runs_the_complete_lifecycle() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let mut command = command(); + command.wrap(Peer { + shared: Arc::clone(&shared), + expect_provider: false, + expect_custom_child: false, + }); + + wait_for_exit(command.spawn().unwrap()); + assert_eq!( + shared.events(), + vec![Event::Pre("peer"), Event::Post("peer"), Event::Wrap("peer")] + ); + } + + #[test] + fn provider_runs_the_exact_lifecycle_for_a_custom_child() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + 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!(shared.events(), successful_events("provider")); + } + + #[test] + fn provider_conflicts_precede_callbacks_and_allocation() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let mut command = provider_command(Arc::clone(&shared), "first"); + command.wrap(OtherProviderWrapper(Provider { + name: "second", + shared: Arc::clone(&shared), + })); + + let error = command.spawn().expect_err("two providers must conflict"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), "multiple spawn providers are registered"); + assert!(shared.events().is_empty()); + } + + #[test] + fn duplicate_provider_wrapper_extends_one_registration() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let mut command = command(); + configure_provider_intent(&mut command); + command + .wrap(ProviderWrapper::new("first", Arc::clone(&shared))) + .wrap(ProviderWrapper::new("second", Arc::clone(&shared))) + .wrap(Peer { + shared: Arc::clone(&shared), + expect_provider: true, + expect_custom_child: true, + }); + + let _child = command.spawn().unwrap(); + let mut expected = vec![Event::Extend("first", "second")]; + expected.extend(successful_events("second")); + assert_eq!(shared.events(), expected); + } + + #[test] + fn availability_error_precedes_native_only_rejection() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.fail_once( + Point::Available, + Failure::Error(io::ErrorKind::Unsupported, "provider unavailable"), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + let _ = command.native_mut(); + + let error = command.spawn().expect_err("availability must fail first"); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert_eq!(error.to_string(), "provider unavailable"); + assert_eq!(shared.events(), vec![Event::Available("provider")]); + } + + #[test] + fn provider_rejects_native_only_base_after_availability() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let mut command = provider_command(Arc::clone(&shared), "provider"); + let _ = command.native_mut(); + + let error = command + .spawn() + .expect_err("native-only state must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only command" + ); + assert_eq!(shared.events(), vec![Event::Available("provider")]); + } + + #[test] + fn immutable_validation_precedes_hooks_and_is_reusable() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.fail_once( + Point::ValidateCommand, + Failure::Error(io::ErrorKind::InvalidInput, "invalid command"), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command.spawn().expect_err("command validation must fail"); + assert_eq!(error.to_string(), "invalid command"); + assert_eq!( + shared.events(), + vec![ + Event::Available("provider"), + Event::ValidateCommand("provider") + ] + ); + + shared.clear_events(); + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn attempt_validation_follows_hooks_and_is_reusable() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.fail_once( + Point::ValidateAttempt, + Failure::Error(io::ErrorKind::InvalidInput, "invalid attempt"), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command.spawn().expect_err("attempt validation must fail"); + assert_eq!(error.to_string(), "invalid attempt"); + assert_eq!( + shared.events(), + vec![ + Event::Available("provider"), + Event::ValidateCommand("provider"), + Event::Pre("provider"), + Event::Pre("peer"), + Event::ValidateAttempt("provider") + ] + ); + + shared.clear_events(); + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn provider_rejects_an_opaque_attempt_before_attempt_validation() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared + .make_attempt_native_only + .store(true, Ordering::SeqCst); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command + .spawn() + .expect_err("opaque attempt must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only spawn attempt" + ); + assert_eq!( + shared.events(), + vec![ + Event::Available("provider"), + Event::ValidateCommand("provider"), + Event::Pre("provider"), + Event::Pre("peer") + ] + ); + } + + #[test] + fn explicit_spawners_cannot_bypass_a_provider() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command + .spawn_with(|_| panic!("explicit native spawner must not run")) + .expect_err("spawn_with must reject a provider"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "an explicit spawner cannot bypass a registered spawn provider" + ); + let error = command + .spawn_with_child(|_| panic!("explicit child spawner must not run")) + .expect_err("spawn_with_child must reject a provider"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(shared.events().is_empty()); + + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn provider_path_restores_after_errors_and_panics() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for point in [ + Point::Available, + Point::ValidateCommand, + Point::Pre, + Point::ValidateAttempt, + Point::Spawn, + ] { + for failure in [ + Failure::Error(io::ErrorKind::Other, "provider callback failed"), + Failure::Panic("provider callback failed"), + ] { + let shared = Arc::new(Shared::default()); + shared.fail_once(point, failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "provider callback failed"); + assert_eq!(shared.events(), expected_before(point, "provider")); + assert!(command.get_wrap::().is_some()); + assert!(command.get_wrap::().is_some()); + + shared.clear_events(); + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + } + } + + #[test] + fn hook_failures_roll_back_and_restore_for_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for point in [Point::Post, Point::PeerPost, Point::Wrap, Point::PeerWrap] { + for failure in [ + Failure::Error(io::ErrorKind::Other, "hook failed"), + Failure::Panic("hook failed"), + ] { + let shared = Arc::new(Shared::default()); + shared.fail_once(point, failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "hook failed"); + let mut expected = expected_before(Point::Spawn, "provider"); + expected.push(Event::Post("provider")); + if point != Point::Post { + expected.push(Event::Post("peer")); + } + if matches!(point, Point::Wrap | Point::PeerWrap) { + expected.push(Event::Wrap("provider")); + } + if point == Point::PeerWrap { + expected.push(Event::Wrap("peer")); + } + 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 rollback_failures_preserve_the_original_failure() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for original in [ + Failure::Error(io::ErrorKind::Other, "original failure"), + Failure::Panic("original failure"), + ] { + for cleanup in [ + Failure::Error(io::ErrorKind::Other, "rollback failure"), + Failure::Panic("rollback failure"), + ] { + let shared = Arc::new(Shared::default()); + shared.fail_once(Point::Post, original); + shared.fail_once(Point::Rollback, cleanup); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, original, "original failure"); + assert_eq!(shared.events().last(), Some(&Event::Rollback)); + + shared.clear_events(); + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + } + } + + #[test] + fn commit_failure_rolls_back_and_preserves_its_error() { + 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::Error(io::ErrorKind::Other, "commit failed"), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command.spawn().expect_err("commit must fail"); + assert_eq!(error.to_string(), "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(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + for _ in 0..2 { + let _child = command.spawn().unwrap(); + } + let expected = successful_events("provider") + .into_iter() + .chain(successful_events("provider")) + .collect::>(); + assert_eq!(shared.events(), expected); + } + } + }; +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +mod tokio_kill_on_drop_policy { + use std::io; + + use process_wrap::tokio::{ + Command, CommandWrapper, KillOnDrop, ProviderProduct, SpawnAttempt, SpawnProvider, + }; + + #[derive(Debug)] + struct InspectProvider; + + impl SpawnProvider for InspectProvider { + fn validate_attempt(&self, attempt: &SpawnAttempt, _command: &Command) -> io::Result<()> { + assert!(!attempt.is_native_only()); + assert!(attempt.kills_on_drop()); + Err(io::Error::other("kill-on-drop policy inspected")) + } + + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> io::Result { + unreachable!("attempt validation stops before provider allocation") + } + } + + #[derive(Debug)] + struct Provider(InspectProvider); + + impl CommandWrapper for Provider { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[test] + fn kill_on_drop_remains_portable_for_tokio_providers() { + let mut command = Command::new("provider-owned-program"); + command.wrap(KillOnDrop).wrap(Provider(InspectProvider)); + + let error = command + .spawn() + .expect_err("policy inspection must stop before provider allocation"); + assert_eq!(error.to_string(), "kill-on-drop policy inspected"); + } +} + +#[cfg(all(unix, feature = "std"))] +const _: Option = None; +#[cfg(all(unix, feature = "tokio1"))] +const _: Option = None; +#[cfg(all(windows, feature = "std"))] +const _: Option = None; +#[cfg(all(windows, feature = "tokio1"))] +const _: Option = None; + +#[cfg(feature = "std")] +spawn_provider_tests!( + std_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, + None +); + +#[cfg(feature = "tokio1")] +spawn_provider_tests!( + tokio_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, + Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + ) +); diff --git a/tests/spawn_with_child.rs b/tests/spawn_with_child.rs index a343505..4b0cbc6 100644 --- a/tests/spawn_with_child.rs +++ b/tests/spawn_with_child.rs @@ -3,9 +3,8 @@ macro_rules! spawn_with_child_tests { ( $module:ident, - $command:path, - $child:path, $command_wrap:path, + $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, $runtime:expr @@ -20,11 +19,10 @@ macro_rules! spawn_with_child_tests { time::{Duration, Instant}, }; - use $child as Child; use $child_wrapper as ChildWrapper; - use $command as Command; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $spawn_attempt as SpawnAttempt; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); @@ -42,7 +40,7 @@ macro_rules! spawn_with_child_tests { impl CommandWrapper for First { fn pre_spawn( &mut self, - _command: &mut Command, + _attempt: &mut SpawnAttempt, _core: &CommandWrap, ) -> io::Result<()> { self.0.lock().unwrap().push(Event::Pre("first")); @@ -51,8 +49,8 @@ macro_rules! spawn_with_child_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> io::Result<()> { self.0.lock().unwrap().push(Event::Post("first")); @@ -75,7 +73,7 @@ macro_rules! spawn_with_child_tests { impl CommandWrapper for Second { fn pre_spawn( &mut self, - _command: &mut Command, + _attempt: &mut SpawnAttempt, _core: &CommandWrap, ) -> io::Result<()> { self.0.lock().unwrap().push(Event::Pre("second")); @@ -84,8 +82,8 @@ macro_rules! spawn_with_child_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> io::Result<()> { self.0.lock().unwrap().push(Event::Post("second")); @@ -146,6 +144,25 @@ macro_rules! spawn_with_child_tests { } } + #[derive(Debug)] + struct InspectCompletedAttempt { + portable: bool, + } + + impl CommandWrapper for InspectCompletedAttempt { + fn post_spawn( + &mut self, + attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _core: &CommandWrap, + ) -> io::Result<()> { + assert_eq!(!attempt.is_native_only(), self.portable); + assert_eq!(attempt.get_portable_args().is_some(), self.portable); + assert_eq!(attempt.inherits_environment().is_some(), self.portable); + Ok(()) + } + } + #[derive(Debug)] struct CustomLeaf; @@ -166,6 +183,7 @@ macro_rules! spawn_with_child_tests { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Phase { Pre, + Post, Wrap, } @@ -199,7 +217,7 @@ macro_rules! spawn_with_child_tests { impl CommandWrapper for FailOnce { fn pre_spawn( &mut self, - _command: &mut Command, + _attempt: &mut SpawnAttempt, _core: &CommandWrap, ) -> io::Result<()> { self.visit(Phase::Pre) @@ -207,11 +225,11 @@ macro_rules! spawn_with_child_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, ) -> io::Result<()> { - panic!("boxed-child spawning must not run post_spawn") + self.visit(Phase::Post) } fn wrap_child( @@ -321,7 +339,18 @@ macro_rules! spawn_with_child_tests { } #[test] - fn boxed_child_runs_pre_spawn_and_wrap_child_without_post_spawn() { + fn ordinary_spawn_keeps_portable_state_for_post_spawn_hooks() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command(); + command.wrap(InspectCompletedAttempt { portable: true }); + + let child = command.spawn().expect("spawn native child"); + wait_for_exit(child); + } + + #[test] + fn boxed_child_runs_the_complete_wrapper_lifecycle() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); let events = Arc::new(Mutex::new(Vec::new())); @@ -346,6 +375,8 @@ macro_rules! spawn_with_child_tests { Event::Pre("first"), Event::Pre("second"), Event::Spawn, + Event::Post("first"), + Event::Post("second"), Event::Wrap("first"), Event::Wrap("second"), ] @@ -360,7 +391,8 @@ macro_rules! spawn_with_child_tests { let mut command = command(); command .wrap(First(Arc::clone(&events))) - .wrap(Second(Arc::clone(&events))); + .wrap(Second(Arc::clone(&events))) + .wrap(InspectCompletedAttempt { portable: false }); let child = command .spawn_with(|command| { @@ -386,14 +418,14 @@ macro_rules! spawn_with_child_tests { #[test] fn boxed_child_restores_hooks_after_errors() { - for phase in [Phase::Pre, Phase::Wrap] { + for phase in [Phase::Pre, Phase::Post, Phase::Wrap] { recover_hook(Failure::Error, phase); } } #[test] fn boxed_child_restores_hooks_after_panics() { - for phase in [Phase::Pre, Phase::Wrap] { + for phase in [Phase::Pre, Phase::Post, Phase::Wrap] { recover_hook(Failure::Panic, phase); } } @@ -414,9 +446,8 @@ macro_rules! spawn_with_child_tests { #[cfg(feature = "std")] spawn_with_child_tests!( std_frontend, - std::process::Command, - std::process::Child, process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, None @@ -425,9 +456,8 @@ spawn_with_child_tests!( #[cfg(feature = "tokio1")] spawn_with_child_tests!( tokio_frontend, - tokio::process::Command, - tokio::process::Child, process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, Some( diff --git a/tests/std_unix/mod.rs b/tests/std_unix/mod.rs index 42463dc..5b24b1c 100644 --- a/tests/std_unix/mod.rs +++ b/tests/std_unix/mod.rs @@ -1,6 +1,8 @@ mod prelude { + #[cfg(all(target_os = "linux", feature = "process-group"))] + pub use std::io::{BufRead, BufReader}; pub use std::{ - io::{BufRead, BufReader, Read, Result, Write}, + io::{Read, Result, Write}, os::unix::process::ExitStatusExt, process::Stdio, thread::sleep, @@ -12,6 +14,7 @@ mod prelude { pub const DIE_TIME: Duration = Duration::from_millis(100); + #[cfg(all(target_os = "linux", feature = "process-group"))] #[track_caller] pub fn pid_alive(pid: i32) -> bool { #[inline] @@ -28,6 +31,7 @@ mod id_same_as_inner; mod inner_read_stdout; mod into_inner_write_stdin; mod kill_and_try_wait; +#[cfg(all(target_os = "linux", feature = "process-group"))] mod multiproc_linux; mod signals; mod try_wait_after_die; diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index 1e654d6..4ca145e 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -1,6 +1,13 @@ use std::{ + fs, io::{Error, ErrorKind}, - process::ExitStatus, + panic::{AssertUnwindSafe, catch_unwind}, + path::PathBuf, + process::{Command as StdCommand, ExitStatus}, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, + }, time::Instant, }; @@ -8,9 +15,14 @@ use windows::Win32::System::Threading::{ CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, PROCESS_CREATION_FLAGS, }; -use super::{prelude::*, windows_thread::process_has_suspended_thread}; +use super::{ + prelude::*, + windows_thread::{ProcessGuard, process_has_suspended_thread, resume_process_threads}, +}; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); +const DESCENDANT_PID_FILE: &str = "PROCESS_WRAP_DESCENDANT_PID_FILE"; +static PID_FILE_SEQUENCE: AtomicU32 = AtomicU32::new(0); #[derive(Clone, Copy)] enum Order { @@ -18,6 +30,262 @@ enum Order { JobObjectFirst, } +#[derive(Clone, Copy, Debug)] +struct ExpectedPolicy { + user_flags: u32, + spawn_flags: u32, + has_creation_flags: bool, + has_job_object: bool, + explicit_suspension: bool, + temporary_suspension: bool, +} + +#[derive(Debug)] +struct InspectProvider(ExpectedPolicy); + +impl SpawnProvider for InspectProvider { + fn validate_attempt(&self, attempt: &SpawnAttempt, _command: &CommandWrap) -> Result<()> { + assert!(!attempt.is_native_only()); + assert!(!attempt.kills_on_drop()); + let policy = attempt.windows_spawn_policy(); + assert_eq!(policy.user_creation_flags(), self.0.user_flags); + assert_eq!(policy.spawn_creation_flags(), self.0.spawn_flags); + assert_eq!(policy.has_creation_flags(), self.0.has_creation_flags); + assert_eq!(policy.has_job_object(), self.0.has_job_object); + assert_eq!(policy.is_explicitly_suspended(), self.0.explicit_suspension); + assert_eq!( + policy.is_temporarily_suspended(), + self.0.temporary_suspension + ); + assert!(!policy.kills_on_drop()); + Err(Error::other("policy inspected")) + } + + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + unreachable!("attempt validation stops before provider allocation") + } +} + +#[derive(Debug)] +struct InspectPolicy(InspectProvider); + +impl InspectPolicy { + fn new(expected: ExpectedPolicy) -> Self { + Self(InspectProvider(expected)) + } +} + +impl CommandWrapper for InspectPolicy { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + +#[derive(Clone, Copy, Debug)] +enum Failure { + Error, + Panic, +} + +#[derive(Debug)] +struct CapturePid(Arc); + +impl CommandWrapper for CapturePid { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _core: &CommandWrap, + ) -> Result<()> { + self.0.store(child.id(), Ordering::SeqCst); + Ok(()) + } +} + +#[derive(Debug)] +struct FailWrapOnce { + failure: Failure, + failed: bool, +} + +impl CommandWrapper for FailWrapOnce { + fn wrap_child( + &mut self, + child: Box, + _core: &CommandWrap, + ) -> Result> { + if self.failed { + return Ok(child); + } + self.failed = true; + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FailureHook { + PostSpawn, + WrapChild, + FinalizeSpawn, + DisarmSpawnCleanup, +} + +#[derive(Debug)] +struct FailFinalizationChild { + inner: Box, + failure: Failure, + hook: FailureHook, +} + +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) -> Result<()> { + if self.hook == FailureHook::FinalizeSpawn { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } + + fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { + if self.hook == FailureHook::DisarmSpawnCleanup { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } +} + +#[derive(Debug)] +struct FailAfterDescendant { + failure: Failure, + hook: FailureHook, + unwrap_child: bool, + pid_file: PathBuf, + guard: Arc>>, +} + +impl FailAfterDescendant { + fn observe_descendant(&self) -> Result<()> { + 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(()) + } + + fn fail(&self) -> Result { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } +} + +impl CommandWrapper for FailAfterDescendant { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _core: &CommandWrap, + ) -> Result<()> { + if self.hook != FailureHook::PostSpawn { + return Ok(()); + } + resume_process_threads(child.id())?; + self.observe_descendant()?; + self.fail() + } + + fn wrap_child( + &mut self, + child: Box, + _core: &CommandWrap, + ) -> Result> { + if self.hook == FailureHook::PostSpawn { + return Ok(child); + } + resume_process_threads(child.id())?; + self.observe_descendant()?; + if matches!( + self.hook, + FailureHook::FinalizeSpawn | FailureHook::DisarmSpawnCleanup + ) { + return Ok(Box::new(FailFinalizationChild { + inner: child, + failure: self.failure, + hook: self.hook, + })); + } + if self.unwrap_child { + drop(child.into_inner()); + } else { + drop(child); + } + self.fail() + } +} + +fn descendant_pid_file() -> PathBuf { + std::env::temp_dir().join(format!( + "process-wrap-{}-{}.pid", + std::process::id(), + PID_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) +} + +fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "descendant survived the failed spawn lifecycle", + )); + } + sleep(Duration::from_millis(10)); + } +} + fn command(flags: PROCESS_CREATION_FLAGS, order: Order) -> CommandWrap { let mut command = CommandWrap::with_new("cmd.exe", |command| { command.args(["/D", "/S", "/C", "exit /b 0"]); @@ -47,6 +315,201 @@ fn wait_for_exit(child: &mut dyn ChildWrapper) -> Result { } } +fn assert_policy(mut command: CommandWrap, expected: ExpectedPolicy) { + command.wrap(InspectPolicy::new(expected)); + let error = command + .spawn() + .expect_err("policy inspection must stop before provider allocation"); + assert_eq!(error.to_string(), "policy inspected"); +} + +#[test] +fn portable_policy_covers_flags_jobs_and_suspension() { + let flags = (CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW).0; + let explicit = (CREATE_NO_WINDOW | CREATE_SUSPENDED).0; + + let mut flags_only = CommandWrap::new("cmd.exe"); + flags_only.wrap(CreationFlags(PROCESS_CREATION_FLAGS(flags))); + assert_policy( + flags_only, + ExpectedPolicy { + user_flags: flags, + spawn_flags: flags, + has_creation_flags: true, + has_job_object: false, + explicit_suspension: false, + temporary_suspension: false, + }, + ); + + let mut job_only = CommandWrap::new("cmd.exe"); + job_only.wrap(JobObject); + assert_policy( + job_only, + ExpectedPolicy { + user_flags: 0, + spawn_flags: CREATE_SUSPENDED.0, + has_creation_flags: false, + has_job_object: true, + explicit_suspension: false, + temporary_suspension: true, + }, + ); + + for order in [Order::CreationFlagsFirst, Order::JobObjectFirst] { + assert_policy( + command(PROCESS_CREATION_FLAGS(flags), order), + ExpectedPolicy { + user_flags: flags, + spawn_flags: flags | CREATE_SUSPENDED.0, + has_creation_flags: true, + has_job_object: true, + explicit_suspension: false, + temporary_suspension: true, + }, + ); + assert_policy( + command(PROCESS_CREATION_FLAGS(explicit), order), + ExpectedPolicy { + user_flags: explicit, + spawn_flags: explicit, + has_creation_flags: true, + has_job_object: true, + explicit_suspension: true, + temporary_suspension: false, + }, + ); + } +} + +#[test] +#[ignore = "subprocess helper"] +fn lifecycle_descendant_leaf() { + std::thread::sleep(Duration::from_secs(300)); +} + +#[test] +#[ignore = "subprocess helper"] +fn lifecycle_descendant_parent() { + let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) + .args(["lifecycle_descendant_leaf", "--ignored", "--nocapture"]) + .spawn() + .unwrap(); + fs::write( + std::env::var_os(DESCENDANT_PID_FILE).unwrap(), + descendant.id().to_string(), + ) + .unwrap(); + descendant.wait().unwrap(); +} + +#[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), + ]; + for failure in [Failure::Error, Failure::Panic] { + for (hook, job_first, unwrap_child) 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| { + command + .args(["lifecycle_descendant_parent", "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_FILE, &pid_file); + }); + let fail = FailAfterDescendant { + failure, + hook, + unwrap_child, + pid_file: pid_file.clone(), + guard: Arc::clone(&guard), + }; + if job_first { + command.wrap(JobObject).wrap(fail); + } else { + command.wrap(fail).wrap(JobObject); + } + + match failure { + Failure::Error => { + let error = command.spawn().expect_err("child wrapping must fail"); + assert_eq!(error.to_string(), "child wrapping failed"); + } + Failure::Panic => { + let panic = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("child wrapping must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "child wrapping failed" + ); + } + } + + let process = guard + .lock() + .unwrap() + .take() + .expect("the failure 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] { + for fail_before_job in [false, true] { + let pid = Arc::new(AtomicU32::new(0)); + let mut command = CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "ping -n 30 127.0.0.1 >NUL"]); + }); + command.wrap(CapturePid(Arc::clone(&pid))); + let fail = FailWrapOnce { + failure, + failed: false, + }; + if fail_before_job { + command.wrap(fail).wrap(JobObject); + } else { + command.wrap(JobObject).wrap(fail); + } + + match failure { + Failure::Error => { + let error = command.spawn().expect_err("the first child wrap must fail"); + assert_eq!(error.to_string(), "child wrapping failed"); + } + Failure::Panic => { + let panic = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("the first child wrap must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "child wrapping failed" + ); + } + } + + let failed_pid = pid.load(Ordering::SeqCst); + assert_ne!(failed_pid, 0); + assert!(process_has_suspended_thread(failed_pid).is_err()); + + let mut child = command.spawn()?; + child.start_kill()?; + let _ = wait_for_exit(child.as_mut())?; + } + } + Ok(()) +} + #[test] fn preserves_flags_and_resumes_in_both_orders() -> Result<()> { let flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; diff --git a/tests/std_windows/process_handle.rs b/tests/std_windows/process_handle.rs index bedd287..54f2af1 100644 --- a/tests/std_windows/process_handle.rs +++ b/tests/std_windows/process_handle.rs @@ -1,7 +1,7 @@ use std::{ any::TypeId, os::windows::{ - io::{AsRawHandle, BorrowedHandle}, + io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}, process::ExitStatusExt, }, process::{Command, ExitStatus}, @@ -13,6 +13,20 @@ use std::{ use super::prelude::*; +#[link(name = "kernel32")] +unsafe extern "system" { + fn TerminateProcess(process: *mut std::ffi::c_void, exit_code: u32) -> i32; + fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; +} + +fn terminate_and_wait(process: &OwnedHandle) { + let raw = process.as_raw_handle(); + // SAFETY: the transaction owns this process handle until both calls return. + let _ = unsafe { TerminateProcess(raw, 1) }; + // SAFETY: the process handle remains live for the duration of this call. + let _ = unsafe { WaitForSingleObject(raw, u32::MAX) }; +} + #[derive(Debug)] struct OpaqueChild { inner_calls: Arc, @@ -121,6 +135,111 @@ impl ChildWrapper for LegacyTransparentChild { } } +#[derive(Debug)] +struct ExactResumeChild { + child: std::process::Child, + resumes: Arc, +} + +impl ChildWrapper for ExactResumeChild { + 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.child.as_handle()) + } + + fn resume_after_job_assignment(&mut self) -> Option> { + self.resumes.fetch_add(1, Ordering::SeqCst); + Some(Ok(())) + } + + fn id(&self) -> u32 { + self.child.id() + } + + fn start_kill(&mut self) -> Result<()> { + self.child.kill() + } + + fn try_wait(&mut self) -> Result> { + self.child.try_wait() + } + + fn wait(&mut self) -> Result { + self.child.wait() + } +} + +#[derive(Debug)] +struct CommitAfterResume { + resumes: Arc, + commits: Arc, + process: Option, +} + +impl SpawnTransaction for CommitAfterResume { + fn commit(&mut self) -> Result<()> { + assert_eq!(self.resumes.load(Ordering::SeqCst), 1); + self.commits.fetch_add(1, Ordering::SeqCst); + self.process.take(); + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + if let Some(process) = self.process.take() { + terminate_and_wait(&process); + } + Ok(()) + } +} + +#[derive(Debug)] +struct ProcessProvider { + resumes: Arc, + commits: Arc, +} + +impl SpawnProvider for ProcessProvider { + fn spawn(&self, attempt: &mut SpawnAttempt, _command: &CommandWrap) -> Result { + assert!(!attempt.is_native_only()); + let policy = attempt.windows_spawn_policy(); + assert!(policy.has_job_object()); + assert!(policy.is_temporarily_suspended()); + let child = sleeping_command().spawn()?; + let process = child.as_handle().try_clone_to_owned()?; + Ok(ProviderProduct::new( + Box::new(ExactResumeChild { + child, + resumes: Arc::clone(&self.resumes), + }), + Box::new(CommitAfterResume { + resumes: Arc::clone(&self.resumes), + commits: Arc::clone(&self.commits), + process: Some(process), + }), + )) + } +} + +#[derive(Debug)] +struct ProviderWrapper(ProcessProvider); + +impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + #[derive(Debug)] struct LegacyTransparent; @@ -235,6 +354,51 @@ fn job_object_falls_back_through_a_legacy_transparent_child() -> Result<()> { Ok(()) } +#[test] +fn job_object_finds_terminal_capabilities_below_multiple_legacy_layers() -> Result<()> { + let resumes = Arc::new(AtomicUsize::new(0)); + let terminal: Box = Box::new(ExactResumeChild { + child: sleeping_command().spawn()?, + resumes: Arc::clone(&resumes), + }); + let child: Box = Box::new(LegacyTransparentChild { + inner: Box::new(LegacyTransparentChild { inner: terminal }), + }); + let core = CommandWrap::new("cmd.exe"); + let mut child = JobObject.wrap_child(child, &core)?; + + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert!(child.process_handle().is_some()); + child.start_kill()?; + let _ = child.wait()?; + Ok(()) +} + +#[test] +fn provider_job_assignment_precedes_commit_in_both_orders() -> Result<()> { + for provider_first in [false, true] { + let resumes = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let provider = ProviderWrapper(ProcessProvider { + resumes: Arc::clone(&resumes), + commits: Arc::clone(&commits), + }); + let mut command = CommandWrap::new("provider-owned-program"); + if provider_first { + command.wrap(provider).wrap(JobObject); + } else { + command.wrap(JobObject).wrap(provider); + } + + let mut child = command.spawn()?; + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert_eq!(commits.load(Ordering::SeqCst), 1); + child.start_kill()?; + let _ = child.wait()?; + } + Ok(()) +} + #[test] fn job_object_falls_back_through_a_legacy_inline_child() -> Result<()> { let mut command = sleeping_command_wrap(); diff --git a/tests/support/windows_thread.rs b/tests/support/windows_thread.rs index 74de8c4..91f9a22 100644 --- a/tests/support/windows_thread.rs +++ b/tests/support/windows_thread.rs @@ -2,13 +2,16 @@ use std::io::{Error, Result}; use windows::{ Win32::{ - Foundation::{CloseHandle, ERROR_NO_MORE_FILES, HANDLE}, + Foundation::{CloseHandle, ERROR_NO_MORE_FILES, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}, System::{ Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, }, - Threading::{OpenThread, ResumeThread, SuspendThread, THREAD_SUSPEND_RESUME}, + Threading::{ + OpenProcess, OpenThread, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, ResumeThread, + SuspendThread, THREAD_SUSPEND_RESUME, TerminateProcess, WaitForSingleObject, + }, }, }, core::HRESULT, @@ -22,6 +25,87 @@ impl Drop for OwnedHandle { } } +#[derive(Debug)] +pub struct ProcessGuard(Option); + +// Windows process handles may be used and closed from any thread while this guard preserves unique +// ownership of the handle value. +unsafe impl Send for ProcessGuard {} + +impl ProcessGuard { + pub fn open(pid: u32) -> Result { + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } + .map_err(Error::other)?; + Ok(Self(Some(handle))) + } + + pub fn has_exited(&self) -> Result { + let wait = unsafe { WaitForSingleObject(self.handle(), 0) }; + if wait == WAIT_FAILED { + Err(Error::last_os_error()) + } else { + Ok(wait == WAIT_OBJECT_0) + } + } + + pub fn disarm(mut self) -> Result<()> { + if let Some(handle) = self.0.take() { + unsafe { CloseHandle(handle) }?; + } + Ok(()) + } + + fn handle(&self) -> HANDLE { + self.0 + .expect("only ProcessGuard::disarm clears the handle, and it consumes the guard") + } +} + +impl Drop for ProcessGuard { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + unsafe { TerminateProcess(handle, 1) }.ok(); + unsafe { CloseHandle(handle) }.ok(); + } + } +} + +pub fn resume_process_threads(pid: u32) -> Result<()> { + let snapshot = OwnedHandle(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?); + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() + .try_into() + .expect("THREADENTRY32 is guaranteed to fit in a DWORD"), + ..Default::default() + }; + unsafe { Thread32First(snapshot.0, &mut entry) }.map_err(Error::other)?; + + let mut found = false; + loop { + if entry.th32OwnerProcessID == pid { + found = true; + let thread = OwnedHandle(unsafe { + OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID) + }?); + if unsafe { ResumeThread(thread.0) } == u32::MAX { + return Err(Error::last_os_error()); + } + } + + match unsafe { Thread32Next(snapshot.0, &mut entry) } { + Ok(()) => {} + Err(error) if error.code() == HRESULT::from_win32(ERROR_NO_MORE_FILES.0) => break, + Err(error) => return Err(Error::other(error)), + } + } + + if found { + Ok(()) + } else { + Err(Error::other("no thread belonging to the child was found")) + } +} + pub fn process_has_suspended_thread(pid: u32) -> Result { let snapshot = OwnedHandle(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?); let mut entry = THREADENTRY32 { diff --git a/tests/tokio_unix/mod.rs b/tests/tokio_unix/mod.rs index 13d5480..3ce63b2 100644 --- a/tests/tokio_unix/mod.rs +++ b/tests/tokio_unix/mod.rs @@ -3,13 +3,16 @@ mod prelude { pub use nix::sys::signal::Signal; pub use process_wrap::tokio::*; + #[cfg(all(target_os = "linux", feature = "process-group"))] + pub use tokio::io::{AsyncBufReadExt, BufReader}; pub use tokio::{ - io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + io::{AsyncReadExt, AsyncWriteExt}, time::sleep, }; pub const DIE_TIME: Duration = Duration::from_millis(100); + #[cfg(all(target_os = "linux", feature = "process-group"))] #[track_caller] pub fn pid_alive(pid: i32) -> bool { #[inline] @@ -26,6 +29,7 @@ mod id_same_as_inner; mod inner_read_stdout; mod into_inner_write_stdin; mod kill_and_try_wait; +#[cfg(all(target_os = "linux", feature = "process-group"))] mod multiproc_linux; mod signals; mod try_wait_after_die; diff --git a/tests/tokio_windows/creation_flags_job_object.rs b/tests/tokio_windows/creation_flags_job_object.rs index 1b53217..bfd161b 100644 --- a/tests/tokio_windows/creation_flags_job_object.rs +++ b/tests/tokio_windows/creation_flags_job_object.rs @@ -1,6 +1,13 @@ use std::{ + fs, io::{Error, ErrorKind}, - process::ExitStatus, + panic::{AssertUnwindSafe, catch_unwind}, + path::PathBuf, + process::{Command as StdCommand, ExitStatus}, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, + }, time::Instant, }; @@ -8,9 +15,14 @@ use windows::Win32::System::Threading::{ CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, PROCESS_CREATION_FLAGS, }; -use super::{prelude::*, windows_thread::process_has_suspended_thread}; +use super::{ + prelude::*, + windows_thread::{ProcessGuard, process_has_suspended_thread, resume_process_threads}, +}; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); +const DESCENDANT_PID_FILE: &str = "PROCESS_WRAP_DESCENDANT_PID_FILE"; +static PID_FILE_SEQUENCE: AtomicU32 = AtomicU32::new(0); #[derive(Clone, Copy)] enum Order { @@ -18,6 +30,274 @@ enum Order { JobObjectFirst, } +#[derive(Clone, Copy, Debug)] +struct ExpectedPolicy { + user_flags: u32, + spawn_flags: u32, + has_creation_flags: bool, + has_job_object: bool, + explicit_suspension: bool, + temporary_suspension: bool, + kill_on_drop: bool, +} + +#[derive(Debug)] +struct InspectProvider(ExpectedPolicy); + +impl SpawnProvider for InspectProvider { + fn validate_attempt(&self, attempt: &SpawnAttempt, _command: &CommandWrap) -> Result<()> { + assert!(!attempt.is_native_only()); + assert_eq!(attempt.kills_on_drop(), self.0.kill_on_drop); + let policy = attempt.windows_spawn_policy(); + assert_eq!(policy.user_creation_flags(), self.0.user_flags); + assert_eq!(policy.spawn_creation_flags(), self.0.spawn_flags); + assert_eq!(policy.has_creation_flags(), self.0.has_creation_flags); + assert_eq!(policy.has_job_object(), self.0.has_job_object); + assert_eq!(policy.is_explicitly_suspended(), self.0.explicit_suspension); + assert_eq!( + policy.is_temporarily_suspended(), + self.0.temporary_suspension + ); + assert_eq!(policy.kills_on_drop(), self.0.kill_on_drop); + Err(Error::other("policy inspected")) + } + + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + unreachable!("attempt validation stops before provider allocation") + } +} + +#[derive(Debug)] +struct InspectPolicy(InspectProvider); + +impl InspectPolicy { + fn new(expected: ExpectedPolicy) -> Self { + Self(InspectProvider(expected)) + } +} + +impl CommandWrapper for InspectPolicy { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + +#[derive(Clone, Copy, Debug)] +enum Failure { + Error, + Panic, +} + +#[derive(Debug)] +struct CapturePid(Arc); + +impl CommandWrapper for CapturePid { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _core: &CommandWrap, + ) -> Result<()> { + self.0.store( + child + .id() + .expect("a newly spawned child exposes its process ID before it is reaped"), + Ordering::SeqCst, + ); + Ok(()) + } +} + +#[derive(Debug)] +struct FailWrapOnce { + failure: Failure, + failed: bool, +} + +impl CommandWrapper for FailWrapOnce { + fn wrap_child( + &mut self, + child: Box, + _core: &CommandWrap, + ) -> Result> { + if self.failed { + return Ok(child); + } + self.failed = true; + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FailureHook { + PostSpawn, + WrapChild, + FinalizeSpawn, + DisarmSpawnCleanup, +} + +#[derive(Debug)] +struct FailFinalizationChild { + inner: Box, + failure: Failure, + hook: FailureHook, +} + +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) -> Result<()> { + if self.hook == FailureHook::FinalizeSpawn { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } + + fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { + if self.hook == FailureHook::DisarmSpawnCleanup { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } +} + +#[derive(Debug)] +struct FailAfterDescendant { + failure: Failure, + hook: FailureHook, + unwrap_child: bool, + pid_file: PathBuf, + guard: Arc>>, +} + +impl FailAfterDescendant { + fn observe_descendant(&self) -> Result<()> { + 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(()) + } + + fn fail(&self) -> Result { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } +} + +fn child_id(child: &dyn ChildWrapper) -> Result { + child + .id() + .ok_or_else(|| Error::other("a newly spawned child must expose its process ID")) +} + +impl CommandWrapper for FailAfterDescendant { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _core: &CommandWrap, + ) -> Result<()> { + if self.hook != FailureHook::PostSpawn { + return Ok(()); + } + resume_process_threads(child_id(child)?)?; + self.observe_descendant()?; + self.fail() + } + + fn wrap_child( + &mut self, + child: Box, + _core: &CommandWrap, + ) -> Result> { + if self.hook == FailureHook::PostSpawn { + return Ok(child); + } + resume_process_threads(child_id(child.as_ref())?)?; + self.observe_descendant()?; + if matches!( + self.hook, + FailureHook::FinalizeSpawn | FailureHook::DisarmSpawnCleanup + ) { + return Ok(Box::new(FailFinalizationChild { + inner: child, + failure: self.failure, + hook: self.hook, + })); + } + if self.unwrap_child { + drop(child.into_inner()); + } else { + drop(child); + } + self.fail() + } +} + +fn descendant_pid_file() -> PathBuf { + std::env::temp_dir().join(format!( + "process-wrap-{}-{}.pid", + std::process::id(), + PID_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) +} + +async fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "descendant survived the failed spawn lifecycle", + )); + } + sleep(Duration::from_millis(10)).await; + } +} + fn command(flags: PROCESS_CREATION_FLAGS, order: Order) -> CommandWrap { let mut command = CommandWrap::with_new("cmd.exe", |command| { command.args(["/D", "/S", "/C", "exit /b 0"]); @@ -47,6 +327,210 @@ async fn wait_for_exit(child: &mut dyn ChildWrapper) -> Result { } } +fn assert_policy(mut command: CommandWrap, expected: ExpectedPolicy) { + command.wrap(InspectPolicy::new(expected)); + let error = command + .spawn() + .expect_err("policy inspection must stop before provider allocation"); + assert_eq!(error.to_string(), "policy inspected"); +} + +#[test] +fn portable_policy_covers_flags_jobs_suspension_and_kill_on_drop() { + let flags = (CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW).0; + let explicit = (CREATE_NO_WINDOW | CREATE_SUSPENDED).0; + + let mut flags_only = CommandWrap::new("cmd.exe"); + flags_only + .wrap(CreationFlags(PROCESS_CREATION_FLAGS(flags))) + .wrap(KillOnDrop); + assert_policy( + flags_only, + ExpectedPolicy { + user_flags: flags, + spawn_flags: flags, + has_creation_flags: true, + has_job_object: false, + explicit_suspension: false, + temporary_suspension: false, + kill_on_drop: true, + }, + ); + + let mut job_only = CommandWrap::new("cmd.exe"); + job_only.wrap(JobObject); + assert_policy( + job_only, + ExpectedPolicy { + user_flags: 0, + spawn_flags: CREATE_SUSPENDED.0, + has_creation_flags: false, + has_job_object: true, + explicit_suspension: false, + temporary_suspension: true, + kill_on_drop: false, + }, + ); + + for order in [Order::CreationFlagsFirst, Order::JobObjectFirst] { + let mut temporary = command(PROCESS_CREATION_FLAGS(flags), order); + temporary.wrap(KillOnDrop); + assert_policy( + temporary, + ExpectedPolicy { + user_flags: flags, + spawn_flags: flags | CREATE_SUSPENDED.0, + has_creation_flags: true, + has_job_object: true, + explicit_suspension: false, + temporary_suspension: true, + kill_on_drop: true, + }, + ); + + assert_policy( + command(PROCESS_CREATION_FLAGS(explicit), order), + ExpectedPolicy { + user_flags: explicit, + spawn_flags: explicit, + has_creation_flags: true, + has_job_object: true, + explicit_suspension: true, + temporary_suspension: false, + kill_on_drop: false, + }, + ); + } +} + +#[test] +#[ignore = "subprocess helper"] +fn lifecycle_descendant_leaf() { + std::thread::sleep(Duration::from_secs(300)); +} + +#[test] +#[ignore = "subprocess helper"] +fn lifecycle_descendant_parent() { + let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) + .args(["lifecycle_descendant_leaf", "--ignored", "--nocapture"]) + .spawn() + .unwrap(); + fs::write( + std::env::var_os(DESCENDANT_PID_FILE).unwrap(), + descendant.id().to_string(), + ) + .unwrap(); + descendant.wait().unwrap(); +} + +#[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), + ]; + for failure in [Failure::Error, Failure::Panic] { + for (hook, job_first, unwrap_child) 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| { + command + .args(["lifecycle_descendant_parent", "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_FILE, &pid_file); + }); + let fail = FailAfterDescendant { + failure, + hook, + unwrap_child, + pid_file: pid_file.clone(), + guard: Arc::clone(&guard), + }; + if job_first { + command.wrap(JobObject).wrap(fail); + } else { + command.wrap(fail).wrap(JobObject); + } + + match failure { + Failure::Error => { + let error = command.spawn().expect_err("child wrapping must fail"); + assert_eq!(error.to_string(), "child wrapping failed"); + } + Failure::Panic => { + let panic = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("child wrapping must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "child wrapping failed" + ); + } + } + + let process = guard + .lock() + .unwrap() + .take() + .expect("the failure hook opened the descendant process"); + wait_for_process_exit(process).await?; + fs::remove_file(pid_file)?; + } + } + Ok(()) +} + +#[tokio::test] +async fn suspended_native_children_are_killed_after_later_failures() -> Result<()> { + for failure in [Failure::Error, Failure::Panic] { + for fail_before_job in [false, true] { + let pid = Arc::new(AtomicU32::new(0)); + let mut command = CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "ping -n 30 127.0.0.1 >NUL"]); + }); + command.wrap(CapturePid(Arc::clone(&pid))); + let fail = FailWrapOnce { + failure, + failed: false, + }; + if fail_before_job { + command.wrap(fail).wrap(JobObject); + } else { + command.wrap(JobObject).wrap(fail); + } + + match failure { + Failure::Error => { + let error = command.spawn().expect_err("the first child wrap must fail"); + assert_eq!(error.to_string(), "child wrapping failed"); + } + Failure::Panic => { + let panic = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("the first child wrap must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "child wrapping failed" + ); + } + } + + let failed_pid = pid.load(Ordering::SeqCst); + assert_ne!(failed_pid, 0); + assert!(process_has_suspended_thread(failed_pid).is_err()); + + let mut child = command.spawn()?; + child.start_kill()?; + let _ = wait_for_exit(child.as_mut()).await?; + } + } + Ok(()) +} + #[tokio::test] async fn preserves_flags_and_resumes_in_both_orders() -> Result<()> { let flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; diff --git a/tests/tokio_windows/process_handle.rs b/tests/tokio_windows/process_handle.rs index de5391c..7a7bb9b 100644 --- a/tests/tokio_windows/process_handle.rs +++ b/tests/tokio_windows/process_handle.rs @@ -1,6 +1,9 @@ use std::{ any::TypeId, - os::windows::io::{AsRawHandle, BorrowedHandle}, + future::Future, + os::windows::io::{AsRawHandle, BorrowedHandle, OwnedHandle}, + pin::Pin, + process::ExitStatus, sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -9,6 +12,20 @@ use std::{ use super::prelude::*; +#[link(name = "kernel32")] +unsafe extern "system" { + fn TerminateProcess(process: *mut std::ffi::c_void, exit_code: u32) -> i32; + fn WaitForSingleObject(handle: *mut std::ffi::c_void, milliseconds: u32) -> u32; +} + +fn terminate_and_wait(process: &OwnedHandle) { + let raw = process.as_raw_handle(); + // SAFETY: the transaction owns this process handle until both calls return. + let _ = unsafe { TerminateProcess(raw, 1) }; + // SAFETY: the process handle remains live for the duration of this call. + let _ = unsafe { WaitForSingleObject(raw, u32::MAX) }; +} + #[derive(Debug)] struct OpaqueChild { inner_calls: Arc, @@ -103,6 +120,117 @@ impl ChildWrapper for LegacyTransparentChild { } } +#[derive(Debug)] +struct ExactResumeChild { + child: tokio::process::Child, + resumes: Arc, +} + +impl ChildWrapper for ExactResumeChild { + 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> { + let handle = self.child.raw_handle()?; + // SAFETY: the child owns this handle and the returned borrow cannot outlive `self`. + Some(unsafe { BorrowedHandle::borrow_raw(handle) }) + } + + fn resume_after_job_assignment(&mut self) -> Option> { + self.resumes.fetch_add(1, Ordering::SeqCst); + Some(Ok(())) + } + + fn id(&self) -> Option { + self.child.id() + } + + fn start_kill(&mut self) -> Result<()> { + self.child.start_kill() + } + + fn try_wait(&mut self) -> Result> { + self.child.try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + Box::pin(self.child.wait()) + } +} + +#[derive(Debug)] +struct CommitAfterResume { + resumes: Arc, + commits: Arc, + process: Option, +} + +impl SpawnTransaction for CommitAfterResume { + fn commit(&mut self) -> Result<()> { + assert_eq!(self.resumes.load(Ordering::SeqCst), 1); + self.commits.fetch_add(1, Ordering::SeqCst); + self.process.take(); + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + if let Some(process) = self.process.take() { + terminate_and_wait(&process); + } + Ok(()) + } +} + +#[derive(Debug)] +struct ProcessProvider { + resumes: Arc, + commits: Arc, +} + +impl SpawnProvider for ProcessProvider { + fn spawn(&self, attempt: &mut SpawnAttempt, _command: &CommandWrap) -> Result { + assert!(!attempt.is_native_only()); + let policy = attempt.windows_spawn_policy(); + assert!(policy.has_job_object()); + assert!(policy.is_temporarily_suspended()); + let child = sleeping_command().spawn()?; + let handle = child + .raw_handle() + .ok_or_else(|| std::io::Error::other("spawned child has no process handle"))?; + // SAFETY: `child` owns this handle until it is moved into `ExactResumeChild` below. + let process = unsafe { BorrowedHandle::borrow_raw(handle) }.try_clone_to_owned()?; + Ok(ProviderProduct::new( + Box::new(ExactResumeChild { + child, + resumes: Arc::clone(&self.resumes), + }), + Box::new(CommitAfterResume { + resumes: Arc::clone(&self.resumes), + commits: Arc::clone(&self.commits), + process: Some(process), + }), + )) + } +} + +#[derive(Debug)] +struct ProviderWrapper(ProcessProvider); + +impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + #[derive(Debug)] struct LegacyTransparent; @@ -216,6 +344,51 @@ async fn job_object_falls_back_through_a_legacy_transparent_child() -> Result<() Ok(()) } +#[tokio::test] +async fn job_object_finds_terminal_capabilities_below_multiple_legacy_layers() -> Result<()> { + let resumes = Arc::new(AtomicUsize::new(0)); + let terminal: Box = Box::new(ExactResumeChild { + child: sleeping_command().spawn()?, + resumes: Arc::clone(&resumes), + }); + let child: Box = Box::new(LegacyTransparentChild { + inner: Box::new(LegacyTransparentChild { inner: terminal }), + }); + let core = CommandWrap::new("cmd.exe"); + let mut child = JobObject.wrap_child(child, &core)?; + + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert!(child.process_handle().is_some()); + child.start_kill()?; + let _ = child.wait().await?; + Ok(()) +} + +#[tokio::test] +async fn provider_job_assignment_precedes_commit_in_both_orders() -> Result<()> { + for provider_first in [false, true] { + let resumes = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let provider = ProviderWrapper(ProcessProvider { + resumes: Arc::clone(&resumes), + commits: Arc::clone(&commits), + }); + let mut command = CommandWrap::new("provider-owned-program"); + if provider_first { + command.wrap(provider).wrap(JobObject); + } else { + command.wrap(JobObject).wrap(provider); + } + + let mut child = command.spawn()?; + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert_eq!(commits.load(Ordering::SeqCst), 1); + child.start_kill()?; + let _ = child.wait().await?; + } + Ok(()) +} + #[tokio::test] async fn job_object_falls_back_through_a_legacy_inline_child() -> Result<()> { let mut command = sleeping_command_wrap(); diff --git a/tests/unix_attempt_policy.rs b/tests/unix_attempt_policy.rs new file mode 100644 index 0000000..458601a --- /dev/null +++ b/tests/unix_attempt_policy.rs @@ -0,0 +1,510 @@ +#![cfg(all( + unix, + any(feature = "std", feature = "tokio1"), + feature = "process-group", + feature = "process-session", + feature = "reset-sigmask" +))] + +macro_rules! unix_attempt_policy_tests { + ( + $module:ident, + $command_wrap:path, + $spawn_attempt:path, + $command_wrapper:path, + $child_wrapper:path, + $process_group:path, + $process_group_child:path, + $process_group_target:path, + $process_session:path, + $reset_sigmask:path, + $replace_native:expr, + $child_id:expr, + $runtime:expr + ) => { + mod $module { + use std::{ + any::Any, + io, + os::unix::process::CommandExt, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + process::{Command as NativeCommand, ExitStatus, Stdio}, + thread::sleep, + time::{Duration, Instant}, + }; + + use nix::{ + sys::signal::{Signal, killpg}, + unistd::{Pid, getpgid}, + }; + use $child_wrapper as ChildWrapper; + use $command_wrap as CommandWrap; + use $command_wrapper as CommandWrapper; + use $process_group as ProcessGroup; + use $process_group_child as ProcessGroupChild; + use $process_group_target as ProcessGroupTarget; + use $process_session as ProcessSession; + use $reset_sigmask as ResetSigmask; + use $spawn_attempt as SpawnAttempt; + + const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + + #[derive(Clone, Copy, Debug)] + enum Failure { + Error, + Panic, + } + + #[derive(Debug)] + struct FailAfterNativeAccess { + failure: Failure, + failed: bool, + } + + impl CommandWrapper for FailAfterNativeAccess { + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result<()> { + if self.failed { + return Ok(()); + } + + let _ = attempt.native_mut(); + self.failed = true; + match self.failure { + Failure::Error => Err(io::Error::other("fail after native access")), + Failure::Panic => panic_any("fail after native access"), + } + } + } + + #[derive(Debug)] + struct AccessNative; + + impl CommandWrapper for AccessNative { + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result<()> { + let _ = attempt.native_mut(); + Ok(()) + } + } + + #[derive(Debug)] + struct InspectPortable; + + impl CommandWrapper for InspectPortable { + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result<()> { + assert!(!attempt.is_native_only()); + assert_eq!(attempt.process_group_target(), None); + assert!(attempt.creates_process_session()); + assert!(attempt.resets_sigmask()); + Ok(()) + } + } + + #[derive(Debug)] + struct InspectGroup(ProcessGroupTarget); + + impl CommandWrapper for InspectGroup { + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result<()> { + assert_eq!(attempt.process_group_target(), Some(self.0)); + assert!(!attempt.creates_process_session()); + assert!(!attempt.resets_sigmask()); + Ok(()) + } + } + + #[derive(Debug)] + struct ReplaceNative; + + impl CommandWrapper for ReplaceNative { + fn pre_spawn( + &mut self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result<()> { + ($replace_native)(attempt.native_mut()); + Ok(()) + } + } + + #[derive(Debug)] + struct ExternalGroup { + child: std::process::Child, + pgid: Pid, + } + + impl ExternalGroup { + fn spawn() -> Self { + let mut command = NativeCommand::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); + let child = command.spawn().unwrap(); + let pgid = Pid::from_raw(i32::try_from(child.id()).unwrap()); + Self { child, pgid } + } + + fn kill(&self) { + let _ = killpg(self.pgid, Signal::SIGKILL); + } + } + + impl Drop for ExternalGroup { + fn drop(&mut self) { + self.kill(); + let _ = self.child.wait(); + } + } + + fn runtime() -> Option { + $runtime + } + + fn fail(failure: Failure, message: &'static str) -> io::Result { + match failure { + Failure::Error => Err(io::Error::other(message)), + Failure::Panic => panic_any(message), + } + } + + fn command_with_exit(code: i32) -> CommandWrap { + CommandWrap::with_new("sh", |command| { + command.args(["-c", &format!("exit {code}")]); + }) + } + + fn child_id(child: &dyn ChildWrapper) -> u32 { + ($child_id)(child) + } + + fn wait_for_exit(child: &mut dyn ChildWrapper) -> ExitStatus { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!( + Instant::now() < deadline, + "child did not exit before timeout" + ); + sleep(Duration::from_millis(10)); + } + } + + #[test] + fn native_only_session_command_is_reusable() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + let _ = command.native_mut(); + command.wrap(ProcessSession); + + for _ in 0..3 { + let mut child = command.spawn().unwrap(); + assert!(wait_for_exit(child.as_mut()).success()); + } + } + + #[test] + fn tracked_dispatcher_is_attached_once_per_attempt() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + command.wrap(ProcessSession).wrap(AccessNative); + + for _ in 0..3 { + let mut child = command.spawn().unwrap(); + assert!(wait_for_exit(child.as_mut()).success()); + } + } + + #[test] + fn child_setup_survives_native_command_replacement() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + command.wrap(ProcessSession).wrap(ReplaceNative); + + let mut child = command.spawn().unwrap(); + child.start_kill().unwrap(); + let _ = wait_for_exit(child.as_mut()); + } + + #[test] + fn native_only_dispatcher_recovers_after_errors_and_panics() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for failure in [Failure::Error, Failure::Panic] { + let mut command = command_with_exit(0); + let _ = command.native_mut(); + command.wrap(ProcessSession).wrap(FailAfterNativeAccess { + failure, + failed: false, + }); + + match failure { + Failure::Error => assert_eq!( + command.spawn().unwrap_err().to_string(), + "fail after native access" + ), + Failure::Panic => { + let panic = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("the first spawn must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "fail after native access" + ); + } + } + + for _ in 0..2 { + let mut child = command.spawn().unwrap(); + assert!(wait_for_exit(child.as_mut()).success()); + } + } + } + + #[test] + fn explicit_native_replacement_forces_dispatcher_reinstallation() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for boxed_child in [false, true] { + for failure in [Failure::Error, Failure::Panic] { + let mut command = command_with_exit(0); + let _ = command.native_mut(); + command.wrap(ProcessGroup::leader()); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + if boxed_child { + command.spawn_with_child(|native| { + ($replace_native)(native); + fail(failure, "explicit spawner failed") + }) + } else { + command.spawn_with(|native| { + ($replace_native)(native); + fail(failure, "explicit spawner failed") + }) + } + })); + match failure { + Failure::Error => assert_eq!( + outcome.unwrap().unwrap_err().to_string(), + "explicit spawner failed" + ), + Failure::Panic => assert_eq!( + *outcome + .expect_err("the explicit spawner must panic") + .downcast::<&'static str>() + .unwrap(), + "explicit spawner failed" + ), + } + + let mut child = command.spawn().unwrap(); + let pid = Pid::from_raw(i32::try_from(child_id(child.as_ref())).unwrap()); + assert_eq!(getpgid(Some(pid)).unwrap(), pid); + child.start_kill().unwrap(); + let _ = wait_for_exit(child.as_mut()); + } + } + } + + #[test] + fn explicit_replacement_after_spawn_preserves_current_and_future_setup() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for boxed_child in [false, true] { + let mut command = CommandWrap::with_new("sh", |command| { + command.args(["-c", "sleep 30"]); + }); + let _ = command.native_mut(); + command.wrap(ProcessGroup::leader()); + + let mut child = if boxed_child { + command + .spawn_with_child(|native| { + let child = native.spawn()?; + ($replace_native)(native); + Ok(Box::new(child) as Box) + }) + .unwrap() + } else { + command + .spawn_with(|native| { + let child = native.spawn()?; + ($replace_native)(native); + Ok(child) + }) + .unwrap() + }; + let pid = Pid::from_raw(i32::try_from(child_id(child.as_ref())).unwrap()); + assert_eq!(getpgid(Some(pid)).unwrap(), pid); + child.start_kill().unwrap(); + let _ = wait_for_exit(child.as_mut()); + + let mut child = command.spawn().unwrap(); + let pid = Pid::from_raw(i32::try_from(child_id(child.as_ref())).unwrap()); + assert_eq!(getpgid(Some(pid)).unwrap(), pid); + child.start_kill().unwrap(); + let _ = wait_for_exit(child.as_mut()); + } + } + + #[test] + fn built_in_policy_hooks_keep_the_attempt_portable() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + command + .wrap(ProcessSession) + .wrap(ResetSigmask) + .wrap(InspectPortable); + + let mut child = command.spawn().unwrap(); + assert!(wait_for_exit(child.as_mut()).success()); + } + + #[test] + fn process_group_policy_is_visible_without_native_state() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + command + .wrap(ProcessGroup::leader()) + .wrap(InspectGroup(ProcessGroupTarget::Leader)); + + let mut child = command.spawn().unwrap(); + assert!(wait_for_exit(child.as_mut()).success()); + } + + #[test] + fn attach_to_tracks_the_direct_pid_and_actual_group() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let external = ExternalGroup::spawn(); + let pgid = u32::try_from(external.pgid.as_raw()).unwrap(); + let mut command = command_with_exit(7); + command + .wrap(ProcessGroup::attach_to(pgid)) + .wrap(InspectGroup(ProcessGroupTarget::AttachTo(pgid))); + + let mut child = command.spawn().unwrap(); + let direct_pid = child_id(child.as_ref()); + assert_ne!(direct_pid, pgid); + let group_child = (child.as_ref() as &dyn Any) + .downcast_ref::() + .expect("ProcessGroup installs its child layer"); + assert_eq!(group_child.pgid(), pgid); + + sleep(Duration::from_millis(200)); + assert_eq!(child.try_wait().unwrap(), None); + external.kill(); + assert_eq!(wait_for_exit(child.as_mut()).code(), Some(7)); + } + + #[test] + fn existing_group_and_new_session_conflict_in_either_order() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for session_first in [false, true] { + let mut command = command_with_exit(0); + if session_first { + command + .wrap(ProcessSession) + .wrap(ProcessGroup::attach_to(1)); + } else { + command + .wrap(ProcessGroup::attach_to(1)) + .wrap(ProcessSession); + } + + let error = command.spawn().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a process cannot join an existing process group and create a new session" + ); + } + } + + #[test] + fn invalid_existing_group_ids_are_rejected_before_spawn() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for (pgid, message) in [ + (0, "an existing process group ID must be positive"), + (u32::MAX, "process group ID exceeds the platform range"), + ] { + let mut command = command_with_exit(0); + command.wrap(ProcessGroup::attach_to(pgid)); + let error = command.spawn().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), message); + } + } + } + }; +} + +#[cfg(feature = "std")] +unix_attempt_policy_tests!( + std_frontend, + process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, + process_wrap::std::CommandWrapper, + process_wrap::std::ChildWrapper, + process_wrap::std::ProcessGroup, + process_wrap::std::ProcessGroupChild, + process_wrap::ProcessGroupTarget, + process_wrap::std::ProcessSession, + process_wrap::std::ResetSigmask, + |command: &mut std::process::Command| { + *command = std::process::Command::new("sh"); + command.args(["-c", "sleep 30"]); + }, + |child: &dyn process_wrap::std::ChildWrapper| child.id(), + None +); + +#[cfg(feature = "tokio1")] +unix_attempt_policy_tests!( + tokio_frontend, + process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, + process_wrap::tokio::CommandWrapper, + process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ProcessGroup, + process_wrap::tokio::ProcessGroupChild, + process_wrap::ProcessGroupTarget, + process_wrap::tokio::ProcessSession, + process_wrap::tokio::ResetSigmask, + |command: &mut tokio::process::Command| { + *command = tokio::process::Command::new("sh"); + command.args(["-c", "sleep 30"]); + }, + |child: &dyn process_wrap::tokio::ChildWrapper| child.id().unwrap(), + Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + ) +); diff --git a/tests/wrapper_hooks.rs b/tests/wrapper_hooks.rs index 359c62c..09e5cca 100644 --- a/tests/wrapper_hooks.rs +++ b/tests/wrapper_hooks.rs @@ -3,9 +3,9 @@ macro_rules! wrapper_hook_tests { ( $module:ident, - $command:path, $child:path, $command_wrap:path, + $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, $runtime:expr @@ -21,9 +21,9 @@ macro_rules! wrapper_hook_tests { use $child as Child; use $child_wrapper as ChildWrapper; - use $command as Command; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $spawn_attempt as SpawnAttempt; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); @@ -57,7 +57,7 @@ macro_rules! wrapper_hook_tests { impl CommandWrapper for First { fn pre_spawn( &mut self, - _command: &mut Command, + _command: &mut SpawnAttempt, core: &CommandWrap, ) -> io::Result<()> { self.observe(Phase::Pre, core); @@ -66,8 +66,8 @@ macro_rules! wrapper_hook_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _command: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, core: &CommandWrap, ) -> io::Result<()> { self.observe(Phase::Post, core); @@ -100,7 +100,7 @@ macro_rules! wrapper_hook_tests { impl CommandWrapper for Second { fn pre_spawn( &mut self, - _command: &mut Command, + _command: &mut SpawnAttempt, core: &CommandWrap, ) -> io::Result<()> { self.observe(Phase::Pre, core); @@ -109,8 +109,8 @@ macro_rules! wrapper_hook_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _command: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, core: &CommandWrap, ) -> io::Result<()> { self.observe(Phase::Post, core); @@ -174,7 +174,7 @@ macro_rules! wrapper_hook_tests { impl CommandWrapper for FailOnce { fn pre_spawn( &mut self, - _command: &mut Command, + _command: &mut SpawnAttempt, core: &CommandWrap, ) -> io::Result<()> { self.visit(Phase::Pre, core) @@ -182,8 +182,8 @@ macro_rules! wrapper_hook_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _command: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, core: &CommandWrap, ) -> io::Result<()> { self.visit(Phase::Post, core) @@ -215,7 +215,7 @@ macro_rules! wrapper_hook_tests { impl CommandWrapper for Peer { fn pre_spawn( &mut self, - _command: &mut Command, + _command: &mut SpawnAttempt, core: &CommandWrap, ) -> io::Result<()> { self.observe(Phase::Pre, core); @@ -224,8 +224,8 @@ macro_rules! wrapper_hook_tests { fn post_spawn( &mut self, - _command: &mut Command, - _child: &mut Child, + _command: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, core: &CommandWrap, ) -> io::Result<()> { self.observe(Phase::Post, core); @@ -422,9 +422,9 @@ macro_rules! wrapper_hook_tests { #[cfg(feature = "std")] wrapper_hook_tests!( std_frontend, - std::process::Command, std::process::Child, process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, None @@ -433,9 +433,9 @@ wrapper_hook_tests!( #[cfg(feature = "tokio1")] wrapper_hook_tests!( tokio_frontend, - tokio::process::Command, tokio::process::Child, process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, Some(