diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0b21f8..5cea43d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,10 +28,14 @@ jobs: name: "Test on ${{ matrix.platform }} with Rust ${{ matrix.toolchain }} (feat: ${{ matrix.features }})" runs-on: "${{ matrix.platform }}-latest" + timeout-minutes: 30 + env: + PROCESS_WRAP_EXPECT_CONPTY: "${{ matrix.platform == 'windows' && '1' || '0' }}" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -41,6 +45,11 @@ jobs: rustup default ${{ matrix.toolchain }} rustup component add clippy + - name: Report expected ConPTY mode + if: matrix.platform == 'windows' + shell: pwsh + run: Write-Host "PROCESS_WRAP_EXPECT_CONPTY=$env:PROCESS_WRAP_EXPECT_CONPTY" + - run: tests/multiproc_helper.rs 1 1 - name: Test defaults plus frontend @@ -50,6 +59,14 @@ jobs: if: matrix.toolchain == 'stable' run: cargo test --locked --no-default-features --features ${{ matrix.features }} --all-targets + - name: Compile PTY README example as a downstream crate + if: matrix.features == 'pty' + run: cargo check --locked --manifest-path tests/readme/pty/Cargo.toml + + - name: Compile Windows README example as a downstream crate + if: matrix.platform == 'windows' && matrix.features == 'pty' + run: cargo check --locked --manifest-path tests/readme/windows/Cargo.toml + - run: cargo clippy --locked --features ${{ matrix.features }} --all-targets no-default-features: @@ -59,6 +76,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -68,6 +86,25 @@ jobs: - run: cargo check --locked --no-default-features --lib + windows-production-features: + name: Check Windows production feature closure + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Configure Rust + run: | + rustup toolchain install --profile minimal --no-self-update stable + rustup default stable + rustup target add x86_64-pc-windows-gnu + + - name: Check Tokio JobObject production library + run: cargo check --locked --target x86_64-pc-windows-gnu --no-default-features --features tokio1,job-object --lib + # Miri deliberately excludes native process creation and FFI. miri-process-free: name: Miri (process-free state and ownership) @@ -76,6 +113,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -101,10 +139,14 @@ jobs: name: "Test all features on ${{ matrix.platform }}" runs-on: "${{ matrix.platform }}-latest" + timeout-minutes: 30 + env: + PROCESS_WRAP_EXPECT_CONPTY: "${{ matrix.platform == 'windows' && '1' || '0' }}" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -114,6 +156,11 @@ jobs: rustup default stable rustup component add clippy + - name: Report expected ConPTY mode + if: matrix.platform == 'windows' + shell: pwsh + run: Write-Host "PROCESS_WRAP_EXPECT_CONPTY=$env:PROCESS_WRAP_EXPECT_CONPTY" + - run: tests/multiproc_helper.rs 1 1 - run: cargo test --locked --all-features --all-targets - run: cargo clippy --locked --all-features --all-targets @@ -125,6 +172,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -163,6 +211,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -184,6 +233,10 @@ jobs: cargo clippy --locked --target "${{ matrix.target }}" --no-default-features --features pty --lib fi + - name: Compile unsupported PTY integration test + if: matrix.target == 'aarch64-apple-ios' + run: cargo check --locked --target aarch64-apple-ios --all-features --test tokio_pty_unsupported + rustdoc: name: Rustdoc runs-on: ubuntu-latest @@ -191,6 +244,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -206,6 +260,9 @@ jobs: - name: Test documentation examples run: cargo test --locked --all-features --doc + - name: Compile PTY README example as a downstream crate + run: cargo check --locked --manifest-path tests/readme/pty/Cargo.toml + package: name: Package contents runs-on: ubuntu-latest @@ -213,6 +270,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust @@ -233,6 +291,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 persist-credentials: false @@ -260,6 +319,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Configure Rust diff --git a/Cargo.toml b/Cargo.toml index 5ff6c4f..c7452cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,10 +44,12 @@ windows = { version = "0.62.2", optional = true } # as process-wrap is carefully designed to never expose windows's types [dev-dependencies] -remoteprocess = "0.5.0" tempfile = { version = "3.20.0", default-features = false } tokio = { version = "1.38.2", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } +[target.'cfg(any(target_os = "freebsd", target_os = "linux", target_os = "macos"))'.dev-dependencies] +remoteprocess = "0.5.0" + [features] default = ["creation-flags", "job-object", "kill-on-drop", "process-group", "process-session", "tracing"] @@ -67,7 +69,13 @@ pty = [ "tokio/net", "dep:windows", "windows/Win32_Globalization", + "windows/Win32_Security", + "windows/Win32_Storage_FileSystem", + "windows/Win32_System_Console", "windows/Win32_System_Environment", + "windows/Win32_System_LibraryLoader", + "windows/Win32_System_Pipes", + "windows/Win32_System_SystemInformation", "windows/Win32_System_Threading", ] diff --git a/README.md b/README.md index efd61b0..254d9af 100644 --- a/README.md +++ b/README.md @@ -89,20 +89,30 @@ dbg!(status); ### or in a pseudo-terminal The non-default `pty` feature enables Tokio PTY transport on Linux, Android, macOS, FreeBSD, -NetBSD 10 and newer, OpenBSD, DragonFly BSD, illumos, and Solaris. It implies `tokio1`, selecting -the Tokio frontend and its terminal dependencies explicitly. +NetBSD 10 and newer, OpenBSD, DragonFly BSD, illumos, Solaris, and Windows 11 24H2 (build 26100) +or Windows Server 2025. The Windows backend uses native ConPTY. That Windows floor is required +because descendant-aware output EOF requires `ReleasePseudoConsole`. The feature implies `tokio1`, +selecting the Tokio frontend and its terminal dependencies explicitly. ```toml [dependencies] process-wrap = { version = "10.0.0", features = ["pty"] } +tokio = { version = "1.38.2", features = ["io-util", "rt"] } ``` ```rust -use process_wrap::tokio::*; +use process_wrap::tokio::{Command, Pty}; use tokio::io::AsyncReadExt; -let mut command = Command::new("ls"); -command.wrap(ProcessSession).wrap(Pty::default()); +#[cfg(unix)] +let mut command = Command::with_new("sh", |command| { + command.args(["-c", "printf terminal"]); +}); +#[cfg(windows)] +let mut command = Command::with_new("cmd.exe", |command| { + command.args(["/d", "/s", "/c", "echo terminal"]); +}); +command.wrap(Pty::default()); let mut child = command.spawn()?; let controller = child .take_pty_controller() @@ -120,12 +130,34 @@ let terminal_bytes = drain.await??; dbg!(status, terminal_bytes); ``` +On every supported platform, including Windows, register `Pty` with the same +`Command::wrap(Pty::default()).spawn()` API and take the same `PtyController`. The controller has +the ownership and lifecycle contract described below on Unix and Windows alike. To select a PTY +conditionally, use `Pty::check_supported()` for a capability result or `Pty::is_supported()` for a +boolean. These report platform and runtime capability only; they do not suppress later +configuration, compatibility, or spawn errors. + +#### Migrating from the former PTY prototype + +Move command and terminal configuration to the shared Tokio `Command` and `Pty` values. Register +that `Pty` as the spawn provider with `.wrap(Pty::default())` (or `.wrap(configured_pty)`), then use +ordinary `.spawn()` and its ordinary boxed-child result. Call `take_pty_controller()` once on that +returned child to obtain terminal I/O and resize control. + +On Windows, the ConPTY provider rejects direct `.bat` and `.cmd` programs case-insensitively. Invoke +`cmd.exe` explicitly with arguments such as `/d /s /c ...` when shell or batch semantics are required. +The provider resolves executable entries itself and reports an unresolved bare program as `NotFound`; +it does not perform implicit shell or `PATHEXT` dispatch. + A PTY has one ordered terminal stream, so standard output and standard error are merged. -`PtyInput` and `PtyOutput` are strong owners of one bidirectional master descriptor, so dropping -either one alone does not half-close the terminal. The terminal hangs up after both are gone; -`PtyResize` is weak and cannot keep it alive. Send the terminal's VEOF character when that is the -desired terminal policy instead of expecting a separate input half-close or clonable force-close -handle. +`PtyInput` and `PtyOutput` are strong owners of one logical bidirectional controller, so dropping +either one alone does not release its master ownership. After both are gone, a valid request through +the weak `PtyResize` handle returns `BrokenPipe`. On Windows, final strong-owner loss schedules +`ClosePseudoConsole` on a detached worker. If that worker cannot be created, process-wrap +intentionally retains the HPCON rather than potentially blocking the dropping thread, so logical +controller closure does not guarantee OS pseudoconsole teardown. Send the terminal's VEOF character +when that is the desired terminal policy instead of expecting a separate input half-close or clonable +force-close handle. Child waiting and PTY draining are independent. On most supported Unix systems, descendants can retain the slave after the direct child exits. On macOS, drain output concurrently with waiting: the @@ -136,8 +168,12 @@ mode, relays, key handling, VT parsing, scrollback, or pager policy. A bare PTY creates the required session. `ProcessGroup::leader()` and `ProcessSession` each preserve group-wide signalling while the direct child is live; waiting still follows that direct child. `ProcessGroup::attach_to(...)` and explicitly registering both wrappers return `InvalidInput`. -`ResetSigmask` composes normally. `KillOnDrop` remains Tokio's direct-child behavior—it does not -promise to kill an entire group or session. Spawning returns the ordinary boxed Tokio child, and +`ResetSigmask` composes normally. Without `JobObject`, Tokio `KillOnDrop` terminates only the direct +child; it does not promise to terminate a process group, session, or descendants. On Windows, +combining `KillOnDrop` with `JobObject` enables `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. While the +`JobObject` child layer remains installed, dropping the returned child terminates every process still +associated with that job. `JobObject` and `KillOnDrop` may be registered in either order, and the +Windows PTY provider preserves the same policy. Spawning returns the ordinary boxed Tokio child, and `take_pty_controller()` traverses any outer child wrappers and yields the controller once. ### or with std @@ -200,6 +236,13 @@ Command::with_new("watch", |command| { command.arg("ls"); }) requested flag, temporarily adds `CREATE_SUSPENDED` while assigning the process, and resumes it after assignment unless the caller explicitly requested `CREATE_SUSPENDED`. +`KillOnDrop` may likewise be registered before or after `JobObject`. On a successful Tokio spawn with +both wrappers, process-wrap enables `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`; ordinary drop while the +`JobObject` child layer remains installed terminates every process still associated with the job. +Without `JobObject`, the lower Tokio child's policy targets only that direct child. Consuming the +`JobObject` layer with `ChildWrapper::into_inner` relinquishes whole-job waiting, explicit whole-job +killing, and kill-on-close supervision. The returned lower child retains only its direct-child policy. + ### Process group - Platforms: POSIX (Linux, Mac, BSDs...) @@ -262,10 +305,20 @@ This wrapper records Windows creation flags as portable per-attempt policy. Call `Command::creation_flags` method instead makes the command native-only because wrappers and alternate transports cannot query or reconstruct those flags. +```toml +[dependencies] +process-wrap = { version = "10.0.0", features = ["std", "creation-flags", "job-object"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_System_Threading"] } +``` + ```rust -use windows::Win32::System::Threading::*; +use process_wrap::std::{Command, CreationFlags, JobObject}; +use windows::Win32::System::Threading::{CREATE_NO_WINDOW, DETACHED_PROCESS}; + Command::with_new("watch", |command| { command.arg("ls"); }) - .wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_DETACHED)) + .wrap(CreationFlags(CREATE_NO_WINDOW | DETACHED_PROCESS)) .wrap(JobObject) .spawn()?; ``` @@ -282,7 +335,10 @@ after assignment unless the caller explicitly requested `CREATE_SUSPENDED`. This wrapper records kill-on-drop as portable per-attempt policy so `JobObject` and alternate spawn providers can preserve it. Calling the native-shaped `Command::kill_on_drop` method instead makes the -command native-only because the setting cannot be queried afterward. +command native-only because the setting cannot be queried afterward. Without an installed +`JobObject`, the policy terminates only the direct Tokio child. With an installed `JobObject`, it also +enables kill-on-last-job-handle-close for every process still associated with that job; consuming the +`JobObject` layer relinquishes that whole-job policy. ```rust let child = Command::with_new("watch", |command| { command.arg("ls"); }) @@ -325,22 +381,28 @@ The trait provides extension or hook points into the lifecycle of a `Command`: native API does not expose callback insertion or command ownership; prefer portable attempt methods for recurring configuration. +- **On Windows, `prepare_child`** runs in registration order after transport creation and before any + public `post_spawn` hook. It constructs fallible per-wrapper prepared state while native/provider + cleanup remains armed. The matching weak token is installed into a detached child layer during + wrapping, before that layer is published to later callbacks. + - **`fn post_spawn(&mut self, attempt: &mut SpawnAttempt, child: &mut dyn ChildWrapper, command: &Command) -> io::Result<()>`** is called after any transport has created its child. The child may be a terminal custom/provider child with no native child value. Changing command settings on `attempt` at this point cannot configure the already-created child. -- **`fn wrap_child(&mut self, child: Box, command: &Command) -> io::Result>`** - is called after all `post_spawn()` hooks. If your wrapper needs to override child methods, create - your own `ChildWrapper` layer and return it here. Child wraps run in registration order, so - `.wrap(Foo).wrap(Bar)` produces an outer `Bar(Foo(child))`. +- **`fn wrap_child(&mut self, child: &mut dyn ChildWrapper, command: &Command) -> io::Result>`** + is called after all `post_spawn()` hooks. The current child stays borrowed from process-wrap. To + override child methods, return a `PendingChildWrapper` containing a `ChildWrapperLayer` whose child + slot is empty; process-wrap installs the child after the callback returns successfully. Child wraps + run in registration order, so `.wrap(Foo).wrap(Bar)` produces an outer `Bar(Foo(child))`. - **`fn spawn_provider(&self) -> Option<&dyn SpawnProvider>`** exposes an alternate transport owned by this wrapper. A provider exposed during selection must remain available throughout the lifecycle; only one registered wrapper may expose one. Pre-spawn, post-spawn, and child-wrapping hooks all run in registration order and stop at the first -error or panic. The active wrapper remains registered but is temporarily unavailable through +error or unwinding panic. The active wrapper remains registered but is temporarily unavailable through `get_wrap`; peer wrappers remain visible. ### Spawn providers @@ -355,16 +417,39 @@ Callbacks run in this order: 4. every `pre_spawn` hook 5. native-only attempt rejection 6. `validate_attempt` -7. provider `spawn` -8. every `post_spawn` hook -9. every child wrapper -10. transaction `commit` +7. provider `spawn`, returning a child and fresh armed transaction +8. on Windows, every fallible prepared-child construction hook +9. every `post_spawn` hook +10. every child-layer description and detached installation, including matching prepared state and + install-time custody validation +11. on Windows, ordinary child finalization and cleanup disarm, cleanup-owner validation, and + non-owner disarm while the sole final owner remains armed +12. fallible private return-custody allocation +13. transaction `commit` +14. committed transaction-residue installation +15. on Windows, the sole final cleanup owner disarms +16. infallible private ownership moves and child return Validation must reject unsupported portable policy before allocating operating-system resources. `spawn` returns a child satisfying the frontend's complete `ChildWrapper` contract together with a -fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. A -later hook, wrapper, or commit error/panic causes best-effort rollback while preserving the original -failure. Until `spawn` returns the product, cleanup remains the provider's responsibility. +fresh, armed `SpawnTransaction`. The transaction owns cleanup independently of the child chain. On +Windows, prepared-state construction occurs before public post hooks. After wrapping and install-time +custody validation, ordinary finalization, ordinary cleanup disarm, owner selection, and non-owner +disarm all complete before return-custody allocation and commit; every step is fallible while the +transaction remains armed. A later error or unwinding panic causes best-effort rollback while +preserving the original failure. After capturing that failure, process-wrap first resolves transaction +cleanup, then disposes any detached child layer, the child chain, and each Windows prepared value +independently. Secondary cleanup panic payloads are quarantined without inspection or destruction. +Successful commit ends failed-spawn rollback, and its residue is installed in the already-allocated +return custody. On Windows, the sole final owner remains armed until it disarms after commit; no caller +callback, allocation-dependent work, or native handle close follows that successful transition before +the infallible return move. On a successful spawn, arbitrary residue destruction occurs outside the +spawn lifecycle. Committed residue must retain no armed cleanup or independent process, terminal, +controller, handle, pseudoconsole, or other liveness resource. + +Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic +containment apply only to unwinding panics. With `panic=abort`, the process terminates before those +guarantees can run. Until `spawn` returns the product, cleanup remains the provider's responsibility. A command may register only one provider; conflicts are rejected before any provider callback or operating-system allocation. Both providers and wrapper state are reused across repeated spawns. diff --git a/docs/plans/command-api-and-pty-provider.md b/docs/plans/command-api-and-pty-provider.md deleted file mode 100644 index 3ef16b3..0000000 --- a/docs/plans/command-api-and-pty-provider.md +++ /dev/null @@ -1,111 +0,0 @@ -# Unified command API and PTY provider - -Process-wrap 10.0 established a reliable ordered wrapper registry and child capability model. -The PTY prototypes proved the Unix and ConPTY transports but introduced `PtyCommand` as a second command-building and spawning API. -Process-wrap 11 will instead make command configuration exact and transport-independent, with PTY selected by the same `.wrap(Pty).spawn()` flow as every other concern. - -## Shared command family - -Introduce one generic command implementation selected by sealed blocking and Tokio frontend markers. -Re-export backend-selected `Command` aliases from the existing `std` and `tokio` modules. -Retain `CommandWrap` as an alias while preferring `Command` in new documentation. -Keep backend-specific `spawn` implementations and child traits because blocking and Tokio child operations have different contracts. -Keep both Cargo frontend features additive and independently usable. - -Move program, argument, environment, cwd, wrapper registry, and common construction logic into the shared command implementation. -Keep `with_new`, with its closure receiving the process-wrap command instead of a native command. -Expose native-shaped tracked configuration methods so inferred existing closure bodies remain valid. -Preserve compatibility command accessors as process-wrap facade views rather than native escape hatches. -Add explicitly named native mutation and conversion methods. - -## Exact and native-only state - -Represent ordinary command configuration as cloneable tracked intent. -Preserve ordered regular and raw Windows argument operations without Unicode normalization. -Preserve inherited versus cleared environment state, environment mutations, and cwd exactly. -Materialize a fresh native command from tracked intent for each spawn attempt. - -Retain conversion from native std and Tokio commands as a native-only compatibility state. -Transition tracked state to native-only when callers explicitly request native mutation or configure state that cannot be reconstructed, such as arbitrary `Stdio` handles or `pre_exec` callbacks. -Allow native-only commands to use exact native spawning and explicit custom spawners. -Reject native-only state from portable providers rather than reconstructing, ignoring, or guessing at it. - -## Attempt and wrapper lifecycle - -Create a per-spawn attempt facade from tracked command state. -Change `pre_spawn` to mutate that facade while retaining read-only access to live peer wrappers. -Change `post_spawn` to receive the attempt and the frontend child capability trait rather than requiring a native child. -Run `post_spawn` for native, custom, and provider children. -Retain ordered `wrap_child` application and typed duplicate-wrapper extension. -Preserve wrapper slots and command state across success, error, and panic paths. - -Move built-in process group, process session, signal-mask, creation-flag, and kill-on-drop setup onto tracked attempt policy where possible. -Use object-safe child capabilities for process handles and provider-owned suspension rather than concrete custom-child downcasts. -Let custom wrappers compose based on representable command state and available child capabilities instead of concrete type allowlists. - -## Spawn providers - -Add an object-safe frontend-specific provider capability exposed by a command wrapper. -Select the native provider when no alternate provider is registered. -Reject multiple alternate providers before hooks or operating-system allocation. -Run provider availability checks before other provider validation so unsupported-platform errors retain precedence. -Reject incompatible immutable configuration before hooks. -Run ordered pre-spawn hooks, then validate the resulting attempt before provider allocation. -Have providers return boxed frontend children and armed cleanup transactions. -Run ordered post-spawn and child-wrapping hooks before committing the provider transaction. -Ensure provider cleanup runs after every later error or panic without replacing the original failure. - -Treat `spawn_with` and `spawn_with_child` as explicit caller-selected transports. -Reject either method when a wrapper provider is registered instead of bypassing it. -Run capability-level post-spawn and ordinary child wrapping for both methods. - -## Tokio PTY wrapper - -Replace `PtyCommand`, its duplicate command intent and wrapper registry, `PtyMarker`, and tuple-returning spawn with a Tokio-only `Pty` provider wrapper. -Store terminal size on `Pty` and merge duplicate registration through typed extension. -Keep ordinary `spawn` returning the boxed Tokio child contract. -Install a private child layer which owns the PTY controller beneath arbitrary outer wrappers. -Add one-shot controller extraction by traversing the child chain without unwrapping it. -Keep PTY input and output on the controller and keep native Tokio pipe accessors absent. - -## Unix PTY transport - -Reuse the existing PTY allocation and Tokio I/O implementation for Android, DragonFly BSD, FreeBSD, illumos, Linux, macOS, NetBSD, OpenBSD, and Solaris. -Preserve close-on-exec, nonblocking master I/O, slave duplication, controlling-terminal setup, foreground process groups, Linux EIO normalization, and cleanup behavior. -Apply slave stdio and terminal callbacks only to the fresh attempt command. -Close parent slave descriptors before post-spawn and child-wrapping hooks. -Preserve shared master ownership by input and output, weak resize ownership, merged terminal output, and independent child-wait and output-EOF lifecycles. - -Integrate real `Pty` registration with `ProcessGroup`, `ProcessSession`, `ResetSigmask`, and `KillOnDrop`. -Preserve group-aware supervision for leader/session modes. -Reject attached groups and simultaneous explicit group/session registration. -Return unsupported-platform errors before terminal-size validation or hook effects. - -## Exact Windows command model - -Reuse the existing Windows argument, environment, program-resolution, and cwd modules against shared tracked command intent. -Preserve interleaved regular and raw argument semantics, CRT quoting, WTF-16 data, and stable validation errors. -Preserve native environment inheritance, explicit Unicode blocks, case-insensitive key replacement, deterministic ordering, and drive pseudo-variables. -Preserve deterministic executable resolution and direct batch-script rejection. -Carry creation flags, explicit versus temporary suspension, JobObject, and KillOnDrop through tracked policy and child capabilities. - -## ConPTY transport - -Reuse the existing dynamic ConPTY API resolution, startup attributes, named pipes, manual `CreateProcessW`, custom child, controller, and cleanup modules. -Integrate them as the Tokio `Pty` provider rather than a separate spawn method. -Preserve unsupported-runtime precedence and exact command line, environment, cwd, flag, and handle-inheritance semantics. -Retain process and primary-thread handles through JobObject assignment and provider-owned suspension finalization. -Keep the cleanup guard armed through all wrapper hooks and disarm it only when the provider transaction commits. -Preserve cancellation-safe repeated waits, post-exit kill behavior, resize, merged I/O, direct-child and job-object kill-on-drop behavior, and off-reactor pseudoconsole closure. - -## Public documentation and migration - -Prefer the backend module `Command` aliases while retaining `CommandWrap` compatibility. -Show inferred `with_new` closures continuing to use native-shaped configuration calls. -Explain why backend typing remains at spawn and child boundaries even though command configuration is shared. -Use only `.wrap(Pty).spawn()` and one-shot controller extraction in PTY examples. -Preserve the crate’s adaptability motivation and complete supported Unix platform list. -Preserve the terminal stream, ownership, VEOF, draining, macOS lifecycle, and process-supervision rationale. -Explain that `pty` remains non-default because it selects Tokio and PTY dependencies. -Remove the rejected public PTY builder, tuple spawn, marker names, fallback-to-pipes wording, and CI-runner prose. -Document native-only escape behavior and the migration from the former PTY prototype. diff --git a/src/command.rs b/src/command.rs index 46fcc72..7c4636b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -468,60 +468,36 @@ impl NativeCommandView { } } -struct NativeOnlyCommand { - command: Option, +struct NativeOnlyCommand { view: NativeCommandView, } -impl NativeOnlyCommand { - fn new(command: N) -> Self { +impl NativeOnlyCommand { + fn new(command: &N) -> Self { Self { - view: NativeCommandView::capture(&command), - command: Some(command), + view: NativeCommandView::capture(command), } } - fn command_mut(&mut self) -> &mut N { - self.command - .as_mut() - .expect("native command access cannot occur while a spawn lifecycle is active") - } - - fn take(&mut self) -> N { - let command = self - .command - .take() - .expect("a native-only command is present when its spawn lifecycle begins"); - self.view = NativeCommandView::capture(&command); - command - } - - fn restore(&mut self, command: N) { - debug_assert!(self.command.is_none()); - self.command = Some(command); - } - - fn into_command(self) -> N { - self.command - .expect("a command cannot be consumed while its spawn lifecycle is active") + fn refresh(&mut self, command: &N) { + self.view = NativeCommandView::capture(command); } } -impl fmt::Debug for NativeOnlyCommand { +impl fmt::Debug for NativeOnlyCommand { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("NativeOnly") - .field("command", &self.command) .field("view", &self.view) .finish() } } -enum CommandState { +enum CommandState { Tracked(CommandIntent), - NativeOnly(NativeOnlyCommand), + NativeOnly(NativeOnlyCommand), } -impl fmt::Debug for CommandState { +impl fmt::Debug for CommandState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Tracked(intent) => f.debug_tuple("Tracked").field(intent).finish(), @@ -533,20 +509,47 @@ impl fmt::Debug for CommandState { /// Cleanup and finalization owned by an alternate spawn provider. /// /// A provider returns a fresh, armed transaction with every child it successfully creates. The -/// transaction must own its cleanup resources independently of the child wrapper chain, because a -/// failing child wrapper may already have consumed or dropped that chain. +/// transaction must own its cleanup resources independently of the child wrapper chain, so rollback +/// authority remains available while process-wrap disposes other lifecycle values separately. +/// +/// After capturing a lifecycle error or unwinding panic, process-wrap first rolls back an armed +/// transaction or disposes committed residue, then disposes any detached child layer, the child chain, +/// and each Windows prepared-state value independently. Every secondary cleanup panic is quarantined +/// without inspecting or destroying its payload before the original failure is returned or resumed. +/// +/// On Windows, process-wrap calls [`commit`](SpawnTransaction::commit) only after every public +/// post-spawn and child-wrapping hook and the complete pre-commit child phase succeed. That phase +/// runs ordinary child finalizers, disarms ordinary child cleanup, rejects multiple JobObject cleanup +/// owners, and disarms every non-owner while leaving the sole owner armed. `commit` must disarm +/// provider rollback resources on success. If pre-commit work or `commit` returns an error or unwinds, +/// the transaction must remain rollbackable; process-wrap then makes one best-effort +/// [`rollback`](SpawnTransaction::rollback) call. A rollback error or unwinding panic is suppressed so +/// the original failure is preserved. Transaction destruction after rollback is contained separately, +/// and secondary panic payloads are quarantined without being inspected or destroyed. +/// +/// Before commit, process-wrap preallocates a private transparent return sidecar. Successful commit +/// ends failed-spawn rollback, and process-wrap installs the committed transaction residue in that +/// sidecar before the sole JobObject owner, if any, disarms. After that owner succeeds, no caller +/// callback or allocation-dependent work runs before return. On a successful spawn, arbitrary +/// transaction destruction therefore occurs only after the child has left the spawn lifecycle. A +/// committed transaction must retain no armed cleanup or independent process, terminal, controller, +/// handle, pseudoconsole, or other liveness resource. If the final owner fails after commit, its +/// still-armed cleanup remains authoritative while process-wrap contains disposal of the committed +/// residue as secondary cleanup. /// -/// Process-wrap calls [`commit`](SpawnTransaction::commit) only after every public post-spawn and -/// child-wrapping hook succeeds. `commit` must disarm rollback resources on success. If it returns an -/// error or panics, the transaction must remain rollbackable; process-wrap then makes one best-effort -/// [`rollback`](SpawnTransaction::rollback) call. A rollback error or panic is suppressed so the -/// original error or panic is preserved. After a successful commit, process-wrap drops the transaction -/// and does not roll it back if a later internal child-finalization phase fails. +/// On non-Windows targets there is no hidden child-finalization phase, so commit follows the public +/// hooks directly. These panic guarantees require unwinding; `panic=abort` terminates the process +/// before rollback or payload preservation can run. /// -/// Until a provider returns its `ProviderProduct`, cleanup for errors or panics in its own `spawn` -/// implementation remains the provider's responsibility. +/// Until a provider returns its `ProviderProduct`, cleanup for errors or unwinding panics in its own +/// `spawn` implementation remains the provider's responsibility. pub trait SpawnTransaction: fmt::Debug + Send + 'static { /// Finalize the successful spawn and disarm rollback resources. + /// + /// On success, the transaction must release every rollback-only strong owner and every + /// independent process, terminal, controller, handle, pseudoconsole, or other liveness resource. + /// Process-wrap may retain and later destroy the committed transaction residue with the returned + /// child. fn commit(&mut self) -> std::io::Result<()>; /// Undo an uncommitted spawn. @@ -676,7 +679,7 @@ pub(crate) fn terminate_process_and_wait(process: BorrowedHandle<'_>) -> std::io #[cfg(windows)] #[derive(Debug)] pub(crate) struct WindowsSpawnCleanup { - process: OwnedHandle, + process: Option, armed: bool, } @@ -684,7 +687,7 @@ pub(crate) struct WindowsSpawnCleanup { impl WindowsSpawnCleanup { pub(crate) fn new(process: BorrowedHandle<'_>) -> std::io::Result { Ok(Self { - process: process.try_clone_to_owned()?, + process: Some(process.try_clone_to_owned()?), armed: true, }) } @@ -698,28 +701,28 @@ impl WindowsSpawnCleanup { impl Drop for WindowsSpawnCleanup { fn drop(&mut self) { if self.armed { - let _ = terminate_process_and_wait(self.process.as_handle()); + let process = self + .process + .as_ref() + .expect("an armed Windows spawn cleanup retains its process handle"); + let _ = terminate_process_and_wait(process.as_handle()); } + drop(self.process.take()); + #[cfg(all(test, feature = "job-object"))] + crate::test_allocator::observe_spawn_cleanup_handle_close(); } } -enum AttemptState { - Tracked { - intent: CommandIntent, - native: Option, - }, - NativeOnly(N), +enum AttemptState { + Tracked(CommandIntent), + NativeOnly, } -impl fmt::Debug for AttemptState { +impl fmt::Debug for AttemptState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Tracked { intent, native } => f - .debug_struct("Tracked") - .field("intent", intent) - .field("native", native) - .finish(), - Self::NativeOnly(command) => f.debug_tuple("NativeOnly").field(command).finish(), + Self::Tracked(intent) => f.debug_tuple("Tracked").field(intent).finish(), + Self::NativeOnly => f.write_str("NativeOnly"), } } } @@ -738,11 +741,14 @@ struct PlatformCommandState { /// Explicit native mutation makes a tracked attempt native-only, which alternate portable spawn /// providers reject rather than reconstructing or partially applying. pub struct SpawnAttempt { - state: AttemptState, + state: AttemptState, + native: Option, #[cfg_attr(not(unix), allow(dead_code))] platform: PlatformCommandState, #[cfg_attr(not(unix), allow(dead_code))] native_only_base: bool, + #[cfg(all(unix, feature = "process-group"))] + spawned_id: Option, kill_on_drop: Option, #[cfg(windows)] windows_policy: WindowsSpawnPolicy, @@ -765,7 +771,8 @@ impl fmt::Debug for SpawnAttempt { /// `process_wrap::tokio::Command`. Command construction and configuration are shared; spawning and /// child behavior remain specific to the selected frontend. pub struct Command { - state: CommandState, + state: CommandState, + native: Option, wrappers: Box, platform: PlatformCommandState, backend: PhantomData B>, @@ -784,6 +791,7 @@ impl Command { pub fn new(program: impl AsRef) -> Self { Self { state: CommandState::Tracked(CommandIntent::new(program)), + native: None, wrappers: B::new_registry(), platform: PlatformCommandState::default(), backend: PhantomData, @@ -818,7 +826,11 @@ impl Command { let arg = arg.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.args.push(CommandArg::Regular(arg.to_owned())), - CommandState::NativeOnly(command) => command.command_mut().arg(arg), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .arg(arg), } self } @@ -843,7 +855,11 @@ impl Command { let arg = arg.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.args.push(CommandArg::Raw(arg.to_owned())), - CommandState::NativeOnly(command) => command.command_mut().raw_arg(arg), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .raw_arg(arg), } self } @@ -856,7 +872,11 @@ impl Command { CommandState::Tracked(intent) => intent .env .push(EnvChange::Set(key.to_owned(), value.to_owned())), - CommandState::NativeOnly(command) => command.command_mut().env(key, value), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .env(key, value), } self } @@ -879,7 +899,11 @@ impl Command { let key = key.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.env_remove(key), - CommandState::NativeOnly(command) => command.command_mut().env_remove(key), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .env_remove(key), } self } @@ -891,7 +915,11 @@ impl Command { intent.env_clear = true; intent.env.clear(); } - CommandState::NativeOnly(command) => command.command_mut().env_clear(), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .env_clear(), } self } @@ -901,7 +929,11 @@ impl Command { let dir = dir.as_ref(); match &mut self.state { CommandState::Tracked(intent) => intent.current_dir = Some(dir.to_owned()), - CommandState::NativeOnly(command) => command.command_mut().current_dir(dir), + CommandState::NativeOnly(_) => self + .native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") + .current_dir(dir), } self } @@ -928,8 +960,8 @@ impl Command { pub fn get_program(&self) -> &OsStr { match &self.state { CommandState::Tracked(intent) => &intent.program, - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_program(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_program(), None => &command.view.program, }, } @@ -939,8 +971,8 @@ impl Command { pub fn get_args(&self) -> Box + '_> { match &self.state { CommandState::Tracked(intent) => Box::new(intent.args.iter().map(CommandArg::value)), - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_args(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_args(), None => command.view.get_args(), }, } @@ -961,8 +993,8 @@ impl Command { pub fn get_envs(&self) -> Box)> + '_> { match &self.state { CommandState::Tracked(intent) => Box::new(intent.get_envs()), - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_envs(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_envs(), None => command.view.get_envs(), }, } @@ -983,8 +1015,8 @@ impl Command { pub fn get_current_dir(&self) -> Option<&Path> { match &self.state { CommandState::Tracked(intent) => intent.current_dir.as_deref(), - CommandState::NativeOnly(command) => match &command.command { - Some(command) => command.get_current_dir(), + CommandState::NativeOnly(command) => match &self.native { + Some(native) => native.get_current_dir(), None => command.view.current_dir.as_deref(), }, } @@ -1003,30 +1035,34 @@ impl Command { /// inactive callbacks; use the tracked facade methods and wrappers for reusable configuration. pub fn native_mut(&mut self) -> &mut B::NativeCommand { if let CommandState::Tracked(intent) = &self.state { - let command = intent.materialize::(); - self.state = CommandState::NativeOnly(NativeOnlyCommand::new(command)); + let native = intent.materialize::(); + let state = NativeOnlyCommand::new(&native); + self.native = Some(native); + self.state = CommandState::NativeOnly(state); } #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] self.platform.unix.invalidate(); - match &mut self.state { - CommandState::NativeOnly(command) => command.command_mut(), - CommandState::Tracked(_) => unreachable!("tracked command was materialized above"), - } + self.native + .as_mut() + .expect("native command access cannot occur while a spawn lifecycle is active") } /// Consume this command and return the frontend's native command. pub fn into_native(self) -> B::NativeCommand { match self.state { CommandState::Tracked(intent) => intent.materialize::(), - CommandState::NativeOnly(command) => command.into_command(), + CommandState::NativeOnly(_) => self + .native + .expect("a command cannot be consumed while its spawn lifecycle is active"), } } - pub(crate) fn from_native(command: B::NativeCommand) -> Self { + pub(crate) fn from_native(native: B::NativeCommand) -> Self { Self { - state: CommandState::NativeOnly(NativeOnlyCommand::new(command)), + state: CommandState::NativeOnly(NativeOnlyCommand::new(&native)), + native: Some(native), wrappers: B::new_registry(), platform: PlatformCommandState::default(), backend: PhantomData, @@ -1055,65 +1091,43 @@ impl Command { invoke: impl FnOnce(&mut Self, &mut SpawnAttempt) -> std::io::Result, ) -> std::io::Result { let platform = self.platform.clone(); - match &mut self.state { + let (state, native, native_only_base) = match &mut self.state { CommandState::Tracked(intent) => { - let mut attempt = SpawnAttempt { - state: AttemptState::Tracked { - intent: intent.clone(), - native: None, - }, - platform, - native_only_base: false, - kill_on_drop: None, - #[cfg(windows)] - windows_policy: WindowsSpawnPolicy::default(), - #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] - unix_policy: crate::unix::SpawnPolicy::default(), - backend: PhantomData, - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - invoke(self, &mut attempt) - })); - attempt.disarm_platform(); - match result { - Ok(result) => result, - Err(payload) => std::panic::resume_unwind(payload), - } + drop(self.native.take()); + (AttemptState::Tracked(intent.clone()), None, false) } CommandState::NativeOnly(command) => { - let native = command.take(); - let mut attempt = SpawnAttempt { - state: AttemptState::NativeOnly(native), - platform, - native_only_base: true, - kill_on_drop: None, - #[cfg(windows)] - windows_policy: WindowsSpawnPolicy::default(), - #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] - unix_policy: crate::unix::SpawnPolicy::default(), - backend: PhantomData, - }; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - invoke(self, &mut attempt) - })); - attempt.disarm_platform(); - let native = match attempt.state { - AttemptState::NativeOnly(native) => native, - AttemptState::Tracked { .. } => { - unreachable!("a native-only spawn attempt cannot become tracked") - } - }; - match &mut self.state { - CommandState::NativeOnly(command) => command.restore(native), - CommandState::Tracked(_) => { - unreachable!("a spawn lifecycle cannot replace native-only command state") - } - } - match result { - Ok(result) => result, - Err(payload) => std::panic::resume_unwind(payload), - } + let native = self + .native + .take() + .expect("a native-only command is present when its spawn lifecycle begins"); + command.refresh(&native); + (AttemptState::NativeOnly, Some(native), true) } + }; + let mut attempt = SpawnAttempt { + state, + native, + platform, + native_only_base, + #[cfg(all(unix, feature = "process-group"))] + spawned_id: None, + kill_on_drop: None, + #[cfg(windows)] + windows_policy: WindowsSpawnPolicy::default(), + #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] + unix_policy: crate::unix::SpawnPolicy::default(), + backend: PhantomData, + }; + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| invoke(self, &mut attempt))); + #[cfg(unix)] + attempt.disarm_platform(); + let native = attempt.native.take(); + self.native = if native_only_base { native } else { None }; + match result { + Ok(result) => result, + Err(payload) => std::panic::resume_unwind(payload), } } } @@ -1123,10 +1137,12 @@ impl SpawnAttempt { pub fn arg(&mut self, arg: impl AsRef) -> &mut Self { let arg = arg.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => { - intent.args.push(CommandArg::Regular(arg.to_owned())) - } - AttemptState::NativeOnly(command) => command.arg(arg), + AttemptState::Tracked(intent) => intent.args.push(CommandArg::Regular(arg.to_owned())), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .arg(arg), } self } @@ -1150,10 +1166,12 @@ impl SpawnAttempt { pub fn raw_arg(&mut self, arg: impl AsRef) -> &mut Self { let arg = arg.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => { - intent.args.push(CommandArg::Raw(arg.to_owned())) - } - AttemptState::NativeOnly(command) => command.raw_arg(arg), + AttemptState::Tracked(intent) => intent.args.push(CommandArg::Raw(arg.to_owned())), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .raw_arg(arg), } self } @@ -1163,10 +1181,14 @@ impl SpawnAttempt { let key = key.as_ref(); let value = value.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => intent + AttemptState::Tracked(intent) => intent .env .push(EnvChange::Set(key.to_owned(), value.to_owned())), - AttemptState::NativeOnly(command) => command.env(key, value), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .env(key, value), } self } @@ -1188,8 +1210,12 @@ impl SpawnAttempt { pub fn env_remove(&mut self, key: impl AsRef) -> &mut Self { let key = key.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => intent.env_remove(key), - AttemptState::NativeOnly(command) => command.env_remove(key), + AttemptState::Tracked(intent) => intent.env_remove(key), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .env_remove(key), } self } @@ -1197,11 +1223,15 @@ impl SpawnAttempt { /// Clear configured variables and prevent inheritance for this spawn attempt. pub fn env_clear(&mut self) -> &mut Self { match &mut self.state { - AttemptState::Tracked { intent, .. } => { + AttemptState::Tracked(intent) => { intent.env_clear = true; intent.env.clear(); } - AttemptState::NativeOnly(command) => command.env_clear(), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .env_clear(), } self } @@ -1210,8 +1240,12 @@ impl SpawnAttempt { pub fn current_dir(&mut self, dir: impl AsRef) -> &mut Self { let dir = dir.as_ref(); match &mut self.state { - AttemptState::Tracked { intent, .. } => intent.current_dir = Some(dir.to_owned()), - AttemptState::NativeOnly(command) => command.current_dir(dir), + AttemptState::Tracked(intent) => intent.current_dir = Some(dir.to_owned()), + AttemptState::NativeOnly => self + .native + .as_mut() + .expect("a native-only attempt retains its native command") + .current_dir(dir), } self } @@ -1237,18 +1271,24 @@ impl SpawnAttempt { /// Get the configured program for this spawn attempt. pub fn get_program(&self) -> &OsStr { match &self.state { - AttemptState::Tracked { intent, .. } => &intent.program, - AttemptState::NativeOnly(command) => command.get_program(), + AttemptState::Tracked(intent) => &intent.program, + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_program(), } } /// Get the configured arguments for this spawn attempt. pub fn get_args(&self) -> Box + '_> { match &self.state { - AttemptState::Tracked { intent, .. } => { - Box::new(intent.args.iter().map(CommandArg::value)) - } - AttemptState::NativeOnly(command) => command.get_args(), + AttemptState::Tracked(intent) => Box::new(intent.args.iter().map(CommandArg::value)), + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_args(), } } @@ -1259,16 +1299,20 @@ impl SpawnAttempt { /// `validate_attempt` callback, so providers receive `Some` there. pub fn get_portable_args(&self) -> Option<&[CommandArg]> { match &self.state { - AttemptState::Tracked { intent, .. } => Some(&intent.args), - AttemptState::NativeOnly(_) => None, + AttemptState::Tracked(intent) => Some(&intent.args), + AttemptState::NativeOnly => None, } } /// Get explicitly configured environment changes for this spawn attempt. pub fn get_envs(&self) -> Box)> + '_> { match &self.state { - AttemptState::Tracked { intent, .. } => Box::new(intent.get_envs()), - AttemptState::NativeOnly(command) => command.get_envs(), + AttemptState::Tracked(intent) => Box::new(intent.get_envs()), + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_envs(), } } @@ -1279,16 +1323,20 @@ impl SpawnAttempt { /// callback, so providers receive `Some` there. pub fn inherits_environment(&self) -> Option { match &self.state { - AttemptState::Tracked { intent, .. } => Some(!intent.env_clear), - AttemptState::NativeOnly(_) => None, + AttemptState::Tracked(intent) => Some(!intent.env_clear), + AttemptState::NativeOnly => None, } } /// Get the configured current directory for this spawn attempt. pub fn get_current_dir(&self) -> Option<&Path> { match &self.state { - AttemptState::Tracked { intent, .. } => intent.current_dir.as_deref(), - AttemptState::NativeOnly(command) => command.get_current_dir(), + AttemptState::Tracked(intent) => intent.current_dir.as_deref(), + AttemptState::NativeOnly => self + .native + .as_ref() + .expect("a native-only attempt retains its native command") + .get_current_dir(), } } @@ -1328,6 +1376,16 @@ impl SpawnAttempt { self.windows_policy.starts_suspended() } + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn set_spawned_id(&mut self, spawned_id: Option) { + self.spawned_id = spawned_id; + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn spawned_id(&self) -> Option { + self.spawned_id + } + /// Return the process-group setup requested for this attempt. /// /// Alternate spawn providers use this to apply process-group intent without making the attempt @@ -1391,12 +1449,10 @@ impl SpawnAttempt { #[cfg(windows)] let windows_policy = self.windows_policy; { - let command = match &mut self.state { - AttemptState::NativeOnly(command) => command, - AttemptState::Tracked { native, .. } => native - .as_mut() - .expect("the attempt is materialized before platform setup"), - }; + let command = self + .native + .as_mut() + .expect("the attempt is materialized before platform setup"); if let Some(kill_on_drop) = kill_on_drop { command.configure_kill_on_drop(kill_on_drop); } @@ -1410,48 +1466,51 @@ impl SpawnAttempt { { let policy = self.unix_policy; let native_only_base = self.native_only_base; - let command = match &mut self.state { - AttemptState::NativeOnly(command) => command, - AttemptState::Tracked { native, .. } => native - .as_mut() - .expect("the attempt is materialized before platform setup"), - }; + let command = self + .native + .as_mut() + .expect("the attempt is materialized before platform setup"); self.platform .unix .prepare(command, native_only_base, policy); } } + #[cfg(unix)] fn disarm_platform(&mut self) { #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] self.platform.unix.disarm(); } fn materialize_native(&mut self) { - if let AttemptState::Tracked { intent, native } = &mut self.state { - if native.is_none() { - *native = Some(intent.materialize::()); - } + if self.native.is_none() + && let AttemptState::Tracked(intent) = &self.state + { + self.native = Some(intent.materialize::()); } } fn make_native_only(&mut self) { + if matches!(&self.state, AttemptState::NativeOnly) { + return; + } self.materialize_native(); - let native = match &mut self.state { - AttemptState::Tracked { native, .. } => native - .take() - .expect("the tracked attempt was materialized above"), - AttemptState::NativeOnly(_) => return, - }; - self.state = AttemptState::NativeOnly(native); + self.state = AttemptState::NativeOnly; } fn native_command_mut(&mut self) -> &mut B::NativeCommand { - match &mut self.state { - AttemptState::Tracked { native, .. } => native - .as_mut() - .expect("the tracked attempt was materialized before native access"), - AttemptState::NativeOnly(command) => command, + self.native + .as_mut() + .expect("the attempt is materialized before native access") + } + + #[cfg(windows)] + pub(crate) fn prepare_for_final_owner(&mut self) { + if !self.native_only_base { + // Retire attempt-owned allocations before the sole native cleanup owner can disarm. + // A native-only base instead keeps its command for the infallible restoration move. + drop(self.native.take()); + self.state = AttemptState::NativeOnly; } } @@ -1490,11 +1549,12 @@ impl SpawnAttempt { ))] pub(crate) fn take_native_for_provider_spawn(&mut self) -> B::NativeCommand { self.materialize_native(); - match &mut self.state { - AttemptState::Tracked { native, .. } => native + match &self.state { + AttemptState::Tracked(_) => self + .native .take() .expect("the tracked provider attempt was materialized above"), - AttemptState::NativeOnly(_) => { + AttemptState::NativeOnly => { unreachable!("portable providers reject native-only attempts before spawning") } } @@ -1525,7 +1585,7 @@ impl SpawnAttempt { /// Return whether this spawn attempt contains opaque native-only state. pub fn is_native_only(&self) -> bool { - matches!(self.state, AttemptState::NativeOnly(_)) + matches!(self.state, AttemptState::NativeOnly) } } diff --git a/src/generic_wrap.rs b/src/generic_wrap.rs index e0d798f..0645d1e 100644 --- a/src/generic_wrap.rs +++ b/src/generic_wrap.rs @@ -4,7 +4,15 @@ )] macro_rules! Wrap { - ($backend:ty, $command:ty, $child:ty, $childer:ident, $first_child_wrapper:expr) => { + ( + $backend:ty, + $command:ty, + $child:ty, + $childer:ident, + $first_child_wrapper:expr, + $frontend:literal, + $prepared_owner_contract:ident + ) => { trait ErasedCommandWrapper: ::std::fmt::Debug + Send + Sync { fn as_command_wrapper(&self) -> &dyn CommandWrapper; fn as_command_wrapper_mut(&mut self) -> &mut dyn CommandWrapper; @@ -60,7 +68,11 @@ macro_rules! Wrap { /// The child must implement the complete contract for this frontend's `ChildWrapper`, including /// any platform capabilities required by registered wrappers. The transaction must be fresh, /// armed, independently owned from the child chain, and able to undo this specific spawn until - /// process-wrap commits it. + /// process-wrap commits it. On Windows, that includes every ordinary child-finalization and + /// cleanup-disarm hook plus JobObject owner validation and non-owner disarming. Process-wrap + /// preallocates the private return sidecar before commit, installs committed residue before the + /// sole JobObject owner hook, and performs only infallible private moves after that owner + /// disarms. #[derive(Debug)] pub struct ProviderProduct { child: Box, @@ -72,7 +84,10 @@ macro_rules! Wrap { /// /// Construct this only after the child has been created successfully. The transaction must own /// everything needed to terminate and reap that child and release provider resources if a later - /// hook, child wrapper, or transaction commit fails or panics. + /// hook, pre-commit child-finalization step, or transaction commit fails or unwinds. A successful + /// commit must release every rollback-only strong owner and independent liveness resource; the + /// remaining residue is transferred with the returned child. These panic guarantees do not apply + /// to `panic=abort`. pub fn new( child: Box, transaction: Box, @@ -87,6 +102,2300 @@ macro_rules! Wrap { } } + #[cfg(all(test, windows))] + struct PreparedStateTestRendezvous { + reached: ::std::sync::mpsc::Sender<()>, + release: ::std::sync::mpsc::Receiver<()>, + publish_reached: bool, + } + + #[cfg(all(test, windows))] + impl PreparedStateTestRendezvous { + fn wait(self) { + if self.publish_reached { + let _ = self.reached.send(()); + } + let _ = self.release.recv(); + } + } + + #[cfg(windows)] + #[derive(Default)] + struct PreparedChildAdmission { + closing: bool, + } + + #[cfg(windows)] + struct PreparedChildState { + // Accessors retain this lock through caller inspection. They take `admission` only long + // enough to linearize admission immediately before invoking the callback. + value: ::std::sync::Mutex>>, + admission: ::std::sync::Mutex, + type_id: ::std::any::TypeId, + #[cfg(test)] + value_lock_attempt: ::std::sync::Mutex>, + #[cfg(test)] + after_closing: ::std::sync::Mutex>, + } + + #[cfg(windows)] + impl PreparedChildState { + fn admitted_value( + &self, + ) -> Option< + ::std::sync::MutexGuard<'_, Option>>, + > { + #[cfg(test)] + self.wait_for_value_lock_attempt(); + let value = self + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + let admission = self + .admission + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + if admission.closing { + drop(admission); + drop(value); + return None; + } + // Reading `closing == false` while holding `admission` is the accessor's + // linearization point. Keep `value` locked so a closer cannot take the box before or + // during the callback, but release `admission` so closing can be published. + drop(admission); + Some(value) + } + + fn close(&self) { + { + let mut admission = self + .admission + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + // The first write of `true` while holding `admission` is the revocation, + // failure-cleanup, or consuming-extraction linearization point. A later accessor + // must acquire this same mutex before inspecting the value and therefore observes + // the closed state. + admission.closing = true; + } + #[cfg(test)] + self.wait_after_closing(); + } + + #[cfg(test)] + fn wait_for_value_lock_attempt(&self) { + let rendezvous = self + .value_lock_attempt + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take(); + if let Some(rendezvous) = rendezvous { + assert!( + matches!( + self.value.try_lock(), + Err(::std::sync::TryLockError::WouldBlock) + ), + "the acquisition-attempt probe requires an active value owner" + ); + rendezvous.wait(); + } + } + + #[cfg(test)] + fn wait_after_closing(&self) { + let rendezvous = self + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take(); + if let Some(rendezvous) = rendezvous { + rendezvous.wait(); + } + } + } + + #[cfg(windows)] + struct PreparedChildOwner { + state: ::std::sync::Arc, + installed_layer: Option<(::std::any::TypeId, usize)>, + } + + #[cfg(windows)] + impl PreparedChildOwner { + fn new(value: Box) -> Self { + let type_id = value.as_ref().type_id(); + Self { + state: ::std::sync::Arc::new(PreparedChildState { + value: ::std::sync::Mutex::new(Some(value)), + admission: ::std::sync::Mutex::new(PreparedChildAdmission::default()), + type_id, + #[cfg(test)] + value_lock_attempt: ::std::sync::Mutex::new(None), + #[cfg(test)] + after_closing: ::std::sync::Mutex::new(None), + }), + installed_layer: None, + } + } + + fn view(&self) -> PreparedChildRef<'_> { + PreparedChildRef { state: &self.state } + } + + fn installed_token(&self) -> PreparedChild { + PreparedChild { + state: ::std::sync::Arc::downgrade(&self.state), + } + } + + fn close_and_take(&self) -> Option> { + self.state.close(); + self.state + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + } + + fn has_exclusive_custody(&self) -> bool { + ::std::sync::Arc::strong_count(&self.state) == 1 + } + + fn has_value(&self) -> bool { + self.state + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .is_some() + } + + fn installed_in(&self, layer: (::std::any::TypeId, usize)) -> bool { + self.installed_layer == Some(layer) + } + } + + #[cfg(windows)] + impl Drop for PreparedChildOwner { + fn drop(&mut self) { + // Closing is published through the admission mutex before contending for the value. + // `close_and_take` releases the value lock before returning, so caller-defined + // destruction runs without either protocol mutex held. + let value = self.close_and_take(); + drop(value); + } + } + + /// Immutable type metadata for state awaiting child-layer installation. + /// + /// This view cannot clone process-wrap's custody owner or access the prepared value. Use + /// [`PreparedChildRef::is`] to select a matching detached layer, declare that layer's empty + /// typed slot through [`ChildWrapperSlots::with_prepared`], and inspect the value through the + /// installed [`PreparedChild`] only after process-wrap fills the slot. + #[cfg(windows)] + #[doc(hidden)] + pub struct PreparedChildRef<'a> { + state: &'a PreparedChildState, + } + + #[cfg(windows)] + impl ::std::fmt::Debug for PreparedChildRef<'_> { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + formatter.debug_struct("PreparedChildRef").finish_non_exhaustive() + } + } + + #[cfg(windows)] + impl PreparedChildRef<'_> { + /// Report whether the pending state has concrete type `T` without exposing its value. + pub fn is(&self) -> bool { + self.state.type_id == ::std::any::TypeId::of::() + } + } + + #[doc = concat!( + "Prepared state installed in a matching child layer by process-wrap.\n\n", + "This token is deliberately non-owning and not `Clone`, and arbitrary mutable access is ", + "not public. A layer may inspect its installed value immutably with ", + "[`PreparedChild::with`] while the returned child retains process-wrap's private owner. ", + "Moving or retaining this token cannot prolong the prepared value's lifetime. Inspection holds ", + "exclusive value access for the callback, which must not synchronously reenter the same token ", + "or destroy an owner whose close waits for that access. Resource types which themselves expose ", + "interior mutation or independently clonable native owners remain responsible for those ", + "capabilities.\n\n", + "On native Windows success, a private outer sidecar retains the strong prepared storage and ", + "any disarmed spawn-cleanup handle until child disposal or consuming sidecar removal. It ", + "delegates the full child contract, exposes the immediate application layer through ", + "`inner` and `inner_mut`, and consumes that application layer through `into_inner`. Its ", + "private concrete type is the returned trait object's top-level `Any` identity while ", + "prepared storage remains installed.\n\n", + "A detached layer cannot manufacture an additional token:\n\n", + "```compile_fail,E0308\n", + "# use process_wrap::", $frontend, "::PreparedChild;\n", + "fn retain_extra_token(prepared: &PreparedChild) {\n", + " let _escaped: PreparedChild = prepared.clone();\n", + "}\n", + "```\n\n", + "Nor can downstream code move fields out through process-wrap's private extraction API:\n\n", + "```compile_fail\n", + "# use process_wrap::", $frontend, "::PreparedChild;\n", + "fn take_guard(prepared: &PreparedChild) {\n", + " let _guard = prepared.take_prepared_value::>();\n", + "}\n", + "```" + )] + #[cfg(windows)] + #[doc(hidden)] + pub struct PreparedChild { + state: ::std::sync::Weak, + } + + #[cfg(windows)] + impl ::std::fmt::Debug for PreparedChild { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + formatter.debug_struct("PreparedChild").finish_non_exhaustive() + } + } + + #[cfg(windows)] + impl PreparedChild { + /// Inspect installed prepared state immutably while its private owner remains live. + #[doc(hidden)] + pub fn with(&self, inspect: impl FnOnce(&T) -> R) -> Option { + #[cfg(test)] + crate::test_allocator::current_probe().observe_operation(); + let state = self.state.upgrade()?; + let value = state.admitted_value()?; + value.as_deref()?.downcast_ref::().map(inspect) + } + + #[cfg(feature = "job-object")] + pub(crate) fn with_required( + &self, + unavailable: impl FnOnce() -> R, + inspect: impl FnOnce(&T) -> R, + ) -> R { + #[cfg(test)] + crate::test_allocator::current_probe().observe_operation(); + let Some(state) = self.state.upgrade() else { + return unavailable(); + }; + let Some(value) = state.admitted_value() else { + return unavailable(); + }; + let Some(value) = value.as_deref().and_then(|value| value.downcast_ref::()) else { + return unavailable(); + }; + inspect(value) + } + + #[cfg(feature = "job-object")] + pub(crate) fn take_prepared_value(&self) -> Option { + let state = self.state.upgrade()?; + // Consuming extraction permanently closes public access before waiting for an active + // callback. A type mismatch restores the identical box while the state remains closed. + state.close(); + let mut slot = state + .value + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + let value = slot.take()?; + match value.downcast::() { + Ok(value) => Some(*value), + Err(value) => { + *slot = Some(value); + None + } + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedRaceDrop(::std::sync::Arc<::std::sync::atomic::AtomicUsize>); + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedRaceDrop { + fn drop(&mut self) { + self.0 + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedRaceCleanup { + releases: Vec<::std::sync::mpsc::Sender<()>>, + workers: Vec<::std::thread::JoinHandle<()>>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedRaceCleanup { + fn new() -> Self { + Self { + releases: Vec::new(), + workers: Vec::new(), + } + } + + fn release_all(&self) { + for release in &self.releases { + let _ = release.send(()); + } + } + + fn finish(mut self) { + self.release_all(); + let mut first_panic = None; + for worker in ::std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + if first_panic.is_none() { + first_panic = Some(payload); + } else { + ::std::mem::forget(payload); + } + } + } + if let Some(payload) = first_panic { + ::std::panic::resume_unwind(payload); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedRaceCleanup { + fn drop(&mut self) { + self.release_all(); + for worker in ::std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + ::std::mem::forget(payload); + } + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Clone, Copy)] + enum PreparedRaceCloser { + OwnerDrop, + ConsumingExtraction, + FailureCleanup, + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Clone, Copy)] + enum PreparedLifecyclePath { + NativeDrop, + ProviderDrop, + NativeExtraction, + ProviderExtraction, + FailureCleanup, + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedExtractionPhase<'a>(&'a ::std::sync::atomic::AtomicBool); + + #[cfg(all(test, windows, feature = "job-object"))] + impl<'a> PreparedExtractionPhase<'a> { + fn enter(active: &'a ::std::sync::atomic::AtomicBool) -> Self { + assert!( + active + .compare_exchange( + false, + true, + ::std::sync::atomic::Ordering::SeqCst, + ::std::sync::atomic::Ordering::SeqCst, + ) + .is_ok(), + "prepared extraction phase entered more than once" + ); + Self(active) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedExtractionPhase<'_> { + fn drop(&mut self) { + self.0 + .store(false, ::std::sync::atomic::Ordering::SeqCst); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedLifecycleChild { + prepared: Option, + retained: Option, + extraction_phase: ::std::sync::Arc<::std::sync::atomic::AtomicBool>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl $childer for PreparedLifecycleChild { + fn inner(&self) -> &dyn $childer { + self + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn retain_prepared_after_sidecar_removal_layer(&mut self) { + let prepared = self + .prepared + .as_ref() + .expect("the extraction fixture retains its prepared token"); + let retained = { + let _phase = PreparedExtractionPhase::enter(&self.extraction_phase); + prepared + .take_prepared_value::() + .expect("consuming extraction transfers the prepared concrete value") + }; + self.retained = Some(retained); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_queued_prepared_access_is_rejected(closer: PreparedRaceCloser) { + const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); + + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let callback_calls = + ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let state = ::std::sync::Arc::clone(&owner.state); + let token = ::std::sync::Arc::new(owner.installed_token()); + let mut cleanup = PreparedRaceCleanup::new(); + + let (active_entered_tx, active_entered_rx) = ::std::sync::mpsc::channel(); + let (active_release_tx, active_release_rx) = ::std::sync::mpsc::channel(); + let (active_result_tx, active_result_rx) = ::std::sync::mpsc::channel(); + cleanup.releases.push(active_release_tx.clone()); + let active_token = ::std::sync::Arc::clone(&token); + cleanup.workers.push(::std::thread::spawn(move || { + let completed = active_token.with::(|_| { + let _ = active_entered_tx.send(()); + active_release_rx.recv().is_ok() + }) == Some(true); + let _ = active_result_tx.send(completed); + })); + active_entered_rx + .recv_timeout(TIMEOUT) + .expect("the active accessor acquired the prepared value"); + + let (queued_reached_tx, queued_reached_rx) = ::std::sync::mpsc::channel(); + let (queued_release_tx, queued_release_rx) = ::std::sync::mpsc::channel(); + *state + .value_lock_attempt + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: queued_reached_tx, + release: queued_release_rx, + publish_reached: true, + }); + cleanup.releases.push(queued_release_tx.clone()); + let (queued_result_tx, queued_result_rx) = ::std::sync::mpsc::channel(); + let queued_token = ::std::sync::Arc::clone(&token); + let queued_callback_calls = ::std::sync::Arc::clone(&callback_calls); + cleanup.workers.push(::std::thread::spawn(move || { + let accessed = queued_token + .with::(|_| { + queued_callback_calls + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + }) + .is_some(); + let _ = queued_result_tx.send(accessed); + })); + queued_reached_rx + .recv_timeout(TIMEOUT) + .expect("the queued accessor upgraded before closing"); + + let (closing_reached_tx, closing_reached_rx) = ::std::sync::mpsc::channel(); + let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); + *state + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: closing_reached_tx, + release: closing_release_rx, + publish_reached: true, + }); + cleanup.releases.push(closing_release_tx.clone()); + queued_release_tx + .send(()) + .expect("release the queued accessor to contend for the value"); + let (destroyed_tx, destroyed_rx) = ::std::sync::mpsc::channel(); + let extraction_token = ::std::sync::Arc::clone(&token); + cleanup.workers.push(::std::thread::spawn(move || { + match closer { + PreparedRaceCloser::OwnerDrop => drop(owner), + PreparedRaceCloser::ConsumingExtraction => { + let extracted = extraction_token + .take_prepared_value::() + .expect("consuming extraction retains the prepared concrete type"); + drop(extracted); + drop(owner); + } + PreparedRaceCloser::FailureCleanup => { + let mut prepared = [Some(owner)]; + crate::command::Command::<$backend>::cleanup_prepared(&mut prepared); + } + } + let _ = destroyed_tx.send(()); + })); + closing_reached_rx + .recv_timeout(TIMEOUT) + .expect("the closing operation published its authoritative state"); + + assert_eq!( + destroyed_rx.try_recv(), + Err(::std::sync::mpsc::TryRecvError::Empty), + "closing returned while the active accessor retained the value" + ); + assert_eq!( + drops.load(::std::sync::atomic::Ordering::SeqCst), + 0, + "prepared state dropped during active access" + ); + active_release_tx + .send(()) + .expect("release the finite active accessor"); + assert!( + !queued_result_rx + .recv_timeout(TIMEOUT) + .expect("the queued accessor completed before the closer took the value"), + "the queued accessor invoked its callback after closing" + ); + assert_eq!( + callback_calls.load(::std::sync::atomic::Ordering::SeqCst), + 0 + ); + closing_release_tx + .send(()) + .expect("release closing to take the prepared value"); + destroyed_rx + .recv_timeout(TIMEOUT) + .expect("the closing operation completed"); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + assert!( + token.with::(|_| ()).is_none(), + "a retained token accessed state after closing" + ); + assert!( + active_result_rx + .recv_timeout(TIMEOUT) + .expect("the active accessor completed") + ); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_prepared_lifecycle_path_closes(path: PreparedLifecyclePath) { + const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); + + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let state = ::std::sync::Arc::clone(&owner.state); + let token = owner.installed_token(); + let extracts = matches!( + path, + PreparedLifecyclePath::NativeExtraction + | PreparedLifecyclePath::ProviderExtraction + ); + let extraction_phase = + ::std::sync::Arc::new(::std::sync::atomic::AtomicBool::new(false)); + let child: Box = Box::new(PreparedLifecycleChild { + prepared: extracts.then(|| owner.installed_token()), + retained: None, + extraction_phase: ::std::sync::Arc::clone(&extraction_phase), + }); + let mut cleanup = PreparedRaceCleanup::new(); + + let (closing_reached_tx, closing_reached_rx) = ::std::sync::mpsc::channel(); + let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); + *state + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: closing_reached_tx, + release: closing_release_rx, + publish_reached: true, + }); + cleanup.releases.push(closing_release_tx.clone()); + let (finished_tx, finished_rx) = ::std::sync::mpsc::channel(); + cleanup.workers.push(::std::thread::spawn(move || { + match path { + PreparedLifecyclePath::NativeDrop => { + let returned: Box = + PreparedOwnerChild::new(child, vec![Some(owner)]); + drop(returned); + } + PreparedLifecyclePath::ProviderDrop => { + let returned: Box = + CommittedProviderChild::new(child, vec![Some(owner)]); + drop(returned); + } + PreparedLifecyclePath::NativeExtraction => { + let returned: Box = + PreparedOwnerChild::new(child, vec![Some(owner)]); + drop(returned.into_inner()); + } + PreparedLifecyclePath::ProviderExtraction => { + let returned: Box = + CommittedProviderChild::new(child, vec![Some(owner)]); + drop(returned.into_inner()); + } + PreparedLifecyclePath::FailureCleanup => { + drop(child); + let mut prepared = [Some(owner)]; + crate::command::Command::<$backend>::cleanup_prepared(&mut prepared); + } + } + let _ = finished_tx.send(()); + })); + + closing_reached_rx + .recv_timeout(TIMEOUT) + .expect("the lifecycle path published prepared closing"); + if extracts { + assert!( + extraction_phase.load(::std::sync::atomic::Ordering::SeqCst), + "consuming extraction must publish closing inside the prepared transfer" + ); + } + assert!( + state + .admission + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .closing, + "the lifecycle probe observes the actual closed state" + ); + assert_eq!( + finished_rx.try_recv(), + Err(::std::sync::mpsc::TryRecvError::Empty), + "the lifecycle path passed its paused close transition" + ); + assert_eq!( + drops.load(::std::sync::atomic::Ordering::SeqCst), + 0, + "the prepared value was disposed before the close transition resumed" + ); + closing_release_tx + .send(()) + .expect("release the lifecycle close transition"); + finished_rx + .recv_timeout(TIMEOUT) + .expect("the lifecycle path completed"); + if extracts { + assert!( + !extraction_phase.load(::std::sync::atomic::Ordering::SeqCst), + "prepared extraction phase remained active after transfer" + ); + } + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + assert!( + token.with::(|_| ()).is_none(), + "the lifecycle path left prepared state accessible after closing" + ); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn queued_prepared_access_is_rejected_by_owner_drop() { + assert_queued_prepared_access_is_rejected(PreparedRaceCloser::OwnerDrop); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn queued_prepared_access_is_rejected_by_consuming_extraction() { + assert_queued_prepared_access_is_rejected(PreparedRaceCloser::ConsumingExtraction); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn queued_prepared_access_is_rejected_by_failure_cleanup() { + assert_queued_prepared_access_is_rejected(PreparedRaceCloser::FailureCleanup); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn native_returned_child_drop_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::NativeDrop); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn provider_returned_child_drop_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::ProviderDrop); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn native_consuming_extraction_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::NativeExtraction); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn provider_consuming_extraction_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::ProviderExtraction); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn failed_spawn_cleanup_publishes_prepared_closing() { + assert_prepared_lifecycle_path_closes(PreparedLifecyclePath::FailureCleanup); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn prepared_extraction_phase_clears_during_unwind() { + let active = ::std::sync::atomic::AtomicBool::new(false); + let panic = ::std::panic::catch_unwind(|| { + let _phase = PreparedExtractionPhase::enter(&active); + assert!(active.load(::std::sync::atomic::Ordering::SeqCst)); + panic!("injected prepared extraction failure"); + }); + + assert!(panic.is_err()); + assert!(!active.load(::std::sync::atomic::Ordering::SeqCst)); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn consuming_type_mismatch_restores_value_in_closed_state() { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let token = owner.installed_token(); + + assert!(token.take_prepared_value::().is_none()); + assert!(owner.has_value(), "the mismatched box remains in owner custody"); + assert!( + token.with::(|_| ()).is_none(), + "consuming extraction closes access even when the concrete type mismatches" + ); + drop(owner); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum PreparedPublicationSetupFailure { + ActiveEntered, + QueuedReached, + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug, Default)] + struct PreparedPublicationSetupProbe { + started: ::std::sync::atomic::AtomicUsize, + active: ::std::sync::atomic::AtomicUsize, + joined: ::std::sync::atomic::AtomicUsize, + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedPublicationWorkerActivity( + ::std::sync::Arc, + ); + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationWorkerActivity { + fn new(probe: ::std::sync::Arc) -> Self { + probe + .active + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Self(probe) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationWorkerActivity { + fn drop(&mut self) { + self.0 + .active + .fetch_sub(1, ::std::sync::atomic::Ordering::SeqCst); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationReader { + release: Option<::std::sync::mpsc::Sender<()>>, + result: Option<::std::sync::mpsc::Receiver>, + worker: Option<::std::thread::JoinHandle<()>>, + probe: ::std::sync::Arc, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationReader { + fn new( + release: ::std::sync::mpsc::Sender<()>, + result: ::std::sync::mpsc::Receiver, + worker: ::std::thread::JoinHandle<()>, + probe: ::std::sync::Arc, + ) -> Self { + probe + .started + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Self { + release: Some(release), + result: Some(result), + worker: Some(worker), + probe, + } + } + + fn release(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + } + + fn receive( + &mut self, + ) -> ::std::result::Result { + self.result + .take() + .expect("an unfinished publication reader owns its result") + .recv_timeout(PreparedPublicationControl::TIMEOUT) + } + + fn join(&mut self) -> Option<::std::thread::Result<()>> { + self.worker.take().map(|worker| { + let result = worker.join(); + self.probe + .joined + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + result + }) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationReader { + fn drop(&mut self) { + self.release(); + if let Some(Err(payload)) = self.join() { + ::std::mem::forget(payload); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn join_publication_readers( + readers: [&mut PreparedPublicationReader; 2], + ) -> Option> { + let mut first_payload = None; + for reader in readers { + if let Some(Err(payload)) = reader.join() { + if first_payload.is_none() { + first_payload = Some(payload); + } else { + ::std::mem::forget(payload); + } + } + } + first_payload + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedPublicationReaderSetup { + active: Option, + queued: Option, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationReaderSetup { + fn new() -> Self { + Self { + active: None, + queued: None, + } + } + + fn own_active(&mut self, reader: PreparedPublicationReader) { + self.active = Some(reader); + } + + fn own_queued(&mut self, reader: PreparedPublicationReader) { + self.queued = Some(reader); + } + + fn install(mut self, control: &PreparedPublicationControl) { + *control + .active_reader + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = self.active.take(); + *control + .queued_reader + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = self.queued.take(); + } + + fn release_all(&mut self) { + if let Some(reader) = self.queued.as_mut() { + reader.release(); + } + if let Some(reader) = self.active.as_mut() { + reader.release(); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationReaderSetup { + fn drop(&mut self) { + self.release_all(); + match (self.active.as_mut(), self.queued.as_mut()) { + (Some(active), Some(queued)) => { + if let Some(payload) = join_publication_readers([active, queued]) { + ::std::mem::forget(payload); + } + } + (Some(reader), None) | (None, Some(reader)) => { + if let Some(Err(payload)) = reader.join() { + ::std::mem::forget(payload); + } + } + (None, None) => {} + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationControl { + placed_tx: ::std::sync::mpsc::Sender<()>, + placed_rx: ::std::sync::Mutex<::std::sync::mpsc::Receiver<()>>, + setup_failure: Option, + setup_timeout: ::std::time::Duration, + setup_probe: ::std::sync::Arc, + closing_tx: ::std::sync::Mutex>>, + closing_rx: ::std::sync::Mutex<::std::sync::mpsc::Receiver<()>>, + active_reader: ::std::sync::Mutex>, + queued_reader: ::std::sync::Mutex>, + closing_release: ::std::sync::Mutex>>, + queued_callbacks: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, + retained_token: ::std::sync::Mutex>>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationControl { + const TIMEOUT: ::std::time::Duration = ::std::time::Duration::from_secs(5); + + fn new() -> ::std::sync::Arc { + Self::with_setup_failure(None) + } + + fn with_setup_failure( + setup_failure: Option, + ) -> ::std::sync::Arc { + let (placed_tx, placed_rx) = ::std::sync::mpsc::channel(); + let (closing_tx, closing_rx) = ::std::sync::mpsc::channel(); + ::std::sync::Arc::new(Self { + placed_tx, + placed_rx: ::std::sync::Mutex::new(placed_rx), + setup_failure, + setup_timeout: if setup_failure.is_some() { + ::std::time::Duration::from_millis(100) + } else { + Self::TIMEOUT + }, + setup_probe: ::std::sync::Arc::new( + PreparedPublicationSetupProbe::default(), + ), + closing_tx: ::std::sync::Mutex::new(Some(closing_tx)), + closing_rx: ::std::sync::Mutex::new(closing_rx), + active_reader: ::std::sync::Mutex::new(None), + queued_reader: ::std::sync::Mutex::new(None), + closing_release: ::std::sync::Mutex::new(None), + queued_callbacks: ::std::sync::Arc::new( + ::std::sync::atomic::AtomicUsize::new(0), + ), + retained_token: ::std::sync::Mutex::new(None), + }) + } + + fn start(&self, token: PreparedChild) { + let state = token + .state + .upgrade() + .expect("the private owner remains live during wrapping"); + let token = ::std::sync::Arc::new(token); + let mut setup = PreparedPublicationReaderSetup::new(); + let (active_entered_tx, active_entered_rx) = ::std::sync::mpsc::channel(); + let (active_release_tx, active_release_rx) = ::std::sync::mpsc::channel(); + let (active_result_tx, active_result_rx) = ::std::sync::mpsc::channel(); + let active_token = ::std::sync::Arc::clone(&token); + let suppress_active = + self.setup_failure == Some(PreparedPublicationSetupFailure::ActiveEntered); + let active_probe = ::std::sync::Arc::clone(&self.setup_probe); + setup.own_active(PreparedPublicationReader::new( + active_release_tx, + active_result_rx, + ::std::thread::spawn(move || { + let _activity = PreparedPublicationWorkerActivity::new(active_probe); + let result = active_token.with::(|_| { + if !suppress_active { + let _ = active_entered_tx.send(()); + } + active_release_rx.recv().is_ok() + }) == Some(true); + let _ = active_result_tx.send(result); + }), + ::std::sync::Arc::clone(&self.setup_probe), + )); + active_entered_rx + .recv_timeout(self.setup_timeout) + .expect("reader A owns the prepared value before wrapping returns"); + + let (queued_reached_tx, queued_reached_rx) = ::std::sync::mpsc::channel(); + let (queued_release_tx, queued_release_rx) = ::std::sync::mpsc::channel(); + *state + .value_lock_attempt + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: queued_reached_tx, + release: queued_release_rx, + publish_reached: self.setup_failure + != Some(PreparedPublicationSetupFailure::QueuedReached), + }); + let (queued_result_tx, queued_result_rx) = ::std::sync::mpsc::channel(); + let queued_token = ::std::sync::Arc::clone(&token); + let queued_callbacks = ::std::sync::Arc::clone(&self.queued_callbacks); + let queued_probe = ::std::sync::Arc::clone(&self.setup_probe); + setup.own_queued(PreparedPublicationReader::new( + queued_release_tx, + queued_result_rx, + ::std::thread::spawn(move || { + let _activity = PreparedPublicationWorkerActivity::new(queued_probe); + let accessed = queued_token + .with::(|_| { + queued_callbacks + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + }) + .is_some(); + let _ = queued_result_tx.send(accessed); + }), + ::std::sync::Arc::clone(&self.setup_probe), + )); + queued_reached_rx + .recv_timeout(self.setup_timeout) + .expect("reader B upgraded and attempted value while A owned it"); + + let (closing_release_tx, closing_release_rx) = ::std::sync::mpsc::channel(); + let closing_tx = self + .closing_tx + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + .expect("the publication fixture arms closing once"); + *state + .after_closing + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = + Some(PreparedStateTestRendezvous { + reached: closing_tx, + release: closing_release_rx, + publish_reached: true, + }); + + setup.install(self); + *self + .closing_release + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(closing_release_tx); + *self + .retained_token + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) = Some(token); + self.placed_tx + .send(()) + .expect("the test observes both publication readers"); + } + + fn assert_setup_workers_joined(&self, expected: usize) { + assert_eq!( + self.setup_probe + .started + .load(::std::sync::atomic::Ordering::SeqCst), + expected, + "the setup seam starts the expected readers" + ); + assert_eq!( + self.setup_probe + .joined + .load(::std::sync::atomic::Ordering::SeqCst), + expected, + "every started reader is joined before setup failure returns" + ); + assert_eq!( + self.setup_probe + .active + .load(::std::sync::atomic::Ordering::SeqCst), + 0, + "no prepared reader remains active after setup failure" + ); + } + + fn wait_placed(&self) { + self.placed_rx + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .recv_timeout(Self::TIMEOUT) + .expect("the later wrapper placed active and queued readers"); + } + + fn wait_closing(&self) { + self.closing_rx + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .recv_timeout(Self::TIMEOUT) + .expect("the real lifecycle path published prepared closing"); + } + + fn release_reader( + slot: &::std::sync::Mutex>, + ) { + if let Some(reader) = slot + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .as_mut() + { + reader.release(); + } + } + + fn release_closing(&self) { + if let Some(release) = self + .closing_release + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + } + + fn release_queued(&self) { + Self::release_reader(&self.queued_reader); + } + + fn release_active(&self) { + Self::release_reader(&self.active_reader); + } + + fn release_all(&self) { + self.release_queued(); + self.release_active(); + self.release_closing(); + } + + fn collect_readers(&self) -> (bool, bool) { + let mut active = self + .active_reader + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + .expect("reader A remains owned until collection"); + let mut queued = self + .queued_reader + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + .expect("reader B remains owned until collection"); + let active_result = active.receive(); + let queued_result = queued.receive(); + if let Some(payload) = join_publication_readers([&mut active, &mut queued]) { + ::std::panic::resume_unwind(payload); + } + let active = active_result.expect("reader A completes after release"); + let queued = queued_result.expect("reader B completes after closing"); + (active, queued) + } + + fn finish_readers(&self) { + let (active, queued) = self.collect_readers(); + assert!(active, "reader A completes its finite admitted callback"); + assert!(!queued, "reader B is rejected after closing publication"); + assert_eq!( + self.queued_callbacks + .load(::std::sync::atomic::Ordering::SeqCst), + 0 + ); + } + + fn assert_closed(&self) { + let token = self + .retained_token + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + assert!( + token + .as_ref() + .expect("the fixture retains its weak token") + .with::(|_| ()) + .is_none(), + "the retained token cannot access state after close" + ); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationControl { + fn drop(&mut self) { + let mut active = self + .active_reader + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take(); + let mut queued = self + .queued_reader + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take(); + if let Some(reader) = queued.as_mut() { + reader.release(); + } + if let Some(reader) = active.as_mut() { + reader.release(); + } + if let Some(release) = self + .closing_release + .get_mut() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + match (active.as_mut(), queued.as_mut()) { + (Some(active), Some(queued)) => { + if let Some(payload) = join_publication_readers([active, queued]) { + ::std::mem::forget(payload); + } + } + (Some(reader), None) | (None, Some(reader)) => { + if let Some(Err(payload)) = reader.join() { + ::std::mem::forget(payload); + } + } + (None, None) => {} + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationLayer { + inner: Option>, + prepared: Option, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl ChildWrapperLayer for PreparedPublicationLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl $childer for PreparedPublicationLayer { + fn inner(&self) -> &dyn $childer { + self.inner + .as_deref() + .expect("the publication layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self.inner + .as_deref_mut() + .expect("the publication layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("the publication layer owns its child") + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationProducer { + drops: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationProducer { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn $childer, + _command: &Command, + ) -> ::std::io::Result>> { + Ok(Some(Box::new(PreparedRaceDrop(::std::sync::Arc::clone( + &self.drops, + ))))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn $childer, + prepared: Option>, + _command: &Command, + ) -> ::std::io::Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::)); + Ok(Some(PendingChildWrapper::new(PreparedPublicationLayer { + inner: None, + prepared: None, + }))) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationReaders { + start_once: bool, + control: ::std::sync::Arc, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationReaders { + fn wrap_prepared_child( + &mut self, + child: &mut dyn $childer, + prepared: Option>, + _command: &Command, + ) -> ::std::io::Result> { + assert!(prepared.is_none()); + if self.start_once { + self.start_once = false; + let layer = (child as &mut dyn ::std::any::Any) + .downcast_mut::() + .expect("the earlier publication layer is directly reachable"); + let token = layer + .prepared + .take() + .expect("the earlier layer contains its installed weak token"); + self.control.start(token); + } + Ok(None) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationFailure(bool); + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationFailure { + fn wrap_child( + &mut self, + _child: &mut dyn $childer, + _command: &Command, + ) -> ::std::io::Result> { + if ::std::mem::take(&mut self.0) { + Err(::std::io::Error::other("later publication wrapping failed")) + } else { + Ok(None) + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationChild; + + #[cfg(all(test, windows, feature = "job-object"))] + impl $childer for PreparedPublicationChild { + fn inner(&self) -> &dyn $childer { + self + } + fn inner_mut(&mut self) -> &mut dyn $childer { + self + } + fn into_inner(self: Box) -> Box { + self + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug, Default)] + struct PreparedPublicationTransactionState { + commits: ::std::sync::atomic::AtomicUsize, + rollbacks: ::std::sync::atomic::AtomicUsize, + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationTransaction( + ::std::sync::Arc, + ); + + #[cfg(all(test, windows, feature = "job-object"))] + impl crate::SpawnTransaction for PreparedPublicationTransaction { + fn commit(&mut self) -> ::std::io::Result<()> { + self.0 + .commits + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + fn rollback(&mut self) -> ::std::io::Result<()> { + self.0 + .rollbacks + .fetch_add(1, ::std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationProvider { + state: ::std::sync::Arc, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl SpawnProvider for PreparedPublicationProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> ::std::io::Result { + Ok(ProviderProduct::new( + Box::new(PreparedPublicationChild), + Box::new(PreparedPublicationTransaction(::std::sync::Arc::clone( + &self.state, + ))), + )) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[derive(Debug)] + struct PreparedPublicationProviderWrapper(PreparedPublicationProvider); + + #[cfg(all(test, windows, feature = "job-object"))] + impl CommandWrapper for PreparedPublicationProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + struct PreparedPublicationThreadCleanup { + control: ::std::sync::Arc, + workers: Vec<::std::thread::JoinHandle<()>>, + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl PreparedPublicationThreadCleanup { + fn new(control: ::std::sync::Arc) -> Self { + Self { + control, + workers: Vec::new(), + } + } + fn push(&mut self, worker: ::std::thread::JoinHandle<()>) { + self.workers.push(worker); + } + fn finish(mut self) { + self.control.release_all(); + for worker in ::std::mem::take(&mut self.workers) { + worker.join().expect("publication lifecycle worker does not panic"); + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + impl Drop for PreparedPublicationThreadCleanup { + fn drop(&mut self) { + self.control.release_all(); + for worker in ::std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + ::std::mem::forget(payload); + } + } + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn prepared_publication_command( + provider: bool, + fail_later: bool, + control: ::std::sync::Arc, + drops: ::std::sync::Arc<::std::sync::atomic::AtomicUsize>, + transaction: ::std::sync::Arc, + ) -> Command { + let mut command = if provider { + Command::new("provider-owned-program") + } else { + Command::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }) + }; + if provider { + command.wrap(PreparedPublicationProviderWrapper( + PreparedPublicationProvider { state: transaction }, + )); + } + command + .wrap(PreparedPublicationProducer { drops }) + .wrap(PreparedPublicationReaders { + start_once: true, + control, + }); + if fail_later { + command.wrap(PreparedPublicationFailure(true)); + } + command + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn run_prepared_publication_spawn( + mut command: Command, + ) -> (Command, ::std::io::Result>) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build prepared-publication runtime"); + let _runtime_guard = runtime.enter(); + let result = command.spawn(); + (command, result) + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_published_active_and_queued_readers_allow_spawn_return(provider: bool) { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let transaction = ::std::sync::Arc::new(PreparedPublicationTransactionState::default()); + let control = PreparedPublicationControl::new(); + let command = prepared_publication_command( + provider, + false, + ::std::sync::Arc::clone(&control), + ::std::sync::Arc::clone(&drops), + ::std::sync::Arc::clone(&transaction), + ); + let (spawned_tx, spawned_rx) = ::std::sync::mpsc::channel(); + let mut cleanup = PreparedPublicationThreadCleanup::new(::std::sync::Arc::clone( + &control, + )); + cleanup.push(::std::thread::spawn(move || { + let _ = spawned_tx.send(run_prepared_publication_spawn(command)); + })); + control.wait_placed(); + + let first = spawned_rx.recv_timeout(::std::time::Duration::from_secs(2)); + let returned_success_while_held = matches!(&first, Ok((_, Ok(_)))); + if !returned_success_while_held { + control.release_all(); + let outcome = match first { + Ok(outcome) => outcome, + Err(::std::sync::mpsc::RecvTimeoutError::Timeout) => spawned_rx + .recv_timeout(PreparedPublicationControl::TIMEOUT) + .expect("spawn returns after emergency publication-reader release"), + Err(::std::sync::mpsc::RecvTimeoutError::Disconnected) => { + cleanup.finish(); + panic!("the publication spawn worker disconnected") + } + }; + let _ = control.collect_readers(); + drop(outcome); + cleanup.finish(); + panic!("public spawn did not return while active and queued readers remained held"); + } + + let (mut command, result) = first.expect("successful first publication outcome"); + let child = result.expect("published readers cannot reject the successful spawn"); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 0); + let (closed_tx, closed_rx) = ::std::sync::mpsc::channel(); + cleanup.push(::std::thread::spawn(move || { + drop(child); + let _ = closed_tx.send(()); + })); + control.wait_closing(); + assert_eq!( + closed_rx.try_recv(), + Err(::std::sync::mpsc::TryRecvError::Empty) + ); + control.release_queued(); + control.release_active(); + control.finish_readers(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 0); + control.release_closing(); + closed_rx + .recv_timeout(PreparedPublicationControl::TIMEOUT) + .expect("returned-child close completes after reader release"); + control.assert_closed(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + + let (returned_command, reused) = run_prepared_publication_spawn(command); + command = returned_command; + drop(reused.expect("the command remains reusable")); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 2); + if provider { + assert_eq!(transaction.commits.load(::std::sync::atomic::Ordering::SeqCst), 2); + assert_eq!(transaction.rollbacks.load(::std::sync::atomic::Ordering::SeqCst), 0); + } + drop(command); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_later_failure_rejects_published_queued_reader(provider: bool) { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let transaction = ::std::sync::Arc::new(PreparedPublicationTransactionState::default()); + let control = PreparedPublicationControl::new(); + let command = prepared_publication_command( + provider, + true, + ::std::sync::Arc::clone(&control), + ::std::sync::Arc::clone(&drops), + ::std::sync::Arc::clone(&transaction), + ); + let (finished_tx, finished_rx) = ::std::sync::mpsc::channel(); + let mut cleanup = PreparedPublicationThreadCleanup::new(::std::sync::Arc::clone( + &control, + )); + cleanup.push(::std::thread::spawn(move || { + let _ = finished_tx.send(run_prepared_publication_spawn(command)); + })); + control.wait_placed(); + control.wait_closing(); + let early = finished_rx.try_recv(); + let remained_pending = matches!( + &early, + Err(::std::sync::mpsc::TryRecvError::Empty) + ); + control.release_queued(); + control.release_active(); + control.finish_readers(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 0); + control.release_closing(); + let (mut command, result) = match early { + Ok(outcome) => outcome, + Err(::std::sync::mpsc::TryRecvError::Empty) => finished_rx + .recv_timeout(PreparedPublicationControl::TIMEOUT) + .expect("failure cleanup completes after reader release"), + Err(::std::sync::mpsc::TryRecvError::Disconnected) => { + cleanup.finish(); + panic!("the publication spawn worker disconnected") + } + }; + assert!( + remained_pending, + "failure cleanup returned while A owned the prepared value" + ); + let error = result.expect_err("the later wrapping callback fails"); + assert_eq!(error.to_string(), "later publication wrapping failed"); + control.assert_closed(); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + if provider { + assert_eq!(transaction.commits.load(::std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(transaction.rollbacks.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + + let (returned_command, reused) = run_prepared_publication_spawn(command); + command = returned_command; + drop(reused.expect("the command remains reusable")); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 2); + if provider { + assert_eq!(transaction.commits.load(::std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(transaction.rollbacks.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + drop(command); + cleanup.finish(); + } + + #[cfg(all(test, windows, feature = "job-object"))] + fn assert_publication_reader_setup_failure_cleanup( + failure: PreparedPublicationSetupFailure, + expected_workers: usize, + ) { + let drops = ::std::sync::Arc::new(::std::sync::atomic::AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedRaceDrop( + ::std::sync::Arc::clone(&drops), + ))); + let control = PreparedPublicationControl::with_setup_failure(Some(failure)); + let outcome = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { + control.start(owner.installed_token()); + })); + assert!(outcome.is_err(), "the suppressed setup event must fail"); + control.assert_setup_workers_joined(expected_workers); + drop(owner); + assert_eq!(drops.load(::std::sync::atomic::Ordering::SeqCst), 1); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn missing_active_reader_setup_event_releases_and_joins_started_worker() { + assert_publication_reader_setup_failure_cleanup( + PreparedPublicationSetupFailure::ActiveEntered, + 1, + ); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn missing_queued_reader_setup_event_releases_and_joins_started_workers() { + assert_publication_reader_setup_failure_cleanup( + PreparedPublicationSetupFailure::QueuedReached, + 2, + ); + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn published_active_and_queued_readers_allow_native_and_provider_spawn_return() { + for provider in [false, true] { + assert_published_active_and_queued_readers_allow_spawn_return(provider); + } + } + + #[cfg(all(test, windows, feature = "job-object"))] + #[test] + fn later_failure_rejects_published_queued_reader_for_native_and_provider() { + for provider in [false, true] { + assert_later_failure_rejects_published_queued_reader(provider); + } + } + + #[cfg(windows)] + struct PreparedChildSlot<'a> { + value: &'a mut Option, + expected_type: ::std::any::TypeId, + } + + /// Empty ownership slots for a child layer returned by a wrapping hook. + /// + /// A layer keeps these slots empty while its hook runs. Process-wrap fills them only after the + /// hook returns successfully, so failure cleanup remains outside caller-defined callbacks. + pub struct ChildWrapperSlots<'a> { + child: &'a mut Option>, + #[cfg(windows)] + prepared: Option>, + } + + impl<'a> ChildWrapperSlots<'a> { + /// Describe a detached layer's empty child slot. + pub fn new(child: &'a mut Option>) -> Self { + Self { + child, + #[cfg(windows)] + prepared: None, + } + } + + /// Describe an empty prepared-state slot paired with concrete state type `T`. + /// + /// Process-wrap verifies the pending value's concrete type and installs a non-owning token + /// after the wrapping callback returns successfully. The private strong owner remains in + /// process-wrap storage. + #[cfg(windows)] + #[doc(hidden)] + pub fn with_prepared( + mut self, + prepared: &'a mut Option, + ) -> Self { + self.prepared = Some(PreparedChildSlot { + value: prepared, + expected_type: ::std::any::TypeId::of::(), + }); + self + } + } + + /// A detached child layer whose ownership slots process-wrap fills after its hook returns. + /// + /// Implement this trait for a layer returned through [`PendingChildWrapper::new`]. The layer's + /// child slot must be empty until process-wrap installs the current child. + pub trait ChildWrapperLayer: $childer { + /// Expose this detached layer's ownership slots. + /// + /// Process-wrap calls this method once during installation. The child slot and any declared + /// prepared-state token slot must be empty at that point. + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_>; + } + + /// A child layer awaiting failure-atomic installation by process-wrap. + /// + /// Wrapping hooks return this value instead of taking ownership of the current child. If a hook + /// succeeds, process-wrap installs that child into the layer's declared empty slot. + pub struct PendingChildWrapper { + layer: Box, + install: fn( + &mut dyn $childer, + &mut Option>, + #[cfg(windows)] Option<&mut PreparedChildOwner>, + ) -> ::std::io::Result<()>, + } + + impl ::std::fmt::Debug for PendingChildWrapper { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.layer.fmt(formatter) + } + } + + impl PendingChildWrapper { + /// Return a detached child layer for failure-atomic installation. + /// + /// `layer.child_wrapper_slots()` must expose an empty child slot. Process-wrap validates the + /// slot and fills it after the wrapping callback has returned. + pub fn new(layer: L) -> Self + where + L: ChildWrapperLayer + 'static, + { + Self { + layer: Box::new(layer), + install: Self::install_layer::, + } + } + + fn install_layer( + layer: &mut dyn $childer, + child: &mut Option>, + #[cfg(windows)] prepared: Option<&mut PreparedChildOwner>, + ) -> ::std::io::Result<()> + where + L: ChildWrapperLayer + 'static, + { + let layer = (layer as &mut dyn ::std::any::Any) + .downcast_mut::() + .expect("a pending child layer retains its concrete type"); + #[cfg(windows)] + let installed_layer = ( + ::std::any::TypeId::of::(), + ::std::ptr::from_mut(&mut *layer).cast::<()>() as usize, + ); + let slots = layer.child_wrapper_slots(); + if slots.child.is_some() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a pending child layer must have an empty child slot", + )); + } + #[cfg(windows)] + match (prepared, slots.prepared) { + (Some(prepared), Some(slot)) if slot.value.is_none() => { + if prepared.state.type_id != slot.expected_type { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state does not match the layer slot type", + )); + } + prepared.installed_layer = Some(installed_layer); + *slot.value = Some(prepared.installed_token()); + #[cfg(all(test, feature = "job-object"))] + if crate::windows::test_support::take_extra_prepared_owner_injection() { + crate::windows::test_support::retain_extra_prepared_owner(Box::new( + ::std::sync::Arc::clone(&prepared.state), + )); + } + if !prepared.has_exclusive_custody() { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state has an unexpected custody topology", + )); + } + } + (Some(_), _) => { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state requires an empty matching layer slot", + )); + } + (None, Some(slot)) if slot.value.is_some() => { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a pending prepared-state slot must be empty", + )); + } + (None, Some(_)) => { + return Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "a prepared-state slot requires matching prepared child state", + )); + } + (None, None) => {} + } + *slots.child = Some(child.take().expect("the lifecycle retains child custody")); + Ok(()) + } + + fn install( + &mut self, + child: &mut Option>, + #[cfg(windows)] prepared: Option<&mut PreparedChildOwner>, + ) -> ::std::io::Result<()> { + (self.install)( + self.layer.as_mut(), + child, + #[cfg(windows)] + prepared, + ) + } + + fn into_child(self) -> Box { + self.layer + } + } + + enum SpawnFailure { + Error(::std::io::Error), + Panic(Box), + } + + impl SpawnFailure { + fn finish(self) -> ::std::io::Result { + match self { + Self::Error(error) => Err(error), + Self::Panic(payload) => ::std::panic::resume_unwind(payload), + } + } + } + + #[derive(Debug)] + enum ProviderTransactionState { + Armed(Box), + Committed(Box), + Transferred, + } + + impl ProviderTransactionState { + fn new(transaction: Box) -> Self { + Self::Armed(transaction) + } + + fn commit(&mut self) -> ::std::io::Result<()> { + let Self::Armed(transaction) = self else { + unreachable!("only an armed provider transaction can commit"); + }; + transaction.commit()?; + let Self::Armed(transaction) = ::std::mem::replace(self, Self::Transferred) else { + unreachable!("the provider transaction remains armed until commit returns"); + }; + *self = Self::Committed(transaction); + Ok(()) + } + + fn take_for_failure(&mut self) -> Option<(bool, Box)> { + match ::std::mem::replace(self, Self::Transferred) { + Self::Armed(transaction) => Some((true, transaction)), + Self::Committed(transaction) => Some((false, transaction)), + Self::Transferred => None, + } + } + + fn transfer_into(&mut self, sidecar: &mut CommittedProviderChild) { + let Self::Committed(transaction) = ::std::mem::replace(self, Self::Transferred) else { + unreachable!("only committed transaction residue can transfer to a child"); + }; + sidecar.install_residue(transaction); + } + } + + #[cfg(windows)] + struct PreparedOwnerChild { + // Field order is intentional: the complete child chain drops before prepared storage and + // the disarmed duplicate process handle. + child: Option>, + prepared: Vec>, + spawn_cleanup: Option, + } + + #[cfg(windows)] + impl PreparedOwnerChild { + fn new( + child: Box, + prepared: Vec>, + ) -> Box { + Self::new_with_spawn_cleanup(child, prepared, None) + } + + fn new_with_spawn_cleanup( + child: Box, + prepared: Vec>, + spawn_cleanup: Option, + ) -> Box { + debug_assert!(prepared.iter().any(Option::is_some) || spawn_cleanup.is_some()); + Box::new(Self { + child: Some(child), + prepared, + spawn_cleanup, + }) + } + + fn child_ref(&self) -> &dyn $childer { + self.child + .as_deref() + .expect("a prepared-owner sidecar retains its child") + } + + fn child_mut(&mut self) -> &mut dyn $childer { + self.child + .as_deref_mut() + .expect("a prepared-owner sidecar retains its child") + } + + fn take_child(&mut self) -> Option> { + self.child.take() + } + + fn take_spawn_cleanup(&mut self) -> Option { + self.spawn_cleanup.take() + } + + fn disarm_spawn_cleanup(&mut self) { + if let Some(cleanup) = self.spawn_cleanup.as_mut() { + cleanup.disarm(); + } + } + } + + #[cfg(windows)] + impl ::std::fmt::Debug for PreparedOwnerChild { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.child_ref().fmt(formatter) + } + } + + #[cfg(windows)] + impl $childer for PreparedOwnerChild { + fn inner(&self) -> &dyn $childer { + self.child_ref() + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self.child_mut() + } + + fn into_inner(mut self: Box) -> Box { + let removed_layer = ( + (self.child_ref() as &dyn ::std::any::Any).type_id(), + ::std::ptr::from_mut(self.child_mut()).cast::<()>() as usize, + ); + #[cfg(feature = "job-object")] + self.child_mut().retain_prepared_after_sidecar_removal(); + let child = self + .take_child() + .expect("a prepared-owner sidecar retains its child"); + let inner = child.into_inner(); + self.prepared.retain(|prepared| { + prepared.as_ref().is_some_and(|prepared| { + prepared.has_value() && !prepared.installed_in(removed_layer) + }) + }); + if self.prepared.is_empty() { + // Consuming the public sidecar ends its custody. `self` then closes any disarmed + // duplicate handle exactly once while the extracted child moves to the caller. + inner + } else { + self.child = Some(inner); + self + } + } + + fn try_clone(&self) -> Option> { + None + } + + #[cfg(windows)] + fn process_handle(&self) -> Option<::std::os::windows::io::BorrowedHandle<'_>> { + self.child_ref().process_handle() + } + + #[cfg(windows)] + fn resume_after_job_assignment(&mut self) -> Option<::std::io::Result<()>> { + self.child_mut().resume_after_job_assignment() + } + + $prepared_owner_contract!(); + } + + #[cfg(windows)] + enum NativeSuccessChild { + Plain(Box), + Sidecar(Box), + } + + #[cfg(windows)] + impl NativeSuccessChild { + fn new( + child: Box, + prepared: Vec>, + spawn_cleanup: Option, + ) -> Self { + if prepared.iter().any(Option::is_some) || spawn_cleanup.is_some() { + Self::Sidecar(PreparedOwnerChild::new_with_spawn_cleanup( + child, + prepared, + spawn_cleanup, + )) + } else { + Self::Plain(child) + } + } + + fn child_mut(&mut self) -> &mut dyn $childer { + match self { + Self::Plain(child) => child.as_mut(), + Self::Sidecar(child) => child.as_mut(), + } + } + + fn take_spawn_cleanup(&mut self) -> Option { + match self { + Self::Plain(_) => None, + Self::Sidecar(child) => child.take_spawn_cleanup(), + } + } + + fn disarm_spawn_cleanup(&mut self) { + if let Self::Sidecar(child) = self { + child.disarm_spawn_cleanup(); + } + } + + fn into_child(self) -> Box { + match self { + Self::Plain(child) => child, + Self::Sidecar(child) => child, + } + } + } + + struct CommittedProviderChild { + // Field order is intentional: child layers drop before transaction and prepared residue. + child: Option>, + residue: ::std::sync::Arc< + ::std::sync::Mutex>>, + >, + #[cfg(windows)] + prepared: Vec>, + } + + impl CommittedProviderChild { + fn new( + child: Box, + #[cfg(windows)] prepared: Vec>, + ) -> Box { + Box::new(Self { + child: Some(child), + residue: ::std::sync::Arc::new(::std::sync::Mutex::new(None)), + #[cfg(windows)] + prepared, + }) + } + + fn child_ref(&self) -> &dyn $childer { + self.child + .as_deref() + .expect("a committed-provider sidecar retains its child") + } + + fn child_mut(&mut self) -> &mut dyn $childer { + self.child + .as_deref_mut() + .expect("a committed-provider sidecar retains its child") + } + + fn take_child(&mut self) -> Option> { + self.child.take() + } + + fn install_residue(&mut self, transaction: Box) { + let mut residue = self + .residue + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner); + debug_assert!(residue.is_none()); + *residue = Some(transaction); + } + + fn take_residue(&mut self) -> Option> { + self.residue + .lock() + .unwrap_or_else(::std::sync::PoisonError::into_inner) + .take() + } + } + + impl ::std::fmt::Debug for CommittedProviderChild { + fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.child_ref().fmt(formatter) + } + } + + impl $childer for CommittedProviderChild { + fn inner(&self) -> &dyn $childer { + self.child_ref() + } + + fn inner_mut(&mut self) -> &mut dyn $childer { + self.child_mut() + } + + fn into_inner(mut self: Box) -> Box { + #[cfg(all(windows, feature = "job-object"))] + self.child_mut().retain_prepared_after_sidecar_removal(); + let child = self + .take_child() + .expect("a committed-provider sidecar retains its child"); + #[cfg(windows)] + { + let mut prepared = ::std::mem::take(&mut self.prepared); + prepared.retain(|prepared| { + prepared + .as_ref() + .is_some_and(PreparedChildOwner::has_value) + }); + if !prepared.is_empty() { + return PreparedOwnerChild::new(child, prepared); + } + } + child + } + + #[cfg(windows)] + fn process_handle(&self) -> Option<::std::os::windows::io::BorrowedHandle<'_>> { + self.child_ref().process_handle() + } + + #[cfg(windows)] + fn resume_after_job_assignment(&mut self) -> Option<::std::io::Result<()>> { + self.child_mut().resume_after_job_assignment() + } + + fn try_clone(&self) -> Option> { + #[cfg(windows)] + if self.prepared.iter().any(Option::is_some) { + return None; + } + self.child_ref().try_clone().map(|child| { + Box::new(Self { + child: Some(child), + residue: ::std::sync::Arc::clone(&self.residue), + #[cfg(windows)] + prepared: self.prepared.iter().map(|_| None).collect(), + }) as Box + }) + } + } + /// An alternate transport for spawning this frontend's child contract. /// /// Providers are exposed by command wrappers through [`CommandWrapper::spawn_provider`]. A @@ -96,10 +2405,26 @@ macro_rules! Wrap { /// /// Process-wrap invokes provider callbacks in this order: `check_available`, native-only base /// rejection, `validate_command`, every `pre_spawn` hook in registration order, native-only - /// attempt rejection, `validate_attempt`, and `spawn`. After `spawn` returns a product, every - /// `post_spawn` and child-wrapping hook runs in registration order before process-wrap commits the - /// product's transaction. `spawn_with` and `spawn_with_child` reject a registered provider instead - /// of bypassing it. + /// attempt rejection, `validate_attempt`, and `spawn`. `spawn` returns a child plus a fresh armed + /// transaction. On Windows, fallible prepared-state construction then runs before every public + /// `post_spawn` hook. Post hooks and child-layer descriptions run in registration order; detached + /// installation fills matching weak prepared tokens, validates private strong custody, and only + /// then publishes each layer. On Windows, ordinary finalization and cleanup disarm, cleanup-owner + /// validation, and non-owner disarm all run before the fallible return-custody allocation and + /// transaction commit while rollback remains armed. Process-wrap commits, transfers committed + /// residue into the ready return custody, and then disarms the sole final owner. Any earlier error + /// or unwinding panic rolls back first and independently disposes remaining lifecycle values while + /// quarantining secondary panic payloads. Successful commit ends failed-spawn rollback. After the + /// final owner succeeds, only infallible private ownership moves remain before return. Arbitrary + /// residue destruction occurs outside the successful spawn lifecycle. `spawn_with` and + /// `spawn_with_child` reject a registered provider instead of bypassing it. + /// + /// A committed transaction residue must retain no armed cleanup or independent process, + /// terminal, controller, handle, pseudoconsole, or other liveness resource. + /// + /// Rollback, wrapper restoration, and original panic-payload preservation apply only to + /// unwinding panics. With `panic=abort`, the process terminates before lifecycle recovery can + /// run. pub trait SpawnProvider: ::std::fmt::Debug + Send + Sync + 'static { /// Check whether this provider is available on the current platform and runtime. /// @@ -135,7 +2460,7 @@ macro_rules! Wrap { /// /// The provider must honor every portable setting accepted by `validate_attempt`. Until this /// method returns a `ProviderProduct`, it remains responsible for cleaning up resources and any - /// child it creates if it returns an error or panics. + /// child it creates if it returns an error or an unwinding panic. fn spawn( &self, attempt: &mut SpawnAttempt, @@ -287,34 +2612,45 @@ macro_rules! Wrap { Ok(()) } + fn capture_io( + invoke: impl FnOnce() -> ::std::io::Result, + ) -> ::std::result::Result { + match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(invoke)) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(SpawnFailure::Error(error)), + Err(payload) => Err(SpawnFailure::Panic(payload)), + } + } + #[cfg(windows)] #[inline] fn run_prepare_child( &mut self, attempt: &mut SpawnAttempt, child: &mut dyn $childer, - ) -> ::std::io::Result< - Vec>>, - > { + prepared: &mut Vec>, + ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); - let mut prepared = Vec::with_capacity(len); for index in 0..len { - #[cfg(feature = "tracing")] - { - 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) - })?); + let value = Self::capture_io(|| { + #[cfg(feature = "tracing")] + { + let id = self + .wrapper_registry() + .wrappers + .get_index(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .0; + ::tracing::debug!(?id, "prepare_child"); + } + self.with_wrapper_at(index, |wrapper, command| { + wrapper.prepare_child(attempt, child, command) + }) + })?; + prepared.push(value.map(PreparedChildOwner::new)); } - Ok(prepared) + Ok(()) } #[inline] @@ -322,21 +2658,23 @@ macro_rules! Wrap { &mut self, attempt: &mut SpawnAttempt, child: &mut dyn $childer, - ) -> ::std::io::Result<()> { + ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { - #[cfg(feature = "tracing")] - { - 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) + Self::capture_io(|| { + #[cfg(feature = "tracing")] + { + let id = self + .wrapper_registry() + .wrappers + .get_index(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .0; + ::tracing::debug!(?id, "post_spawn"); + } + self.with_wrapper_at(index, |wrapper, command| { + wrapper.post_spawn(attempt, child, command) + }) })?; } @@ -346,53 +2684,101 @@ macro_rules! Wrap { #[inline] fn run_wrap_child( &mut self, - mut child: Box, - #[cfg(windows)] mut prepared: Vec< - Option>, - >, - ) -> ::std::io::Result> { + child: &mut Option>, + pending: &mut Option, + #[cfg(all(unix, feature = "process-group"))] spawned_id: Option, + #[cfg(windows)] prepared: &mut [Option], + ) -> ::std::result::Result<(), SpawnFailure> { let len = self.wrapper_registry().wrappers.len(); for index in 0..len { - #[cfg(feature = "tracing")] - { - let id = self - .wrapper_registry() - .wrappers - .get_index(index) - .expect("wrapper indices cannot disappear during ordered hook traversal") - .0; - ::tracing::debug!(?id, "wrap_child"); - } - child = self.with_wrapper_at(index, |wrapper, command| { - #[cfg(windows)] + let layer = Self::capture_io(|| { + #[cfg(feature = "tracing")] { - wrapper.wrap_prepared_child( + let id = self + .wrapper_registry() + .wrappers + .get_index(index) + .expect("wrapper indices cannot disappear during ordered hook traversal") + .0; + ::tracing::debug!(?id, "wrap_child"); + } + self.with_wrapper_at(index, |wrapper, command| { + let child = child + .as_deref_mut() + .expect("process-wrap retains child custody between wrapping hooks"); + #[cfg(windows)] + { + wrapper.wrap_prepared_child( + child, + prepared[index].as_ref().map(PreparedChildOwner::view), + command, + ) + } + #[cfg(all(not(windows), unix, feature = "process-group"))] + { + wrapper.wrap_child_with_spawned_id(child, spawned_id, command) + } + #[cfg(all(not(windows), not(all(unix, feature = "process-group"))))] + { + wrapper.wrap_child(child, command) + } + }) + })?; + *pending = layer; + + if let Some(layer) = pending.as_mut() { + Self::capture_io(|| { + layer.install( child, - prepared[index].take(), - command, + #[cfg(windows)] + prepared[index].as_mut(), ) + })?; + let layer = pending + .take() + .expect("the installed child layer remains in custody"); + debug_assert!(child.is_none()); + *child = Some(layer.into_child()); + } else { + #[cfg(windows)] + if prepared[index].is_some() { + return Err(SpawnFailure::Error(::std::io::Error::new( + ::std::io::ErrorKind::InvalidInput, + "prepared child state requires a matching child layer", + ))); } - #[cfg(not(windows))] - { - wrapper.wrap_child(child, command) - } - })?; + } } - Ok(child) + Ok(()) } fn finish_spawn( &mut self, attempt: &mut SpawnAttempt, - mut child: Box, + child: Box, ) -> ::std::io::Result> { + let mut child = Some(child); + let mut pending = None; #[cfg(windows)] - let mut cleanup = if attempt.starts_suspended() { - let handle = match child.as_ref().try_process_handle() { + let mut prepared = Vec::with_capacity(self.wrapper_registry().wrappers.len()); + #[cfg(windows)] + let mut cleanup = match Self::capture_io(|| { + if !attempt.starts_suspended() { + return Ok(None); + } + let handle = match child + .as_ref() + .expect("the native lifecycle retains child custody") + .as_ref() + .try_process_handle() + { Some(handle) => handle, None => { - let _ = child.start_kill(); + let _ = child + .as_deref_mut() + .expect("the native lifecycle retains child custody") + .start_kill(); return Err(::std::io::Error::new( ::std::io::ErrorKind::Unsupported, "child wrapper does not expose a Windows process handle", @@ -400,45 +2786,202 @@ macro_rules! Wrap { } }; match crate::command::WindowsSpawnCleanup::new(handle) { - Ok(cleanup) => Some(cleanup), + Ok(cleanup) => Ok(Some(cleanup)), Err(error) => { let _ = crate::command::terminate_process_and_wait(handle); - return Err(error); + Err(error) } } - } else { - None + }) { + Ok(cleanup) => cleanup, + Err(failure) => { + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); + } }; - let result = (|| { - #[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))] + let before_owner: ::std::result::Result<(), SpawnFailure> = (|| { + #[cfg(all(unix, feature = "process-group"))] { - self.run_wrap_child(child) + let spawned_id = Self::capture_io(|| { + Ok(child + .as_deref() + .expect("the spawn lifecycle retains child custody") + .try_spawned_id()) + })?; + attempt.set_spawned_id(spawned_id); } + #[cfg(windows)] + self.run_prepare_child( + attempt, + child + .as_deref_mut() + .expect("the native lifecycle retains child custody"), + &mut prepared, + )?; + self.run_post_spawn( + attempt, + child + .as_deref_mut() + .expect("the native lifecycle retains child custody"), + )?; + self.run_wrap_child( + &mut child, + &mut pending, + #[cfg(all(unix, feature = "process-group"))] + attempt.spawned_id(), + #[cfg(windows)] + &mut prepared, + )?; + Ok(()) })(); + + if let Err(failure) = before_owner { + #[cfg(windows)] + drop(cleanup.take()); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + #[cfg(windows)] + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); + } + + #[cfg(not(windows))] + return Ok(child + .take() + .expect("the completed native lifecycle retains its child")); + #[cfg(windows)] - let result = result.and_then(|mut child| { - child.finalize_spawn()?; - if let Some(cleanup) = cleanup.as_mut() { - cleanup.disarm(); + { + let child = child + .take() + .expect("the native lifecycle retains its child before finalization"); + let mut success = NativeSuccessChild::new( + child, + ::std::mem::take(&mut prepared), + cleanup, + ); + let final_owner = match Self::capture_io(|| { + success.child_mut().finalize_spawn_before_commit() + }) { + Ok(owner) => owner, + Err(failure) => { + Self::cleanup_native_spawn_guard(&mut success); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_native_success(success); + return failure.finish::>(); + } + }; + attempt.prepare_for_final_owner(); + let final_result = Self::capture_io(|| { + success + .child_mut() + .finalize_spawn_final_owner(final_owner) + }); + match final_result { + Ok(()) => { + success.disarm_spawn_cleanup(); + Ok(success.into_child()) + } + Err(failure) => { + Self::cleanup_native_spawn_guard(&mut success); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_native_success(success); + failure.finish::>() + } } - Ok(child) - }); - result + } + } + + fn quarantine_cleanup_panic(result: ::std::thread::Result<()>) { + if let Err(payload) = result { + ::std::mem::forget(payload); + } + } + + fn dispose_value(value: T) { + let disposal = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { + drop(value); + })); + Self::quarantine_cleanup_panic(disposal); + } + + fn cleanup_pending_child(pending: &mut Option) { + if let Some(pending) = pending.take() { + Self::dispose_value(pending); + } + } + + fn cleanup_child(child: &mut Option>) { + if let Some(child) = child.take() { + Self::dispose_value(child); + } + } + + #[cfg(windows)] + fn cleanup_prepared(prepared: &mut [Option]) { + for prepared in prepared.iter_mut().filter_map(Option::take) { + if let Some(value) = prepared.close_and_take() { + Self::dispose_value(value); + } + } + } + + #[cfg(windows)] + fn cleanup_native_spawn_guard(success: &mut NativeSuccessChild) { + if let Some(cleanup) = success.take_spawn_cleanup() { + Self::dispose_value(cleanup); + } + } + + #[cfg(windows)] + fn cleanup_native_success(mut success: NativeSuccessChild) { + Self::cleanup_native_spawn_guard(&mut success); + match success { + NativeSuccessChild::Plain(child) => Self::dispose_value(child), + NativeSuccessChild::Sidecar(mut sidecar) => { + Self::cleanup_child(&mut sidecar.child); + Self::cleanup_prepared(&mut sidecar.prepared); + Self::dispose_value(sidecar); + } + } } - fn rollback_transaction(transaction: Box) { - let _ = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { - let mut transaction = transaction; + fn dispose_transaction(transaction: Box) { + Self::dispose_value(transaction); + } + + fn rollback_transaction(mut transaction: Box) { + let rollback = ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { let _ = transaction.rollback(); })); + Self::quarantine_cleanup_panic(rollback); + Self::dispose_transaction(transaction); + } + + fn cleanup_failed_transaction(transaction: &mut ProviderTransactionState) { + let Some((armed, transaction)) = transaction.take_for_failure() else { + return; + }; + if armed { + Self::rollback_transaction(transaction); + } else { + Self::dispose_transaction(transaction); + } + } + + fn cleanup_provider_residue(sidecar: &mut CommittedProviderChild) { + if let Some(transaction) = sidecar.take_residue() { + Self::dispose_transaction(transaction); + } + } + + fn cleanup_provider_child(sidecar: &mut CommittedProviderChild) { + Self::cleanup_child(&mut sidecar.child); + #[cfg(windows)] + Self::cleanup_prepared(&mut sidecar.prepared); } fn finish_provider_spawn( @@ -446,49 +2989,109 @@ macro_rules! Wrap { 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"), - ); + let (child, transaction) = product.into_parts(); + let mut child = Some(child); + let mut pending = None; + let mut transaction = ProviderTransactionState::new(transaction); + #[cfg(windows)] + let mut prepared = Vec::with_capacity(self.wrapper_registry().wrappers.len()); + let before_commit: ::std::result::Result<(), SpawnFailure> = (|| { + #[cfg(all(unix, feature = "process-group"))] + { + let spawned_id = Self::capture_io(|| { + Ok(child + .as_deref() + .expect("the spawn lifecycle retains child custody") + .try_spawned_id()) + })?; + attempt.set_spawned_id(spawned_id); + } #[cfg(windows)] - let child = { - let mut child = child; - child.finalize_spawn()?; + self.run_prepare_child( + attempt, child - }; - Ok(child) - })); + .as_deref_mut() + .expect("the provider lifecycle retains child custody"), + &mut prepared, + )?; + self.run_post_spawn( + attempt, + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody"), + )?; + self.run_wrap_child( + &mut child, + &mut pending, + #[cfg(all(unix, feature = "process-group"))] + attempt.spawned_id(), + #[cfg(windows)] + &mut prepared, + )?; + Ok(()) + })(); - match result { - Ok(Ok(child)) => Ok(child), - Ok(Err(error)) => { - if let Some(transaction) = transaction.take() { - Self::rollback_transaction(transaction); - } - Err(error) + if let Err(failure) = before_commit { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + #[cfg(windows)] + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); + } + + #[cfg(windows)] + let final_owner = match Self::capture_io(|| { + child + .as_deref_mut() + .expect("the provider lifecycle retains child custody") + .finalize_spawn_before_commit() + }) { + Ok(owner) => owner, + Err(failure) => { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_child(&mut child); + Self::cleanup_prepared(&mut prepared); + return failure.finish::>(); } - Err(payload) => { - if let Some(transaction) = transaction.take() { - Self::rollback_transaction(transaction); - } - ::std::panic::resume_unwind(payload) + }; + + let child = child + .take() + .expect("the provider lifecycle retains its child before commit"); + let mut sidecar = CommittedProviderChild::new( + child, + #[cfg(windows)] + ::std::mem::take(&mut prepared), + ); + if let Err(failure) = Self::capture_io(|| transaction.commit()) { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_provider_residue(&mut sidecar); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_provider_child(&mut sidecar); + return failure.finish::>(); + } + transaction.transfer_into(&mut sidecar); + + #[cfg(windows)] + { + attempt.prepare_for_final_owner(); + let final_result = Self::capture_io(|| { + sidecar + .child_mut() + .finalize_spawn_final_owner(final_owner) + }); + if let Err(failure) = final_result { + Self::cleanup_failed_transaction(&mut transaction); + Self::cleanup_provider_residue(&mut sidecar); + Self::cleanup_pending_child(&mut pending); + Self::cleanup_provider_child(&mut sidecar); + return failure.finish::>(); } } + + Ok(sidecar) } fn spawn_with_provider( @@ -530,9 +3133,18 @@ macro_rules! Wrap { /// Spawn the command, returning a child that can be interacted with. /// /// With no alternate provider, this runs all `pre_spawn` hooks, spawns through the native - /// frontend, runs all capability-level `post_spawn` hooks, then stacks all - /// `wrap_child`s. A registered provider replaces only the native transport and commits its - /// cleanup transaction after the same complete hook chain succeeds. + /// frontend, runs Windows prepared-state construction before every public `post_spawn` hook, + /// then describes and installs child layers in registration order with install-time custody + /// validation. Windows ordinary finalization, cleanup disarm, and owner selection finish before + /// the sole final owner disarms and the child returns. + /// + /// A registered provider replaces only transport creation and returns a child plus fresh armed + /// transaction. The same prepared/post/wrap lifecycle and Windows pre-commit finalization run + /// while rollback remains armed. Process-wrap allocates return custody, commits, transfers residue, + /// and only then disarms the sole final owner. Any earlier error or unwinding panic rolls back + /// first, independently disposes remaining values, quarantines secondary payloads, and preserves + /// the exact primary failure. After successful final-owner disarm only infallible private moves + /// remain before return. pub fn spawn(&mut self) -> ::std::io::Result> { if let Some(provider_index) = self.select_spawn_provider()? { return self.spawn_with_provider(provider_index); @@ -672,9 +3284,9 @@ macro_rules! Wrap { /// Called before the command is spawned, to mutate this attempt as needed. /// - /// Hooks run in registration order and stop at the first error or panic. Mutations to an attempt - /// copied from a tracked command apply to that spawn only. A native-only base instead retains - /// native mutations when process-wrap restores it after the lifecycle. + /// Hooks run in registration order and stop at the first error or unwinding panic. Mutations to + /// an attempt copied from a tracked command apply to that spawn only. A native-only base instead + /// retains native mutations when process-wrap restores it after the lifecycle. /// /// Calling `SpawnAttempt::native_mut`, directly or through `stdin`, `stdout`, or `stderr`, makes a /// tracked attempt opaque. A registered portable provider rejects it after all pre-spawn hooks @@ -699,8 +3311,23 @@ macro_rules! Wrap { /// Prepare Windows child state which must exist before public post-spawn hooks run. /// - /// Process-wrap retains the returned state through post-spawn hooks and supplies it to the - /// matching wrapper's `wrap_prepared_child` call. + /// Return `Some(Box::new(state))` for a concrete `state: T`. Process-wrap keeps that value in + /// private, non-cloneable pending custody through later hooks. The matching + /// [`CommandWrapper::wrap_prepared_child`] callback receives only immutable type metadata, + /// never the value or a custody handle. + /// + /// The complete safe installation pattern is: return `T` here; have the detached layer store + /// an empty `Option`; confirm `PreparedChildRef::is::()` while wrapping; + /// return that layer through `PendingChildWrapper::new`; and expose its slot with + /// `ChildWrapperSlots::with_prepared::`. Process-wrap validates the type, fills the weak token, + /// validates exclusive strong custody while the layer remains detached, then transfers the child + /// and publishes the installed layer. Once installed, the layer may use + /// `PreparedChild::with::` for immutable access. + /// + /// A prepared type which itself exposes interior ownership transfer or independently clonable + /// native resources can deliberately extend their lifetime; that is part of the type's own + /// contract. Process-wrap does not expose a cloneable custody token or mutable pre-install + /// accessor which creates such an escape. #[doc(hidden)] #[cfg(windows)] fn prepare_child( @@ -714,12 +3341,12 @@ macro_rules! Wrap { /// Called after any transport spawns a child, but before the child is wrapped. /// - /// Hooks run in registration order and stop at the first error or panic. The child is exposed - /// through the frontend's object-safe capability trait, so it may be a terminal custom or + /// Hooks run in registration order and stop at the first error or unwinding panic. The child is + /// exposed through the frontend's object-safe capability trait, so it may be a terminal custom or /// provider child with no native child value. The transport has already created it: changing /// command settings on `attempt` here cannot configure that child. /// - /// On the provider path, an error or panic triggers best-effort transaction rollback. Native + /// On the provider path, an error or unwinding panic triggers best-effort transaction rollback. Native /// transports do not promise equivalent child cleanup on every platform. /// /// Default implementation: no-op. @@ -732,32 +3359,55 @@ macro_rules! Wrap { Ok(()) } - /// Called to wrap a child into this command wrapper's child wrapper. + /// Called to describe a child layer after a transport creates the child. + /// + /// Wrappers run in registration order. The current child remains borrowed from process-wrap while + /// this callback runs. To add a layer, return a [`PendingChildWrapper`] containing a + /// [`ChildWrapperLayer`] whose ownership slots are empty; process-wrap installs the current child + /// only after the callback has returned successfully. Returning `None` leaves the child unchanged. + /// This custody protocol keeps child destruction outside the callback boundary when a later hook + /// fails. `.wrap(Foo).wrap(Bar)` still produces an outer `Bar(Foo(child))` layer. /// - /// 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. + /// On the provider path, an error or unwinding panic triggers best-effort transaction rollback. /// - /// Default implementation: no-op (returns the child unchanged). + /// Default implementation: no-op (returns no layer). fn wrap_child( &mut self, - child: Box, + _child: &mut dyn $childer, _command: &Command, - ) -> ::std::io::Result> { - Ok(child) + ) -> ::std::io::Result> { + Ok(None) + } + + /// Describe a child layer with the historical transport identity captured before post-spawn hooks. + /// + /// The identity is for process-group/session installation only. It is not evidence of current + /// liveness and must never become a post-return signal or kill target. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn wrap_child_with_spawned_id( + &mut self, + child: &mut dyn $childer, + _spawned_id: Option, + command: &Command, + ) -> ::std::io::Result> { + self.wrap_child(child, command) } - /// Install a child wrapper using state returned by `prepare_child`. + /// Describe a child layer for state returned by `prepare_child`. + /// + /// `prepared` is a non-cloneable metadata view. It can establish presence and concrete type but + /// cannot expose, mutate, replace, or move the pending value. A matching layer declares an + /// empty typed slot; process-wrap alone creates its installed `PreparedChild` handle after this + /// callback succeeds. #[doc(hidden)] #[cfg(windows)] fn wrap_prepared_child( &mut self, - child: Box, - prepared: Option>, + child: &mut dyn $childer, + prepared: Option>, command: &Command, - ) -> ::std::io::Result> { + ) -> ::std::io::Result> { debug_assert!( prepared.is_none(), "the default child preparation does not produce wrapper state" diff --git a/src/lib.rs b/src/lib.rs index e104f30..0342f20 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -112,7 +112,9 @@ //! //! The non-default `pty` feature selects the Tokio frontend and terminal dependencies. It provides //! native transport on Linux, Android, macOS, FreeBSD, NetBSD 10 and newer, OpenBSD, DragonFly BSD, -//! illumos, and Solaris; unavailable platforms report `std::io::ErrorKind::Unsupported`. +//! illumos, Solaris, and Windows 11 24H2 (build 26100) or Windows Server 2025. The Windows backend +//! uses native ConPTY. That Windows floor is required because descendant-aware output EOF requires +//! `ReleasePseudoConsole`. Unavailable platforms report `std::io::ErrorKind::Unsupported`. //! //! ```rust,no_run //! # #[cfg(feature = "pty")] @@ -120,9 +122,14 @@ //! # fn run() -> std::io::Result<()> { //! use process_wrap::tokio::{Command, Pty}; //! +//! #[cfg(unix)] //! let mut command = Command::with_new("sh", |command| { //! command.args(["-c", "printf terminal"]); //! }); +//! #[cfg(windows)] +//! let mut command = Command::with_new("cmd.exe", |command| { +//! command.args(["/d", "/s", "/c", "echo terminal"]); +//! }); //! command.wrap(Pty::default()); //! let mut child = command.spawn()?; //! let controller = child @@ -134,20 +141,47 @@ //! # fn main() {} //! ``` //! +//! On every supported platform, including Windows, register `Pty` with the same +//! `Command::wrap(Pty::default()).spawn()` API and take the same `PtyController`. Its ownership and +//! lifecycle contract below applies on Unix and Windows alike. To select a PTY conditionally, use +//! `Pty::check_supported()` for a capability result or `Pty::is_supported()` for a boolean. These +//! report platform and runtime capability only; they do not suppress later configuration, +//! compatibility, or spawn errors. +//! +//! ## Migrating from the former PTY prototype +//! +//! Move command and terminal configuration to the shared Tokio `Command` and `Pty` values. Register +//! that `Pty` as the spawn provider with `.wrap(Pty::default())` (or `.wrap(configured_pty)`), then +//! use ordinary `.spawn()` and its ordinary boxed-child result. Call `take_pty_controller()` once on +//! that returned child to obtain terminal I/O and resize control. +//! +//! On Windows, the ConPTY provider rejects direct `.bat` and `.cmd` programs case-insensitively. +//! Invoke `cmd.exe` explicitly with arguments such as `/d /s /c ...` for shell or batch semantics. +//! The provider resolves executable entries itself and reports unresolved bare programs as `NotFound`; +//! it does not perform implicit shell or `PATHEXT` dispatch. +//! //! A terminal has one ordered output stream, so PTY standard output and standard error are merged. -//! Input and output each strongly own the bidirectional master; resize handles are weak. Dropping one -//! I/O side is not a half-close, and the terminal hangs up only after both are gone. Send VEOF when -//! terminal input policy calls for end-of-file. Waiting for the direct child and draining terminal -//! output are separate lifecycles because descendants may retain the slave. On macOS they should run -//! concurrently while the kernel drains and revokes the terminal during session-leader teardown. +//! Input and output each strongly own the logical bidirectional controller; resize handles are weak. +//! Dropping one I/O side is not a half-close. After both are gone, a valid resize request returns +//! `BrokenPipe`. On Windows, final strong-owner loss schedules `ClosePseudoConsole` on a detached +//! worker. If worker creation fails, process-wrap intentionally retains the HPCON rather than +//! potentially blocking the dropping thread, so logical closure does not guarantee OS pseudoconsole +//! teardown. Send VEOF when terminal input policy calls for end-of-file. Waiting for the direct child +//! and draining terminal output are separate lifecycles because descendants may retain the slave. On +//! macOS they should run concurrently while the kernel drains and revokes the terminal during +//! session-leader teardown. //! //! The transport owns terminal bytes and resize, not parent-terminal raw mode, relaying, key handling, //! VT parsing, scrollback, or pager policy. A bare PTY creates its required session. //! `ProcessGroup::leader()` or `ProcessSession` may independently add group-wide signalling while the //! direct child is live; waiting continues to follow that child. Attaching to an existing group or -//! explicitly registering both is invalid. `ResetSigmask` composes, while Tokio `KillOnDrop` continues -//! to target only the direct child. The returned boxed child keeps arbitrary outer wrappers, and -//! `take_pty_controller()` traverses them and yields the controller once. +//! explicitly registering both is invalid. `ResetSigmask` composes. Without `JobObject`, Tokio +//! `KillOnDrop` targets only the direct child. On Windows, combining `KillOnDrop` with `JobObject` +//! enables kill-on-last-job-handle-close for every process still associated with the job; either +//! registration order works and the Windows PTY provider preserves that policy. Consuming the +//! `JobObject` layer relinquishes whole-job supervision, leaving only the lower direct-child policy. +//! The returned boxed child keeps arbitrary outer wrappers, and `take_pty_controller()` traverses them +//! and yields the controller once. //! //! # KillOnDrop and CreationFlags //! @@ -175,10 +209,17 @@ //! //! ## Instead of `.creation_flags(CREATE_NO_WINDOW)` (Windows-only): //! -//! ```rust,ignore -//! use process_wrap::std::*; +//! ```rust,no_run +//! # #[cfg(all(windows, feature = "std", feature = "creation-flags"))] +//! # fn run() -> std::io::Result<()> { +//! use process_wrap::std::{Command, CreationFlags}; +//! use windows::Win32::System::Threading::CREATE_NO_WINDOW; +//! //! let mut command = Command::with_new("ls", |command| { command.arg("-l"); }); //! command.wrap(CreationFlags(CREATE_NO_WINDOW)); +//! # let _child = command.spawn()?; +//! # Ok(()) } +//! # fn main() {} //! ``` //! //! Internally the `JobObject` wrapper always sets the `CREATE_SUSPENDED` flag, but as it is able to @@ -222,23 +263,30 @@ //! retain native mutations. Calling `attempt.native_mut()` or `stdin`/`stdout`/`stderr` makes a //! tracked attempt incompatible with a portable provider. By default does nothing. //! +//! - **On Windows, `prepare_child`** runs in registration order after transport creation and before +//! public post-spawn hooks. It constructs fallible per-wrapper prepared state while cleanup remains +//! armed; the matching weak token is installed into a detached layer during wrapping before that +//! layer is published to later callbacks. +//! //! - **`fn post_spawn(&mut self, attempt: &mut SpawnAttempt, child: &mut dyn ChildWrapper, command: &Command)`** //! is called after any transport creates its child. The child may be a terminal custom/provider //! child with no native value. Changing command settings on `attempt` here cannot configure the //! already-created child. By default does nothing. //! -//! - **`fn wrap_child(&mut self, child: Box, command: &Command)`** is called after -//! all `post_spawn()` hooks. If your wrapper needs to override child methods, create and return its -//! own `ChildWrapper` layer. Child wraps run in registration order, so -//! `.wrap(Foo).wrap(Bar)` produces an outer `Bar(Foo(child))`. By default returns the input child. +//! - **`fn wrap_child(&mut self, child: &mut dyn ChildWrapper, command: &Command) -> io::Result>`** +//! is called after all `post_spawn()` hooks. The current child remains borrowed from process-wrap. +//! To override child methods, return a `PendingChildWrapper` containing a `ChildWrapperLayer` whose +//! child slot is empty; process-wrap installs the child after the callback returns successfully. +//! Child wraps run in registration order, so `.wrap(Foo).wrap(Bar)` produces an outer +//! `Bar(Foo(child))`. By default returns `None` and leaves the child unchanged. //! //! - **`fn spawn_provider(&self) -> Option<&dyn SpawnProvider>`** exposes an alternate transport owned //! by this wrapper. A provider exposed during selection must remain available throughout the spawn //! lifecycle, and only one registered wrapper may expose one. By default returns `None`. //! //! Pre-spawn, post-spawn, and child-wrapping hooks all run in registration order and stop at the first -//! error or panic. The active wrapper remains registered but is temporarily unavailable through -//! `get_wrap`; peer wrappers remain visible. +//! error or unwinding panic. The active wrapper remains registered but is temporarily unavailable +//! through `get_wrap`; peer wrappers remain visible. //! //! ## Spawn providers //! @@ -251,16 +299,40 @@ //! 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` +//! 7. provider `spawn`, returning a child and fresh armed transaction +//! 8. on Windows, every fallible prepared-child construction hook +//! 9. every `post_spawn` hook +//! 10. every child-layer description and detached installation, including matching prepared state and +//! install-time custody validation +//! 11. on Windows, ordinary finalization and cleanup disarm, cleanup-owner validation, and non-owner +//! disarm while the sole final owner remains armed +//! 12. fallible private return-custody allocation +//! 13. transaction `commit` +//! 14. committed transaction-residue installation +//! 15. on Windows, the sole final cleanup owner disarms +//! 16. infallible private ownership moves and child return //! //! Validation rejects unsupported portable policy before operating-system allocation. `spawn` //! returns a child satisfying the frontend's complete `ChildWrapper` contract and a fresh, armed -//! `SpawnTransaction` which owns cleanup independently of the child chain. A later public hook, -//! wrapper, or commit error/panic causes best-effort rollback while preserving the original failure. -//! Cleanup before `spawn` returns that product remains the provider's responsibility. +//! `SpawnTransaction` which owns cleanup independently of the child chain. On Windows, prepared-state +//! construction occurs before public post hooks. After wrapping and install-time custody validation, +//! ordinary finalization, ordinary cleanup disarm, owner selection, and non-owner disarm all complete +//! before return-custody allocation and commit; every step is fallible while the transaction remains +//! armed. A later error or unwinding panic causes best-effort rollback while preserving the original +//! failure. After capturing that failure, process-wrap first resolves transaction cleanup, then +//! disposes any detached child layer, the child chain, and each Windows prepared value independently. +//! Secondary cleanup panic payloads are quarantined without inspection or destruction. Successful +//! commit ends failed-spawn rollback, and its residue is installed in the already-allocated return +//! custody. On Windows, the sole final owner remains armed until it disarms after commit; no caller +//! callback, allocation-dependent work, or native handle close follows that successful transition +//! before the infallible return move. On a successful spawn, arbitrary residue destruction occurs +//! outside the spawn lifecycle. Committed residue must retain no armed cleanup or independent process, +//! terminal, controller, handle, pseudoconsole, or other liveness resource. +//! +//! Rollback, wrapper restoration, original panic-payload preservation, and cleanup-diagnostic panic +//! containment apply only to unwinding panics. With `panic=abort`, the process terminates before +//! those guarantees can run. Cleanup before `spawn` returns that product remains the provider's +//! responsibility. //! //! A command may register only one provider; conflicts are rejected before callbacks or allocation. //! Providers and wrapper state are reusable across repeated spawns. `spawn_with` and @@ -313,17 +385,17 @@ //! # fn main() {} //! ``` //! -//! That's a great start, but it's actually introduced a resource leak: if the main thread of your -//! program exits before that background one does, then the background thread won't get a chance to -//! call `logfile`'s `Drop` implementation which closes the file. The file handle will be left open! -//! To fix this, we'll need to keep track of the background thread's `ThreadHandle` and `.join()` it -//! when calling `.wait()` on the `ChildWrapper`. +//! That is a useful start, but returning from `main` can terminate the process before the detached +//! worker finishes copying and flushing terminal bytes, leaving incomplete log output. Retain the +//! background thread's `JoinHandle` and join it from the child wrapper's `wait` method so copying +//! completes before the caller proceeds. //! //! ```rust //! # #[cfg(feature = "std")] //! # mod example { //! # use process_wrap::std::{ -//! # ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper, SpawnAttempt, +//! # ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command as WrappedCommand, +//! # CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, //! # }; //! # use std::{ //! # fs::File, @@ -364,45 +436,51 @@ //! //! fn wrap_child( //! &mut self, -//! child: Box, +//! _child: &mut dyn ChildWrapper, //! _core: &CommandWrap, -//! ) -> io::Result> { +//! ) -> io::Result> { //! let wrapped_child = LogFileChild { -//! inner: child, +//! inner: None, //! thread: mem::take(&mut self.thread), //! }; -//! Ok(Box::new(wrapped_child)) +//! Ok(Some(PendingChildWrapper::new(wrapped_child))) //! } //! } //! //! #[derive(Debug)] //! struct LogFileChild { -//! inner: Box, +//! inner: Option>, //! thread: Option>, //! } //! +//! impl ChildWrapperLayer for LogFileChild { +//! fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { +//! ChildWrapperSlots::new(&mut self.inner) +//! } +//! } +//! //! impl ChildWrapper for LogFileChild { //! fn inner(&self) -> &dyn ChildWrapper { -//! &*self.inner +//! self.inner.as_deref().expect("an installed log layer owns its child") //! } //! //! fn inner_mut(&mut self) -> &mut dyn ChildWrapper { -//! &mut *self.inner +//! self.inner.as_deref_mut().expect("an installed log layer owns its child") //! } //! -//! fn into_inner(self: Box) -> Box { -//! self.inner +//! fn into_inner(mut self: Box) -> Box { +//! self.inner.take().expect("an installed log layer owns its child") //! } //! //! #[cfg(windows)] //! fn process_handle( //! &self, //! ) -> Option> { -//! self.inner.process_handle() +//! self.inner().process_handle() //! } //! //! fn wait(&mut self) -> io::Result { -//! let exit_status = self.inner.wait(); +//! let exit_status = self.inner_mut().wait(); //! //! if let Some(thread) = mem::take(&mut self.thread) { //! thread.join().unwrap(); @@ -431,7 +509,8 @@ //! # #[cfg(feature = "std")] //! # mod example { //! # use process_wrap::std::{ -//! # ChildWrapper, Command as WrappedCommand, CommandWrap, CommandWrapper, SpawnAttempt, +//! # ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command as WrappedCommand, +//! # CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, //! # }; //! # use std::{ //! # error::Error, @@ -474,45 +553,51 @@ //! # //! # fn wrap_child( //! # &mut self, -//! # child: Box, +//! # _child: &mut dyn ChildWrapper, //! # _core: &CommandWrap, -//! # ) -> io::Result> { +//! # ) -> io::Result> { //! # let wrapped_child = LogFileChild { -//! # inner: child, +//! # inner: None, //! # thread: mem::take(&mut self.thread), //! # }; -//! # Ok(Box::new(wrapped_child)) +//! # Ok(Some(PendingChildWrapper::new(wrapped_child))) //! # } //! # } //! # //! # #[derive(Debug)] //! # struct LogFileChild { -//! # inner: Box, +//! # inner: Option>, //! # thread: Option>, //! # } //! # +//! # impl ChildWrapperLayer for LogFileChild { +//! # fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { +//! # ChildWrapperSlots::new(&mut self.inner) +//! # } +//! # } +//! # //! # impl ChildWrapper for LogFileChild { //! # fn inner(&self) -> &dyn ChildWrapper { -//! # &*self.inner +//! # self.inner.as_deref().expect("an installed log layer owns its child") //! # } //! # //! # fn inner_mut(&mut self) -> &mut dyn ChildWrapper { -//! # &mut *self.inner +//! # self.inner.as_deref_mut().expect("an installed log layer owns its child") //! # } //! # -//! # fn into_inner(self: Box) -> Box { -//! # self.inner +//! # fn into_inner(mut self: Box) -> Box { +//! # self.inner.take().expect("an installed log layer owns its child") //! # } //! # //! # #[cfg(windows)] //! # fn process_handle( //! # &self, //! # ) -> Option> { -//! # self.inner.process_handle() +//! # self.inner().process_handle() //! # } //! # //! # fn wait(&mut self) -> io::Result { -//! # let exit_status = self.inner.wait(); +//! # let exit_status = self.inner_mut().wait(); //! # //! # if let Some(thread) = mem::take(&mut self.thread) { //! # thread.join().unwrap(); @@ -579,6 +664,251 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] +#[cfg(all(test, windows))] +pub(crate) mod test_allocator { + use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + sync::atomic::{AtomicBool, Ordering}, + }; + + #[derive(Clone, Copy, Debug)] + pub(crate) struct PostTransitionProbeState { + pub(crate) query_attempted: bool, + pub(crate) query_succeeded: bool, + pub(crate) query_attempted_before_setter: bool, + pub(crate) query_succeeded_before_setter: bool, + pub(crate) setter_attempted: bool, + pub(crate) transitioned: bool, + pub(crate) query_attempted_after_transition: bool, + pub(crate) allocator_callback: bool, + pub(crate) later_operation: bool, + } + + #[derive(Default)] + pub(crate) struct PostTransitionProbe { + pub(crate) active: AtomicBool, + query_attempted: AtomicBool, + query_succeeded: AtomicBool, + query_attempted_before_setter: AtomicBool, + query_succeeded_before_setter: AtomicBool, + setter_attempted: AtomicBool, + transitioned: AtomicBool, + query_attempted_after_transition: AtomicBool, + allocator_callback: AtomicBool, + operation: AtomicBool, + inject_setter_failure: AtomicBool, + } + + impl PostTransitionProbe { + #[cfg(feature = "job-object")] + fn arm_with_setter_failure(&self, inject_setter_failure: bool) { + assert!(!self.active.swap(true, Ordering::SeqCst)); + self.query_attempted.store(false, Ordering::SeqCst); + self.query_succeeded.store(false, Ordering::SeqCst); + self.query_attempted_before_setter + .store(false, Ordering::SeqCst); + self.query_succeeded_before_setter + .store(false, Ordering::SeqCst); + self.setter_attempted.store(false, Ordering::SeqCst); + self.transitioned.store(false, Ordering::SeqCst); + self.query_attempted_after_transition + .store(false, Ordering::SeqCst); + self.allocator_callback.store(false, Ordering::SeqCst); + self.operation.store(false, Ordering::SeqCst); + self.inject_setter_failure + .store(inject_setter_failure, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn arm(&self) { + self.arm_with_setter_failure(false); + } + + #[cfg(feature = "job-object")] + pub(crate) fn arm_setter_failure(&self) { + self.arm_with_setter_failure(true); + } + + #[cfg(feature = "job-object")] + pub(crate) fn take_setter_failure(&self) -> bool { + self.inject_setter_failure.swap(false, Ordering::SeqCst) + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_query_attempt(&self) { + if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { + self.query_attempted_after_transition + .store(true, Ordering::SeqCst); + } + self.query_attempted.store(true, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_query_success(&self) { + self.query_succeeded.store(true, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_setter_attempt(&self) { + self.query_attempted_before_setter.store( + self.query_attempted.load(Ordering::SeqCst), + Ordering::SeqCst, + ); + self.query_succeeded_before_setter.store( + self.query_succeeded.load(Ordering::SeqCst), + Ordering::SeqCst, + ); + self.setter_attempted.store(true, Ordering::SeqCst); + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_transition(&self) { + self.transitioned.store(true, Ordering::SeqCst); + } + + pub(crate) fn observe_operation(&self) { + if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { + self.operation.store(true, Ordering::SeqCst); + } + } + + fn observe_allocator_callback(&self) { + if self.active.load(Ordering::SeqCst) && self.transitioned.load(Ordering::SeqCst) { + self.allocator_callback.store(true, Ordering::SeqCst); + } + } + + #[cfg(feature = "job-object")] + pub(crate) fn finish(&self) -> PostTransitionProbeState { + self.active.store(false, Ordering::SeqCst); + self.inject_setter_failure.store(false, Ordering::SeqCst); + PostTransitionProbeState { + query_attempted: self.query_attempted.load(Ordering::SeqCst), + query_succeeded: self.query_succeeded.load(Ordering::SeqCst), + query_attempted_before_setter: self + .query_attempted_before_setter + .load(Ordering::SeqCst), + query_succeeded_before_setter: self + .query_succeeded_before_setter + .load(Ordering::SeqCst), + setter_attempted: self.setter_attempted.load(Ordering::SeqCst), + transitioned: self.transitioned.load(Ordering::SeqCst), + query_attempted_after_transition: self + .query_attempted_after_transition + .load(Ordering::SeqCst), + allocator_callback: self.allocator_callback.load(Ordering::SeqCst), + later_operation: self.operation.load(Ordering::SeqCst), + } + } + } + + #[cfg(feature = "job-object")] + #[derive(Default)] + pub(crate) struct SpawnCleanupHandleProbe { + active: AtomicBool, + closes: std::sync::atomic::AtomicUsize, + } + + #[cfg(feature = "job-object")] + impl SpawnCleanupHandleProbe { + pub(crate) fn arm(&self) { + assert!(!self.active.swap(true, Ordering::SeqCst)); + self.closes.store(0, Ordering::SeqCst); + } + + fn observe_close(&self) { + if self.active.load(Ordering::SeqCst) { + self.closes.fetch_add(1, Ordering::SeqCst); + } + } + + pub(crate) fn close_count(&self) -> usize { + self.closes.load(Ordering::SeqCst) + } + + pub(crate) fn finish(&self) -> usize { + self.active.store(false, Ordering::SeqCst); + self.close_count() + } + } + + thread_local! { + static POST_TRANSITION_PROBE: Cell> = const { Cell::new(None) }; + #[cfg(feature = "job-object")] + static SPAWN_CLEANUP_HANDLE_PROBE: Cell> = const { Cell::new(None) }; + } + + pub(crate) fn current_probe() -> &'static PostTransitionProbe { + if let Some(probe) = POST_TRANSITION_PROBE.get() { + return probe; + } + let probe = Box::leak(Box::new(PostTransitionProbe::default())); + POST_TRANSITION_PROBE.set(Some(probe)); + probe + } + + #[cfg(feature = "job-object")] + pub(crate) fn current_spawn_cleanup_handle_probe() -> &'static SpawnCleanupHandleProbe { + if let Some(probe) = SPAWN_CLEANUP_HANDLE_PROBE.get() { + return probe; + } + let probe = Box::leak(Box::new(SpawnCleanupHandleProbe::default())); + SPAWN_CLEANUP_HANDLE_PROBE.set(Some(probe)); + probe + } + + #[cfg(feature = "job-object")] + pub(crate) fn observe_spawn_cleanup_handle_close() { + let _ = SPAWN_CLEANUP_HANDLE_PROBE.try_with(|slot| { + if let Some(probe) = slot.get() { + probe.observe_close(); + } + }); + } + + fn observe_allocator_callback() { + let _ = POST_TRANSITION_PROBE.try_with(|slot| { + if let Some(probe) = slot.get() { + probe.observe_allocator_callback(); + } + }); + } + + struct TrackingAllocator; + + // SAFETY: every operation preserves `System`'s arguments and return value exactly. The probe uses + // only already-initialized thread-local and atomic state and does not alter allocator ownership. + unsafe impl GlobalAlloc for TrackingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + observe_allocator_callback(); + // SAFETY: `layout` is passed through unchanged under `GlobalAlloc::alloc`'s contract. + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + observe_allocator_callback(); + // SAFETY: `layout` is passed through unchanged under `GlobalAlloc::alloc_zeroed`'s contract. + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + observe_allocator_callback(); + // SAFETY: both arguments are passed through unchanged under `GlobalAlloc::dealloc`'s contract. + unsafe { System.dealloc(pointer, layout) } + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + observe_allocator_callback(); + // SAFETY: all arguments are passed through unchanged under `GlobalAlloc::realloc`'s contract. + unsafe { System.realloc(pointer, layout, size) } + } + } + + #[global_allocator] + static GLOBAL_ALLOCATOR: TrackingAllocator = TrackingAllocator; +} + mod command; pub(crate) mod generic_wrap; #[cfg(all(unix, any(feature = "std", feature = "tokio1")))] diff --git a/src/std.rs b/src/std.rs index 4dbfc1f..2382398 100644 --- a/src/std.rs +++ b/src/std.rs @@ -22,9 +22,12 @@ pub use crate::SpawnTransaction; pub use crate::WindowsSpawnPolicy; #[doc(inline)] pub use core::{ - ChildWrapper, Command, CommandWrap, CommandWrapper, ProviderProduct, SpawnAttempt, - SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrap, CommandWrapper, + PendingChildWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, }; +#[cfg(windows)] +#[doc(hidden)] +pub use core::{PreparedChild, PreparedChildRef}; #[cfg(all(windows, feature = "creation-flags"))] #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] #[doc(inline)] diff --git a/src/std/core.rs b/src/std/core.rs index 91ecf72..2d8eb97 100644 --- a/src/std/core.rs +++ b/src/std/core.rs @@ -9,18 +9,73 @@ use std::{ #[cfg(windows)] use std::os::windows::io::{AsHandle, BorrowedHandle}; +#[cfg(all(unix, feature = "process-group"))] +use nix::sys::signal::killpg; #[cfg(unix)] use nix::{ sys::signal::{Signal, kill}, unistd::Pid, }; +#[cfg(windows)] +macro_rules! prepared_owner_child_contract { + () => { + fn stdin(&mut self) -> &mut Option { + self.child_mut().stdin() + } + + fn stdout(&mut self) -> &mut Option { + self.child_mut().stdout() + } + + fn stderr(&mut self) -> &mut Option { + self.child_mut().stderr() + } + + fn id(&self) -> u32 { + self.child_ref().id() + } + + fn kill(&mut self) -> Result<()> { + self.child_mut().kill() + } + + fn start_kill(&mut self) -> Result<()> { + self.child_mut().start_kill() + } + + fn try_wait(&mut self) -> Result> { + self.child_mut().try_wait() + } + + fn wait(&mut self) -> Result { + self.child_mut().wait() + } + + fn wait_with_output(mut self: Box) -> Result { + let child = self + .take_child() + .expect("a prepared-owner sidecar retains its child"); + let output = child.wait_with_output(); + drop(self); + output + } + + #[cfg(unix)] + fn signal(&mut self, sig: i32) -> Result<()> { + self.child_mut().signal(sig) + } + }; +} + crate::generic_wrap::Wrap!( crate::Blocking, NativeCommand, Child, ChildWrapper, - |child| child + |child| child, + "std", + prepared_owner_child_contract ); /// Wrapper for `std::process::Child`. @@ -127,22 +182,85 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Disarm Windows cleanup state owned by this child layer. /// /// Process-wrap invokes this internal hook only after every ordinary spawn finalizer succeeds, so - /// cleanup remains armed if any earlier finalizer errors or panics. + /// cleanup remains armed if any earlier finalizer errors or hits an unwinding panic. #[doc(hidden)] #[cfg(windows)] fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { Ok(()) } - /// Disarm the JobObject cleanup state owned by this child layer. + /// Report whether this layer owns the JobObject rollback guard. + /// + /// Process-wrap counts owners and disarms every non-owner before a provider transaction commits. + /// More than one owner fails the lifecycle before commit. The sole owner stays armed until commit + /// succeeds and is then disarmed as the only post-commit child-layer operation. + #[doc(hidden)] + #[cfg(windows)] + fn owns_job_object_cleanup_layer(&self) -> bool { + false + } + + /// Disarm JobObject-phase cleanup state owned by this child layer. /// - /// This process-wrap-internal phase runs after every other fallible finalizer and cleanup disarm. + /// Non-owning hooks run before provider commit. The sole layer identified by + /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs after commit and must be failure-atomic: + /// returning an error or unwinding must leave kill-on-close protection armed. If disarming uses a + /// native state transition, no caller-controlled callback may run after that transition succeeds. #[doc(hidden)] #[cfg(windows)] fn disarm_job_object_layer(&mut self) -> Result<()> { Ok(()) } + /// Move built-in prepared state into this layer before its private owner sidecar is removed. + /// + /// This hook is used only during caller-initiated, post-transfer consuming extraction. Custom + /// layers cannot obtain process-wrap's strong prepared owner through it. + #[doc(hidden)] + #[cfg(all(windows, feature = "job-object"))] + fn retain_prepared_after_sidecar_removal_layer(&mut self) {} + + /// Return the original PID retained by this exact child layer, if it advertises one. + /// + /// Process-wrap consults this historical identity only while installing `ProcessGroup` or + /// `ProcessSession` supervision, including when a post-spawn hook has already reaped the child. + /// It may preserve the PID observed at spawn for that construction step; it is not proof that the + /// child remains live and must not be used after spawn returns as a signal or kill target or as a + /// substitute for lower-child synchronized live signalling. + /// + /// For this installation path, `try_spawned_id` consults this capability rather than the + /// live-facing [`ChildWrapper::id`]. Every self-terminal custom or provider child that advertises + /// composition with `ProcessGroup` or `ProcessSession` must override this method and retain the + /// original spawned PID. Transparent wrappers may retain the default so traversal can use a lower + /// layer's advertised capability. Children and wrappers that do not advertise this supervision + /// composition need not retain historical identity. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn spawned_id_layer(&self) -> Option { + None + } + + /// Report whether this exact layer can linearize direct-child status refresh with group signalling. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + false + } + + /// Refresh direct-child status and signal `process_group` before releasing this layer's wait custody. + /// + /// `Ok(Some(status))` means the direct child was already reaped and no signal was issued; + /// `Ok(None)` means the signal was issued while the child remained unreaped. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + _process_group: i32, + _signal: i32, + ) -> Option>> { + None + } + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -270,11 +388,25 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// was introduced by command-group to abstract over the signal behaviour between process groups /// and unwrapped processes. #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - self.inner().signal(sig) + fn signal(&mut self, sig: i32) -> Result<()> { + self.inner_mut().signal(sig) } } +#[cfg(unix)] +fn signal_child_if_running_with( + child: &mut Child, + signal: Signal, + send: impl FnOnce(Pid, Signal) -> std::result::Result<(), nix::errno::Errno>, +) -> Result> { + if let Some(status) = Child::try_wait(child)? { + return Ok(Some(status)); + } + let pid = Pid::from_raw(i32::try_from(Child::id(child)).map_err(std::io::Error::other)?); + send(pid, signal).map_err(std::io::Error::from)?; + Ok(None) +} + impl ChildWrapper for Child { fn inner(&self) -> &dyn ChildWrapper { self @@ -301,10 +433,32 @@ impl ChildWrapper for Child { fn id(&self) -> u32 { Child::id(self) } + #[cfg(all(unix, feature = "process-group"))] + fn spawned_id_layer(&self) -> Option { + Some(Child::id(self)) + } + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + true + } + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let signal = match Signal::try_from(signal) { + Ok(signal) => signal, + Err(error) => return Some(Err(error.into())), + }; + Some(signal_child_if_running_with(self, signal, |_, signal| { + killpg(Pid::from_raw(process_group), signal) + })) + } fn start_kill(&mut self) -> Result<()> { #[cfg(unix)] { - self.signal(Signal::SIGKILL as _) + signal_child_if_running_with(self, Signal::SIGKILL, kill).map(drop) } #[cfg(not(unix))] @@ -319,12 +473,8 @@ impl ChildWrapper for Child { Child::wait(self) } #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - kill( - Pid::from_raw(i32::try_from(self.id()).map_err(std::io::Error::other)?), - Signal::try_from(sig)?, - ) - .map_err(std::io::Error::from) + fn signal(&mut self, sig: i32) -> Result<()> { + signal_child_if_running_with(self, Signal::try_from(sig)?, kill).map(drop) } } @@ -332,6 +482,20 @@ fn same_child(left: &dyn ChildWrapper, right: &dyn ChildWrapper) -> bool { std::ptr::addr_eq(left, right) && left.type_id() == right.type_id() } +#[cfg(windows)] +#[derive(Debug)] +pub(crate) struct FinalJobOwner { + identity: Option<(std::any::TypeId, *mut ())>, +} + +#[cfg(windows)] +fn spawn_layer_identity(layer: &mut dyn ChildWrapper) -> (std::any::TypeId, *mut ()) { + ( + (&*layer as &dyn Any).type_id(), + std::ptr::from_mut(layer).cast::<()>(), + ) +} + impl dyn ChildWrapper + '_ { fn downcast_ref(&self) -> Option<&T> { (self as &dyn Any).downcast_ref() @@ -341,6 +505,59 @@ impl dyn ChildWrapper + '_ { self.downcast_ref::().is_some() } + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn try_spawned_id(&self) -> Option { + let mut inner = self; + loop { + if let Some(pid) = inner.spawned_id_layer() { + return Some(pid); + } + let next = inner.inner(); + if same_child(inner, next) { + return None; + } + inner = next; + } + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn has_process_group_signal_capability(&self) -> bool { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return true; + } + let next = inner.inner(); + if same_child(inner, next) { + return false; + } + inner = next; + } + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn try_signal_process_group( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return inner.signal_process_group_layer(process_group, signal); + } + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return None; + } + inner = next; + } + } + /// Find the first Windows process-handle capability in this wrapper chain. /// /// Unlike [`ChildWrapper::process_handle`], this traverses legacy transparent layers which do not @@ -408,10 +625,69 @@ impl dyn ChildWrapper + '_ { } #[cfg(windows)] - pub(crate) fn finalize_spawn(&mut self) -> Result<()> { + pub(crate) fn finalize_spawn_before_commit(&mut self) -> Result { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; self.visit_spawn_layers(|inner| inner.disarm_spawn_cleanup_layer())?; - self.visit_spawn_layers(|inner| inner.disarm_job_object_layer()) + + let mut owners = 0; + let mut identity = None; + self.visit_spawn_layers(|inner| { + if inner.owns_job_object_cleanup_layer() { + owners += 1; + identity.get_or_insert_with(|| spawn_layer_identity(inner)); + Ok(()) + } else { + inner.disarm_job_object_layer() + } + })?; + if owners > 1 { + return Err(std::io::Error::other( + "multiple child layers own JobObject cleanup", + )); + } + Ok(FinalJobOwner { identity }) + } + + #[cfg(windows)] + pub(crate) fn finalize_spawn_final_owner(&mut self, owner: FinalJobOwner) -> Result<()> { + let Some(identity) = owner.identity else { + return Ok(()); + }; + let mut inner = self; + loop { + if spawn_layer_identity(inner) == identity { + return inner.disarm_job_object_layer(); + } + + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return Err(std::io::Error::other( + "the captured JobObject cleanup owner left the child chain", + )); + } + inner = next; + } + } + + #[cfg(all(windows, feature = "job-object"))] + pub(crate) fn retain_prepared_after_sidecar_removal(&mut self) { + let mut inner = self; + loop { + inner.retain_prepared_after_sidecar_removal_layer(); + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return; + } + inner = next; + } } /// Try to obtain a reference to the underlying native [`Child`]. @@ -588,6 +864,87 @@ fn read2( Ok(()) } +#[cfg(all(test, unix))] +mod signal_tests { + use std::{ + process::Command, + sync::atomic::{AtomicUsize, Ordering}, + }; + + use super::*; + + struct ChildGuard(Option); + + impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("an armed test guard owns its child") + } + + fn finish(mut self) -> Result<()> { + let child = self.child_mut(); + match child.kill() { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let _ = child.wait()?; + self.0.take(); + Ok(()) + } + } + + impl Drop for ChildGuard { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { + let child = Command::new("true").spawn()?; + let mut child = ChildGuard::new(child); + let status = child.child_mut().wait()?; + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, Some(status)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(child.child_mut().wait()?, status); + child.finish()?; + Ok(()) + } + + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { + let child = Command::new("sh").args(["-c", "sleep 30"]).spawn()?; + let mut child = ChildGuard::new(child); + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + child.finish()?; + Ok(()) + } +} + const _: () = { const fn assert_sync() {} assert_sync::(); diff --git a/src/std/job_object.rs b/src/std/job_object.rs index c1e41aa..3177104 100644 --- a/src/std/job_object.rs +++ b/src/std/job_object.rs @@ -1,9 +1,10 @@ use std::{ any::Any, io::{Error, ErrorKind, Result}, - os::windows::io::{AsRawHandle, BorrowedHandle}, + ops::ControlFlow, + os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}, process::ExitStatus, - time::Duration, + time::{Duration, Instant}, }; #[cfg(feature = "tracing")] @@ -16,14 +17,22 @@ use windows::Win32::{ use crate::{ ChildExitStatus, windows::{ - JobPort, job_creation_flags, make_job_object, resume_threads, set_job_kill_on_drop, - terminate_job, wait_on_job, + JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, + resume_threads, terminate_job, }, }; +#[cfg(not(test))] +use crate::windows::set_job_kill_on_drop; +#[cfg(test)] +use crate::windows::set_job_kill_on_drop_observed; + #[cfg(feature = "creation-flags")] use super::CreationFlags; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, PreparedChild, PreparedChildRef, SpawnAttempt, +}; /// Wrapper which creates a job object context for a `Command`. /// @@ -61,7 +70,7 @@ fn terminate_child(child: &mut dyn ChildWrapper) { #[derive(Debug)] struct PreparedJobObject { - job_port: JobPort, + job_port: Option, } impl JobObject { @@ -110,7 +119,9 @@ impl JobObject { } } - Ok(PreparedJobObject { job_port }) + Ok(PreparedJobObject { + job_port: Some(job_port), + }) } } @@ -133,126 +144,1027 @@ impl CommandWrapper for JobObject { fn wrap_prepared_child( &mut self, - inner: Box, - prepared: Option>, + _inner: &mut dyn ChildWrapper, + prepared: Option>, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); - let prepared = match prepared.downcast::() { - Ok(prepared) => *prepared, - Err(_) => unreachable!("JobObject prepared state retains its concrete type"), - }; - Ok(Box::new(JobObjectChild::new( - inner, - prepared.job_port, + assert!( + prepared.is::(), + "JobObject prepared state retains its concrete type" + ); + Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( false, - ))) - } - - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self, inner)))] - fn wrap_child( - &mut self, - mut inner: Box, - core: &CommandWrap, - ) -> Result> { - let prepared = self.prepare_job(inner.as_mut(), core)?; - let mut child = JobObjectChild::new(inner, prepared.job_port, false); - child.disarm_job_object_layer()?; - Ok(Box::new(child)) + )))) } } /// Wrapper for `Child` which waits on all processes within the job. #[derive(Debug)] pub struct JobObjectChild { - inner: Box, + inner: Option>, + prepared: Option, + extracted: Option, exit_status: ChildExitStatus, - job_port: JobPort, + job_drained: bool, final_kill_on_drop: bool, spawn_finalized: bool, } impl JobObjectChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(job_port)))] - pub(crate) fn new( - inner: Box, - job_port: JobPort, - final_kill_on_drop: bool, - ) -> Self { + pub(crate) fn detached(final_kill_on_drop: bool) -> Self { Self { - inner, + inner: None, + prepared: None, + extracted: None, exit_status: ChildExitStatus::Running, - job_port, + job_drained: false, final_kill_on_drop, spawn_finalized: false, } } + + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed JobObject layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed JobObject layer owns its child") + } + + fn prepared(&self) -> &PreparedChild { + self.prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state") + } + + fn with_job_port(&self, inspect: impl FnOnce(&JobPort) -> R) -> R { + if let Some(prepared) = self.extracted.as_ref() { + return inspect( + prepared + .job_port + .as_ref() + .expect("the extracted JobObject layer retains its job handles"), + ); + } + self.prepared().with_required::( + || panic!("JobObject prepared state retains its concrete type"), + |prepared| { + let job_port = prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"); + inspect(job_port) + }, + ) + } + + fn take_prepared(&mut self) -> PreparedJobObject { + if let Some(prepared) = self.extracted.take() { + return prepared; + } + self.prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type") + } +} + +impl ChildWrapperLayer for JobObjectChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) + } +} + +fn wait_for_exit_and_job_drain_with( + exit_status: &mut ChildExitStatus, + job_drained: &mut bool, + mut wait_direct: impl FnMut() -> Result, + mut poll_drain: impl FnMut(Duration) -> Result>, + mut now: impl FnMut() -> Instant, + mut sleep: impl FnMut(Duration), + mut on_pending: impl FnMut(ChildExitStatus), +) -> Result { + let status = match *exit_status { + ChildExitStatus::Running => { + let status = wait_direct()?; + *exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; + + while !*job_drained { + let poll_started = now(); + if poll_drain(JOB_POLL_INTERVAL)?.is_break() { + *job_drained = true; + } else { + let elapsed = now().saturating_duration_since(poll_started); + let remaining = JOB_POLL_INTERVAL.saturating_sub(elapsed); + on_pending(*exit_status); + if !remaining.is_zero() { + sleep(remaining); + } + } + } + Ok(status) } impl ChildWrapper for JobObjectChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { - let Self { - inner, - job_port, - final_kill_on_drop, - spawn_finalized, - .. - } = *self; - if spawn_finalized && final_kill_on_drop { - // manually drop the completion port - let its = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `its` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(its.completion_port.0) }.ok(); - // we leave the job handle unclosed, otherwise the Child is useless - // (as closing it may terminate the job) + fn into_inner(mut self: Box) -> Box { + let inner = self + .inner + .take() + .expect("an installed JobObject layer owns its child"); + if self.spawn_finalized && self.final_kill_on_drop { + let mut prepared = self.take_prepared(); + let job_port = prepared + .job_port + .take() + .expect("the installed JobObject layer retains its job handles"); + // Manually close the completion port while retaining the job handle. Closing a + // kill-on-close job here would make the extracted child unusable. + let job_port = std::mem::ManuallyDrop::new(job_port); + // SAFETY: `job_port` owns the completion-port handle and suppresses `JobPort::drop`. + unsafe { CloseHandle(HANDLE(job_port.completion_port.as_raw_handle())) }.ok(); } - // Before spawn finalization, dropping the still-armed job instead guarantees that removing this - // layer cannot let descendants escape a later lifecycle failure. + // Before spawn finalization, dropping the still-armed prepared job instead guarantees that + // removing this layer cannot let descendants escape a later lifecycle failure. inner } + fn retain_prepared_after_sidecar_removal_layer(&mut self) { + if self.extracted.is_none() { + let prepared = self + .prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type"); + self.extracted = Some(prepared); + } + } fn process_handle(&self) -> Option> { - self.inner.try_process_handle() + self.inner_ref().try_process_handle() + } + fn owns_job_object_cleanup_layer(&self) -> bool { + true } fn disarm_job_object_layer(&mut self) -> Result<()> { - set_job_kill_on_drop(self.job_port.job, self.final_kill_on_drop)?; + #[cfg(test)] + crate::windows::test_support::fail_final_owner()?; + let final_kill_on_drop = self.final_kill_on_drop; + #[cfg(test)] + let transition_probe = crate::windows::test_support::owner_transition_probe(); + #[cfg(test)] + self.with_job_port(|job_port| { + crate::windows::test_support::record_owner_event("before-owner-transition"); + set_job_kill_on_drop_observed(job_port.job, final_kill_on_drop, transition_probe) + })?; + #[cfg(not(test))] + self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; Ok(()) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - terminate_job(self.job_port.job, 1) + self.with_job_port(|job_port| terminate_job(job_port.job, 1)) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Result { - if let ChildExitStatus::Exited(status) = &self.exit_status { - return Ok(*status); + let Self { + inner, + prepared, + extracted, + exit_status, + job_drained, + .. + } = self; + let inner = inner + .as_deref_mut() + .expect("an installed JobObject layer owns its child"); + let mut wait_with = |job_port: &JobPort| { + wait_for_exit_and_job_drain_with( + exit_status, + job_drained, + || inner.wait(), + |timeout| { + poll_job_drain(job_port.job, job_port.completion_port.as_handle(), timeout) + }, + Instant::now, + std::thread::sleep, + |_| {}, + ) + }; + if let Some(extracted) = extracted.as_ref() { + return wait_with( + extracted + .job_port + .as_ref() + .expect("the extracted JobObject layer retains its job handles"), + ); } - - // always wait for parent to exit first, as by the time it does, - // it's likely that all its children have already exited. - let status = self.inner.wait()?; - self.exit_status = ChildExitStatus::Exited(status); - - // nevertheless, now wait and make sure we reap all children. - let JobPort { - completion_port, .. - } = self.job_port; - let _ = wait_on_job(completion_port, None)?; - Ok(status) + prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state") + .with::(|prepared| { + wait_with( + prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"), + ) + }) + .expect("JobObject prepared state retains its concrete type") } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn try_wait(&mut self) -> Result> { - let _ = wait_on_job(self.job_port.completion_port, Some(Duration::ZERO))?; - self.inner.try_wait() + if matches!(self.exit_status, ChildExitStatus::Running) { + let Some(status) = self.inner_mut_ref().try_wait()? else { + return Ok(None); + }; + self.exit_status = ChildExitStatus::Exited(status); + } + + if !self.job_drained + && self + .with_job_port(|job_port| { + poll_job_drain( + job_port.job, + job_port.completion_port.as_handle(), + Duration::ZERO, + ) + })? + .is_break() + { + self.job_drained = true; + } + + match (self.exit_status, self.job_drained) { + (ChildExitStatus::Exited(status), true) => Ok(Some(status)), + _ => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + cell::{Cell, RefCell}, + os::windows::{io::AsHandle, process::CommandExt, process::ExitStatusExt}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + }; + + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + use crate::std::{ProviderProduct, SpawnProvider}; + use crate::windows::test_support::{ + LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_owner_events, arm_owner_failure, + arm_owner_setter_failure_probe, arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, + assert_tree_terminated, clear_extra_prepared_owners, clear_owner_events, + clear_owner_failure, finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, + observe_descendant, publish_process_guards, record_owner_event, + spawn_cleanup_handle_close_count, + }; + + use super::*; + + #[derive(Debug)] + struct TreeProvider { + paths: TreePaths, + state: Arc, + } + + impl SpawnProvider for TreeProvider { + fn spawn( + &self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let policy = attempt.windows_spawn_policy(); + assert!(policy.has_job_object()); + assert!(policy.is_temporarily_suspended()); + let mut command = self.paths.direct_command()?; + command.creation_flags(CREATE_SUSPENDED.0); + let child = command.spawn()?; + let rollback_guard = publish_process_guards(&self.state, child.as_handle())?; + Ok(ProviderProduct::new( + Box::new(child), + Box::new(PanickingCommittedTransaction::new( + Arc::clone(&self.state), + rollback_guard, + )), + )) + } + } + + #[derive(Debug)] + struct TreeProviderWrapper(TreeProvider); + + impl CommandWrapper for TreeProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct ObserveTree { + paths: TreePaths, + state: Arc, + } + + impl CommandWrapper for ObserveTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + observe_descendant(&self.paths, &self.state) + } + } + + #[derive(Debug)] + struct ObserveNativeTree(ObserveTree); + + impl CommandWrapper for ObserveNativeTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + let handle = child + .try_process_handle() + .ok_or_else(|| Error::other("the native child has no process handle"))?; + let guard = ProcessGuard::clone_from(handle)?; + *self + .0 + .state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard); + observe_descendant(&self.0.paths, &self.0.state) + } + } + + #[derive(Debug)] + struct PostOwnerAccessorLayer { + inner: Option>, + prepared: Option, + slot_calls: Arc, + panic_on: usize, + } + + impl ChildWrapperLayer for PostOwnerAccessorLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + let call = self.slot_calls.fetch_add(1, Ordering::SeqCst) + 1; + record_owner_event("slot-accessor"); + if call == self.panic_on { + panic_any("slot accessor ran after final owner disarm"); + } + ChildWrapperSlots::new(&mut self.inner).with_prepared::<()>(&mut self.prepared) + } + } + + impl ChildWrapper for PostOwnerAccessorLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed accessor layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed accessor layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed accessor layer owns its child") + } + } + + #[derive(Debug)] + struct PostOwnerAccessor { + slot_calls: Arc, + panic_on: usize, + } + + impl CommandWrapper for PostOwnerAccessor { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result>> { + self.slot_calls.store(0, Ordering::SeqCst); + Ok(Some(Box::new(()))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::<()>)); + Ok(Some(PendingChildWrapper::new(PostOwnerAccessorLayer { + inner: None, + prepared: None, + slot_calls: Arc::clone(&self.slot_calls), + panic_on: self.panic_on, + }))) + } + } + + fn primary_owner_failure_preserved( + outcome: std::thread::Result>>, + identity: &Arc<()>, + was_panic: bool, + ) -> bool { + if was_panic { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => return false, + }; + match payload.downcast::() { + Ok(payload) => Arc::ptr_eq(&payload.0, identity), + Err(secondary) => { + std::mem::forget(secondary); + false + } + } + } else { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + return false; + } + }; + let error = match result { + Err(error) => error, + Ok(_) => return false, + }; + error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .is_some_and(|error| Arc::ptr_eq(&error.0, identity)) + } + } + + fn topology_command( + provider: bool, + paths: &TreePaths, + state: &Arc, + ) -> Result { + let mut command = if provider { + CommandWrap::new("provider-owned-program") + } else { + CommandWrap::from(paths.direct_command()?) + }; + command.wrap(JobObject); + let observer = ObserveTree { + paths: paths.clone(), + state: Arc::clone(state), + }; + if provider { + command + .wrap(observer) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(state), + })); + } else { + command.wrap(ObserveNativeTree(observer)); + } + Ok(command) + } + + #[test] + fn native_spawn_cleanup_handle_closes_with_returned_child() -> Result<()> { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(false, &paths, &state)?; + + for attempt in 0..2 { + arm_spawn_cleanup_handle_probe(); + let mut child = command.spawn()?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "the exact duplicate process handle remains owned through public spawn return" + ); + child.start_kill()?; + let _ = child.wait()?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "child operations retain cleanup-handle custody" + ); + if attempt == 0 { + drop(child); + } else { + let child = child.into_inner(); + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "consuming the custody sidecar closes the exact duplicate once" + ); + drop(child); + } + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "returned-child disposal closes the exact duplicate process handle once" + ); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert_tree_terminated(&paths, &state)?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + Ok(()) + } + + #[test] + fn native_final_owner_failures_close_armed_spawn_cleanup_once() -> Result<()> { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + let mut command = topology_command(false, &paths, &state)?; + arm_owner_failure(failure); + arm_owner_transition_probe(); + arm_spawn_cleanup_handle_probe(); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let probe = finish_owner_transition_probe(); + assert!(!probe.setter_attempted); + assert!( + !probe.transitioned, + "the native policy transition must not run" + ); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!( + !probe.query_attempted, + "the injected final-owner failure must occur before any native limit query attempt" + ); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert!( + !failure_was_not_consumed, + "the final owner hook did not consume its injected failure" + ); + assert!( + primary_preserved, + "armed cleanup replaced the final owner failure" + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait()?; + drop(child); + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + + #[test] + fn final_owner_setter_failure_records_attempt_without_transition() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_owner_setter_failure_probe(); + if !provider { + arm_spawn_cleanup_handle_probe(); + } + + let error = command + .spawn() + .expect_err("the injected native setter failure must fail spawn"); + let probe = finish_owner_transition_probe(); + assert_eq!(error.kind(), ErrorKind::Other); + assert!(probe.query_attempted); + assert!(probe.query_succeeded); + assert!(probe.query_attempted_before_setter); + assert!(probe.query_succeeded_before_setter); + assert!(probe.setter_attempted); + assert!( + !probe.transitioned, + "a failed native setter must not report a successful transition" + ); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + if !provider { + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!( + state.residue_drops.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait()?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + + #[test] + fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_extra_prepared_owner(); + + let error = command + .spawn() + .expect_err("an unexpected prepared owner must fail the lifecycle"); + let (injection_pending, retained_owners) = clear_extra_prepared_owners(); + assert!( + !injection_pending, + "the installation must consume the injection" + ); + assert_eq!(retained_owners, 1); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "prepared child state has an unexpected custody topology" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 0); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait()?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + } + Ok(()) + } + + #[test] + fn final_owner_transition_has_no_later_test_or_caller_operation() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let slot_calls = Arc::new(AtomicUsize::new(0)); + let events = Arc::new(std::sync::Mutex::new(Vec::with_capacity(2))); + let mut command = topology_command(provider, &paths, &state)?; + command.wrap(PostOwnerAccessor { + slot_calls: Arc::clone(&slot_calls), + panic_on: if provider { 4 } else { 3 }, + }); + + for attempt in 0..2 { + events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + arm_owner_transition_probe(); + arm_owner_events(Arc::clone(&events)); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let probe = finish_owner_transition_probe(); + let mut succeeded = false; + match outcome { + Ok(Ok(mut child)) => { + child.start_kill()?; + let _ = child.wait()?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = disposal + .expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + succeeded = true; + } + Ok(Err(_)) => {} + Err(payload) => std::mem::forget(payload), + } + assert!( + clear_owner_events(), + "the owner event barrier remained armed" + ); + assert!( + probe.query_attempted, + "the native limit query was attempted" + ); + assert!(probe.query_succeeded, "the native limit query succeeded"); + assert!( + probe.query_attempted_before_setter, + "the native limit query attempt must precede the setter attempt" + ); + assert!( + probe.query_succeeded_before_setter, + "the native limit query must complete before the setter attempt" + ); + assert!( + probe.setter_attempted, + "the terminal native setter was attempted" + ); + assert!( + probe.transitioned, + "the native final-owner transition completed" + ); + assert!( + !probe.query_attempted_after_transition, + "the terminal final-owner set must not be followed by another native limit query" + ); + assert!( + !probe.allocator_callback, + "an allocator callback ran after the native final-owner transition" + ); + assert!( + !probe.later_operation, + "a caller or test operation ran after the native final-owner transition" + ); + let tree_result = assert_tree_terminated(&paths, &state); + assert!( + succeeded, + "a post-owner-only accessor panic must remain unreachable" + ); + assert_eq!(slot_calls.load(Ordering::SeqCst), 1); + assert_eq!( + *events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ["slot-accessor", "before-owner-transition"], + "the final native transition follows the last caller-defined accessor" + ); + tree_result?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + } + Ok(()) + } + + #[test] + fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + arm_owner_failure(failure); + arm_owner_transition_probe(); + + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ObserveTree { + paths: paths.clone(), + state: Arc::clone(&state), + }) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(&state), + })); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let probe = finish_owner_transition_probe(); + let tree_result = assert_tree_terminated(&paths, &state); + + assert!(!probe.setter_attempted); + assert!(!probe.transitioned); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!(!probe.query_attempted); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); + + assert!( + !failure_was_not_consumed, + "the final owner hook did not run" + ); + assert!( + primary_preserved, + "cleanup replaced the final owner failure" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 1); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!(state.residue_drops.load(Ordering::SeqCst), 1); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + tree_result?; + } + Ok(()) + } + + #[test] + fn sustained_nonterminal_wakes_preserve_poll_cadence() -> Result<()> { + let status = ExitStatus::from_raw(0); + let mut exit_status = ChildExitStatus::Exited(status); + let mut job_drained = false; + let base = Instant::now(); + let elapsed = Cell::new(Duration::ZERO); + let poll_costs = [ + Duration::from_millis(2), + Duration::from_millis(7), + JOB_POLL_INTERVAL, + Duration::ZERO, + ]; + let mut poll_costs = poll_costs.into_iter(); + let mut outcomes = [ + ControlFlow::Continue(()), + ControlFlow::Continue(()), + ControlFlow::Continue(()), + ControlFlow::Break(()), + ] + .into_iter(); + let poll_starts = RefCell::new(Vec::new()); + let sleeps = RefCell::new(Vec::new()); + + let result = wait_for_exit_and_job_drain_with( + &mut exit_status, + &mut job_drained, + || unreachable!("the direct status was already cached"), + |timeout| { + assert_eq!(timeout, JOB_POLL_INTERVAL); + poll_starts.borrow_mut().push(elapsed.get()); + elapsed.set(elapsed.get() + poll_costs.next().expect("one cost per injected poll")); + Ok(outcomes.next().expect("one outcome per injected poll")) + }, + || base + elapsed.get(), + |duration| { + sleeps.borrow_mut().push(duration); + elapsed.set(elapsed.get() + duration); + }, + |_| {}, + )?; + + assert_eq!(result, status); + assert!(job_drained); + assert_eq!( + poll_starts.into_inner(), + [ + Duration::ZERO, + JOB_POLL_INTERVAL, + JOB_POLL_INTERVAL * 2, + JOB_POLL_INTERVAL * 3, + ] + ); + assert_eq!( + sleeps.into_inner(), + [Duration::from_millis(8), Duration::from_millis(3)] + ); + Ok(()) + } + + #[test] + fn blocking_wait_rendezvous_follows_cached_exit_and_false_drain_poll() -> Result<()> { + let (pending_tx, pending_rx) = mpsc::sync_channel(0); + let (release_tx, release_rx) = mpsc::channel(); + let (completed_tx, completed_rx) = mpsc::channel(); + let waiter = thread::spawn(move || { + let status = ExitStatus::from_raw(0); + let mut exit_status = ChildExitStatus::Running; + let mut job_drained = false; + let mut polls = [ControlFlow::Continue(()), ControlFlow::Break(())].into_iter(); + let result = wait_for_exit_and_job_drain_with( + &mut exit_status, + &mut job_drained, + || Ok(status), + |_| { + Ok(polls + .next() + .expect("the fake job drains on its second poll")) + }, + Instant::now, + |_| {}, + |cached_exit| { + assert!(matches!( + cached_exit, + ChildExitStatus::Exited(cached) if cached == status + )); + pending_tx + .send(()) + .expect("the test receives the pending rendezvous"); + release_rx + .recv() + .expect("the test releases the pending drain"); + }, + ); + let _ = completed_tx.send((result, exit_status, job_drained)); + }); + + if let Err(error) = pending_rx.recv_timeout(Duration::from_secs(5)) { + let _ = release_tx.send(()); + let _ = waiter.join(); + return Err(Error::new( + ErrorKind::TimedOut, + format!("wait did not enter the pending drain state: {error}"), + )); + } + let was_pending = matches!(completed_rx.try_recv(), Err(mpsc::TryRecvError::Empty)); + let _ = release_tx.send(()); + let completed = completed_rx.recv_timeout(Duration::from_secs(5)); + let joined = waiter.join(); + + assert!( + was_pending, + "wait returned before the injected drain release" + ); + joined.map_err(|_| Error::other("blocking wait seam thread panicked"))?; + let (result, exit_status, job_drained) = completed.map_err(|error| { + Error::new( + ErrorKind::TimedOut, + format!("wait did not finish after the injected release: {error}"), + ) + })?; + let status = result?; + assert!(status.success()); + assert!(matches!( + exit_status, + ChildExitStatus::Exited(cached) if cached == status + )); + assert!(job_drained); + Ok(()) } } diff --git a/src/std/process_group.rs b/src/std/process_group.rs index eff9047..743a483 100644 --- a/src/std/process_group.rs +++ b/src/std/process_group.rs @@ -3,16 +3,16 @@ use std::{ process::ExitStatus, }; -use nix::{ - sys::signal::{Signal, killpg}, - unistd::Pid, -}; +use nix::{sys::signal::Signal, unistd::Pid}; #[cfg(feature = "tracing")] use tracing::instrument; use crate::{ChildExitStatus, unix::ProcessGroupTarget}; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, SpawnAttempt, +}; /// Wrapper which sets the process group of a [`Command`](super::Command). /// @@ -44,6 +44,37 @@ impl ProcessGroup { target: ProcessGroupTarget::AttachTo(leader), } } + + pub(super) fn detached_child( + self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + ) -> Result { + if !inner.has_process_group_signal_capability() { + return Err(Error::new( + std::io::ErrorKind::Unsupported, + "the child does not expose liveness-gated process-group signalling", + )); + } + let direct_id = spawned_id.ok_or_else(|| { + Error::new( + std::io::ErrorKind::InvalidInput, + "the spawn transport did not retain its historical process ID", + ) + })?; + let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); + let pgid = match self.target { + ProcessGroupTarget::Leader => direct_pid, + ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( + i32::try_from(pgid).expect("process group IDs are validated before spawning"), + ), + }; + let exit_status = inner.try_wait()?; + Ok(PendingChildWrapper::new(ProcessGroupChild::detached( + pgid, + exit_status, + ))) + } } /// Wrapper for `Child` which signals the process group while its direct child is live. @@ -53,25 +84,32 @@ impl ProcessGroup { /// unrelated group. #[derive(Debug)] pub struct ProcessGroupChild { - inner: Box, + inner: Option>, exit_status: ChildExitStatus, pgid: Pid, } impl ProcessGroupChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - pub(crate) fn new( - inner: Box, - pgid: Pid, - exit_status: Option, - ) -> Self { + pub(crate) fn detached(pgid: Pid, exit_status: Option) -> Self { Self { - inner, + inner: None, exit_status: exit_status.map_or(ChildExitStatus::Running, ChildExitStatus::Exited), pgid, } } + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed process-group layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed process-group layer owns its child") + } + /// Get the process group ID of this child process. /// /// See: [`man 'setpgid(2)'`](https://www.man7.org/linux/man-pages/man2/setpgid.2.html) @@ -89,50 +127,67 @@ impl CommandWrapper for ProcessGroup { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - let direct_pid = Pid::from_raw(i32::try_from(inner.id()).map_err(Error::other)?); - let pgid = match self.target { - ProcessGroupTarget::Leader => direct_pid, - ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( - i32::try_from(pgid).expect("process group IDs are validated before spawning"), - ), - }; - let exit_status = inner.try_wait()?; + ) -> Result> { + let spawned_id = inner.try_spawned_id(); + self.detached_child(inner, spawned_id).map(Some) + } - Ok(Box::new(ProcessGroupChild::new(inner, pgid, exit_status))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + self.detached_child(inner, spawned_id).map(Some) } } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn signal_imp(&self, sig: Signal) -> Result<()> { + fn signal_imp(&mut self, sig: Signal) -> Result<()> { if matches!(self.exit_status, ChildExitStatus::Exited(_)) { return Ok(()); } - killpg(self.pgid, sig).map_err(Error::from) + let pgid = self.pgid.as_raw(); + let outcome = self + .inner_mut_ref() + .try_signal_process_group(pgid, sig as i32) + .ok_or_else(|| { + Error::new( + std::io::ErrorKind::Unsupported, + "the child lost its liveness-gated process-group signalling capability", + ) + })??; + if let Some(status) = outcome { + self.exit_status = ChildExitStatus::Exited(status); + } + Ok(()) + } +} + +impl ChildWrapperLayer for ProcessGroupChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) } } impl ChildWrapper for ProcessGroupChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed process-group layer owns its child") } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Running) { - if let Some(status) = self.inner.try_wait()? { - self.exit_status = ChildExitStatus::Exited(status); - } - } self.signal_imp(Signal::SIGKILL) } @@ -140,7 +195,7 @@ impl ChildWrapper for ProcessGroupChild { fn wait(&mut self) -> Result { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.wait()?; + let status = self.inner_mut_ref().wait()?; self.exit_status = ChildExitStatus::Exited(status); Ok(status) } @@ -152,7 +207,7 @@ impl ChildWrapper for ProcessGroupChild { fn try_wait(&mut self) -> Result> { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.try_wait()?; + let status = self.inner_mut_ref().try_wait()?; if let Some(status) = status { self.exit_status = ChildExitStatus::Exited(status); } @@ -162,7 +217,7 @@ impl ChildWrapper for ProcessGroupChild { } } - fn signal(&self, sig: i32) -> Result<()> { + fn signal(&mut self, sig: i32) -> Result<()> { self.signal_imp(Signal::try_from(sig)?) } } diff --git a/src/std/process_session.rs b/src/std/process_session.rs index 2e121de..e11db65 100644 --- a/src/std/process_session.rs +++ b/src/std/process_session.rs @@ -1,10 +1,9 @@ -use std::io::{Error, Result}; +use std::io::Result; -use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; -use super::{CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, core::ChildWrapper}; /// Wrapper which creates a new session and group for the `Command`. /// @@ -30,16 +29,23 @@ impl CommandWrapper for ProcessSession { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - let direct_pid = Pid::from_raw(i32::try_from(inner.id()).map_err(Error::other)?); - let exit_status = inner.try_wait()?; + ) -> Result> { + let spawned_id = inner.try_spawned_id(); + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) + } - Ok(Box::new(super::ProcessGroupChild::new( - inner, - direct_pid, - exit_status, - ))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) } } diff --git a/src/tokio.rs b/src/tokio.rs index 374af26..782c357 100644 --- a/src/tokio.rs +++ b/src/tokio.rs @@ -22,9 +22,12 @@ pub use crate::SpawnTransaction; pub use crate::WindowsSpawnPolicy; #[doc(inline)] pub use core::{ - ChildWrapper, Command, CommandWrap, CommandWrapper, ProviderProduct, SpawnAttempt, - SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrap, CommandWrapper, + PendingChildWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, }; +#[cfg(windows)] +#[doc(hidden)] +pub use core::{PreparedChild, PreparedChildRef}; #[cfg(all(windows, feature = "creation-flags"))] #[cfg_attr(docsrs, doc(cfg(all(windows, feature = "creation-flags"))))] #[doc(inline)] diff --git a/src/tokio/core.rs b/src/tokio/core.rs index 3d40452..78a99c4 100644 --- a/src/tokio/core.rs +++ b/src/tokio/core.rs @@ -10,6 +10,8 @@ use std::{ use std::os::windows::io::BorrowedHandle; use futures::future::try_join3; +#[cfg(all(unix, feature = "process-group"))] +use nix::sys::signal::killpg; #[cfg(unix)] use nix::{ sys::signal::{Signal, kill}, @@ -20,9 +22,114 @@ use tokio::{ process::{Child, ChildStderr, ChildStdin, ChildStdout, Command as NativeCommand}, }; -crate::generic_wrap::Wrap!(crate::Tokio1, NativeCommand, Child, ChildWrapper, |child| { - child -}); +#[cfg(windows)] +macro_rules! prepared_owner_child_contract { + () => { + #[cfg(feature = "pty")] + fn take_pty_controller_layer(&mut self) -> Option { + self.child_mut().take_pty_controller_layer() + } + + fn stdin(&mut self) -> &mut Option { + self.child_mut().stdin() + } + + fn stdout(&mut self) -> &mut Option { + self.child_mut().stdout() + } + + fn stderr(&mut self) -> &mut Option { + self.child_mut().stderr() + } + + fn id(&self) -> Option { + self.child_ref().id() + } + + fn kill(&mut self) -> Box> + Send + '_> { + self.child_mut().kill() + } + + fn start_kill(&mut self) -> Result<()> { + self.child_mut().start_kill() + } + + fn try_wait(&mut self) -> Result> { + self.child_mut().try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + self.child_mut().wait() + } + + fn wait_with_output( + mut self: Box, + ) -> Box> + Send> { + let child = self + .take_child() + .expect("a prepared-owner sidecar retains its child"); + prepared_owner_wait_with_output(child, self) + } + + #[cfg(unix)] + fn signal(&mut self, sig: i32) -> Result<()> { + self.child_mut().signal(sig) + } + }; +} + +crate::generic_wrap::Wrap!( + crate::Tokio1, + NativeCommand, + Child, + ChildWrapper, + |child| { child }, + "tokio", + prepared_owner_child_contract +); + +#[cfg(windows)] +struct PreparedOwnerWaitWithOutput { + // Field order is intentional: cancellation drops the child future before prepared storage. + child: Option> + Send>>>, + owner: Option>, +} + +#[cfg(windows)] +impl Future for PreparedOwnerWaitWithOutput { + type Output = Result; + + fn poll( + self: Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.get_mut(); + let result = match this + .child + .as_mut() + .expect("a prepared wait retains its child future") + .as_mut() + .poll(context) + { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(result) => result, + }; + drop(this.child.take()); + drop(this.owner.take()); + std::task::Poll::Ready(result) + } +} + +#[cfg(windows)] +fn prepared_owner_wait_with_output( + child: Box, + owner: Box, +) -> Box> + Send> { + Box::new(PreparedOwnerWaitWithOutput { + child: Some(Box::into_pin(child.wait_with_output())), + owner: Some(owner), + }) +} /// Wrapper for `tokio::process::Child`. /// @@ -115,21 +222,47 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { None } - /// Return the original PID retained by this exact provider-child layer. + /// Return the original PID retained by this exact child layer, if it advertises one. + /// + /// Process-wrap consults this historical identity only while installing `ProcessGroup` or + /// `ProcessSession` supervision, including when a post-spawn hook has already reaped the child. + /// It may preserve the PID observed at spawn for that construction step; it is not proof that the + /// child remains live and must not be used after spawn returns as a signal or kill target or as a + /// substitute for lower-child synchronized live signalling. /// - /// This internal capability lets a wrapper finish installation after an earlier post-spawn hook - /// observed and reaped a fast provider child. Ordinary child operations must continue to use - /// [`ChildWrapper::id`] so they never act on a recycled PID. + /// For this installation path, `try_spawned_id` consults this capability rather than the + /// live-facing [`ChildWrapper::id`]. Every self-terminal custom or provider child that advertises + /// composition with `ProcessGroup` or `ProcessSession` must override this method and retain the + /// original spawned PID. Transparent wrappers may retain the default so traversal can use a lower + /// layer's advertised capability. Children and wrappers that do not advertise this supervision + /// composition need not retain historical identity. #[doc(hidden)] - #[cfg(all( - unix, - feature = "pty", - any(feature = "process-group", feature = "process-session") - ))] + #[cfg(all(unix, feature = "process-group"))] fn spawned_id_layer(&self) -> Option { None } + /// Report whether this exact layer can linearize direct-child status refresh with group signalling. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + false + } + + /// Refresh direct-child status and signal `process_group` before releasing this layer's wait custody. + /// + /// `Ok(Some(status))` means the direct child was already reaped and no signal was issued; + /// `Ok(None)` means the signal was issued while the child remained unreaped. + #[doc(hidden)] + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + _process_group: i32, + _signal: i32, + ) -> Option>> { + None + } + /// Take the PTY controller owned by this exact child layer. #[doc(hidden)] #[cfg(feature = "pty")] @@ -150,22 +283,44 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// Disarm Windows cleanup state owned by this child layer. /// /// Process-wrap invokes this internal hook only after every ordinary spawn finalizer succeeds, so - /// cleanup remains armed if any earlier finalizer errors or panics. + /// cleanup remains armed if any earlier finalizer errors or hits an unwinding panic. #[doc(hidden)] #[cfg(windows)] fn disarm_spawn_cleanup_layer(&mut self) -> Result<()> { Ok(()) } - /// Disarm the JobObject cleanup state owned by this child layer. + /// Report whether this layer owns the JobObject rollback guard. /// - /// This process-wrap-internal phase runs after every other fallible finalizer and cleanup disarm. + /// Process-wrap counts owners and disarms every non-owner before a provider transaction commits. + /// More than one owner fails the lifecycle before commit. The sole owner stays armed until commit + /// succeeds and is then disarmed as the only post-commit child-layer operation. + #[doc(hidden)] + #[cfg(windows)] + fn owns_job_object_cleanup_layer(&self) -> bool { + false + } + + /// Disarm JobObject-phase cleanup state owned by this child layer. + /// + /// Non-owning hooks run before provider commit. The sole layer identified by + /// [`ChildWrapper::owns_job_object_cleanup_layer`] runs after commit and must be failure-atomic: + /// returning an error or unwinding must leave kill-on-close protection armed. If disarming uses a + /// native state transition, no caller-controlled callback may run after that transition succeeds. #[doc(hidden)] #[cfg(windows)] fn disarm_job_object_layer(&mut self) -> Result<()> { Ok(()) } + /// Move built-in prepared state into this layer before its private owner sidecar is removed. + /// + /// This hook is used only during caller-initiated, post-transfer consuming extraction. Custom + /// layers cannot obtain process-wrap's strong prepared owner through it. + #[doc(hidden)] + #[cfg(all(windows, feature = "job-object"))] + fn retain_prepared_after_sidecar_removal_layer(&mut self) {} + /// Obtain a clone if possible. /// /// Some implementations may make it possible to clone the implementing structure, even though @@ -195,12 +350,19 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { self.inner_mut().stderr() } - /// Obtain the `Child`'s process ID. + /// Obtain the `Child`'s live-facing process ID. + /// + /// Raw Tokio children return `Some(pid)` while the child is live and can return `None` after + /// completion. Custom wrappers may vary according to their documented behavior, so even `Some(pid)` + /// is not by itself proof that the child remains live. /// - /// In general this should be the PID of the top-level spawned process that was spawned - /// However, that may vary depending on what a wrapper does. + /// This value is distinct from the historical spawned ID exposed by + /// [`ChildWrapper::spawned_id_layer`] only while installing `ProcessGroup` or `ProcessSession` + /// supervision. That installation-only identity must not be used as post-return liveness proof or as + /// a signal or kill target. /// - /// Returns an `Option` to resemble Tokio's API, but isn't expected to be `None` in practice. + /// In general this should identify the top-level spawned process. However, that may vary depending + /// on what a wrapper does. fn id(&self) -> Option { self.inner().id() } @@ -292,11 +454,28 @@ pub trait ChildWrapper: Any + std::fmt::Debug + Send + Sync { /// was introduced by command-group to abstract over the signal behaviour between process groups /// and unwrapped processes. #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - self.inner().signal(sig) + fn signal(&mut self, sig: i32) -> Result<()> { + self.inner_mut().signal(sig) } } +#[cfg(unix)] +fn signal_child_if_running_with( + child: &mut Child, + signal: Signal, + send: impl FnOnce(Pid, Signal) -> std::result::Result<(), nix::errno::Errno>, +) -> Result> { + if let Some(status) = Child::try_wait(child)? { + return Ok(Some(status)); + } + let id = Child::id(child).ok_or_else(|| { + std::io::Error::other("a running Tokio child did not retain its process ID") + })?; + let pid = Pid::from_raw(i32::try_from(id).map_err(std::io::Error::other)?); + send(pid, signal).map_err(std::io::Error::from)?; + Ok(None) +} + impl ChildWrapper for Child { fn inner(&self) -> &dyn ChildWrapper { self @@ -326,8 +505,37 @@ impl ChildWrapper for Child { fn id(&self) -> Option { Child::id(self) } + #[cfg(all(unix, feature = "process-group"))] + fn spawned_id_layer(&self) -> Option { + Child::id(self) + } + #[cfg(all(unix, feature = "process-group"))] + fn has_process_group_signal_layer(&self) -> bool { + true + } + #[cfg(all(unix, feature = "process-group"))] + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let signal = match Signal::try_from(signal) { + Ok(signal) => signal, + Err(error) => return Some(Err(error.into())), + }; + Some(signal_child_if_running_with(self, signal, |_, signal| { + killpg(Pid::from_raw(process_group), signal) + })) + } fn start_kill(&mut self) -> Result<()> { - Child::start_kill(self) + #[cfg(unix)] + { + signal_child_if_running_with(self, Signal::SIGKILL, kill).map(drop) + } + #[cfg(not(unix))] + { + Child::start_kill(self) + } } fn try_wait(&mut self) -> Result> { Child::try_wait(self) @@ -336,16 +544,8 @@ impl ChildWrapper for Child { Box::pin(Child::wait(self)) } #[cfg(unix)] - fn signal(&self, sig: i32) -> Result<()> { - if let Some(id) = self.id() { - kill( - Pid::from_raw(i32::try_from(id).map_err(std::io::Error::other)?), - Signal::try_from(sig)?, - ) - .map_err(std::io::Error::from) - } else { - Ok(()) - } + fn signal(&mut self, sig: i32) -> Result<()> { + signal_child_if_running_with(self, Signal::try_from(sig)?, kill).map(drop) } } @@ -353,6 +553,20 @@ fn same_child(left: &dyn ChildWrapper, right: &dyn ChildWrapper) -> bool { std::ptr::addr_eq(left, right) && left.type_id() == right.type_id() } +#[cfg(windows)] +#[derive(Debug)] +pub(crate) struct FinalJobOwner { + identity: Option<(std::any::TypeId, *mut ())>, +} + +#[cfg(windows)] +fn spawn_layer_identity(layer: &mut dyn ChildWrapper) -> (std::any::TypeId, *mut ()) { + ( + (&*layer as &dyn Any).type_id(), + std::ptr::from_mut(layer).cast::<()>(), + ) +} + impl dyn ChildWrapper + '_ { fn downcast_ref(&self) -> Option<&T> { (self as &dyn Any).downcast_ref() @@ -362,11 +576,7 @@ impl dyn ChildWrapper + '_ { self.downcast_ref::().is_some() } - #[cfg(all( - unix, - feature = "pty", - any(feature = "process-group", feature = "process-session") - ))] + #[cfg(all(unix, feature = "process-group"))] pub(crate) fn try_spawned_id(&self) -> Option { let mut inner = self; loop { @@ -382,6 +592,44 @@ impl dyn ChildWrapper + '_ { } } + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn has_process_group_signal_capability(&self) -> bool { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return true; + } + let next = inner.inner(); + if same_child(inner, next) { + return false; + } + inner = next; + } + } + + #[cfg(all(unix, feature = "process-group"))] + pub(crate) fn try_signal_process_group( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let mut inner = self; + loop { + if inner.has_process_group_signal_layer() { + return inner.signal_process_group_layer(process_group, signal); + } + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return None; + } + inner = next; + } + } + /// Take the controller installed by a PTY spawn. /// /// This traverses arbitrary child-wrapper layers without removing them. The controller can be taken @@ -474,10 +722,69 @@ impl dyn ChildWrapper + '_ { } #[cfg(windows)] - pub(crate) fn finalize_spawn(&mut self) -> Result<()> { + pub(crate) fn finalize_spawn_before_commit(&mut self) -> Result { self.visit_spawn_layers(|inner| inner.finalize_spawn_layer())?; self.visit_spawn_layers(|inner| inner.disarm_spawn_cleanup_layer())?; - self.visit_spawn_layers(|inner| inner.disarm_job_object_layer()) + + let mut owners = 0; + let mut identity = None; + self.visit_spawn_layers(|inner| { + if inner.owns_job_object_cleanup_layer() { + owners += 1; + identity.get_or_insert_with(|| spawn_layer_identity(inner)); + Ok(()) + } else { + inner.disarm_job_object_layer() + } + })?; + if owners > 1 { + return Err(std::io::Error::other( + "multiple child layers own JobObject cleanup", + )); + } + Ok(FinalJobOwner { identity }) + } + + #[cfg(windows)] + pub(crate) fn finalize_spawn_final_owner(&mut self, owner: FinalJobOwner) -> Result<()> { + let Some(identity) = owner.identity else { + return Ok(()); + }; + let mut inner = self; + loop { + if spawn_layer_identity(inner) == identity { + return inner.disarm_job_object_layer(); + } + + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return Err(std::io::Error::other( + "the captured JobObject cleanup owner left the child chain", + )); + } + inner = next; + } + } + + #[cfg(all(windows, feature = "job-object"))] + pub(crate) fn retain_prepared_after_sidecar_removal(&mut self) { + let mut inner = self; + loop { + inner.retain_prepared_after_sidecar_removal_layer(); + let inner_type = (&*inner as &dyn Any).type_id(); + let inner_ptr = std::ptr::from_mut(inner); + let next = inner.inner_mut(); + if std::ptr::addr_eq(inner_ptr, std::ptr::from_mut(next)) + && inner_type == (&*next as &dyn Any).type_id() + { + return; + } + inner = next; + } } /// Try to obtain a reference to the underlying native [`Child`]. @@ -557,7 +864,343 @@ impl dyn ChildWrapper + '_ { } } +#[cfg(all(test, unix))] +mod signal_tests { + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, Instant}, + }; + + use super::*; + + struct ChildGuard(Option); + + impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0.as_mut().expect("an armed test guard owns its child") + } + + fn cleanup(&mut self) -> Result<()> { + let child = self.child_mut(); + match child.start_kill() { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if child.try_wait()?.is_some() { + self.0.take(); + return Ok(()); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Tokio signal seam child was not reaped", + )); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn finish(mut self) -> Result<()> { + self.cleanup() + } + } + + impl Drop for ChildGuard { + fn drop(&mut self) { + if self.0.is_some() { + let _ = self.cleanup(); + } + } + } + + #[tokio::test] + #[cfg_attr(miri, ignore = "requires a native child process")] + async fn terminal_status_prevents_the_production_signal_seam() -> Result<()> { + let child = NativeCommand::new("true").spawn()?; + let mut child = ChildGuard::new(child); + let status = child.child_mut().wait().await?; + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, Some(status)); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(child.child_mut().wait().await?, status); + child.finish()?; + Ok(()) + } + + #[tokio::test] + #[cfg_attr(miri, ignore = "requires a native child process")] + async fn live_status_reaches_the_production_signal_seam_once() -> Result<()> { + let child = NativeCommand::new("sh").args(["-c", "sleep 30"]).spawn()?; + let mut child = ChildGuard::new(child); + let calls = AtomicUsize::new(0); + + let observed = signal_child_if_running_with(child.child_mut(), Signal::SIGCONT, |_, _| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + })?; + + assert_eq!(observed, None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + child.finish()?; + Ok(()) + } +} + const _: () = { const fn assert_sync() {} assert_sync::(); }; + +#[cfg(all(test, windows, feature = "job-object"))] +mod prepared_output_wait_tests { + use std::{ + future::Future, + io, + pin::Pin, + process::{ExitStatus, Output}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + task::{Context, Poll}, + thread, + time::Duration, + }; + + use futures::task::noop_waker; + + use super::*; + + const TIMEOUT: Duration = Duration::from_secs(5); + + #[derive(Debug)] + struct PreparedDrop(Arc); + + impl Drop for PreparedDrop { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[derive(Debug)] + struct OutputChild { + future_drops: Arc, + ready: bool, + } + + impl ChildWrapper for OutputChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn wait_with_output( + self: Box, + ) -> Box> + Send> { + Box::new(OutputFuture { + drops: Arc::clone(&self.future_drops), + ready: self.ready, + }) + } + } + + struct OutputFuture { + drops: Arc, + ready: bool, + } + + impl Future for OutputFuture { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll { + if self.ready { + use std::os::windows::process::ExitStatusExt; + Poll::Ready(Ok(Output { + status: ExitStatus::from_raw(0), + stdout: Vec::new(), + stderr: Vec::new(), + })) + } else { + Poll::Pending + } + } + } + + impl Drop for OutputFuture { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + fn assert_output_wait_drops_child_before_blocking_on_owner(complete: bool) { + let prepared_drops = Arc::new(AtomicUsize::new(0)); + let future_drops = Arc::new(AtomicUsize::new(0)); + let racing_callbacks = Arc::new(AtomicUsize::new(0)); + let owner = PreparedChildOwner::new(Box::new(PreparedDrop(Arc::clone(&prepared_drops)))); + let state = Arc::clone(&owner.state); + let token = Arc::new(owner.installed_token()); + let child = PreparedOwnerChild::new( + Box::new(OutputChild { + future_drops: Arc::clone(&future_drops), + ready: complete, + }), + vec![Some(owner)], + ); + let output = child.wait_with_output(); + let mut cleanup = PreparedRaceCleanup::new(); + + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (inspection_tx, inspection_rx) = mpsc::channel(); + cleanup.releases.push(release_tx.clone()); + let inspection_token = Arc::clone(&token); + cleanup.workers.push(thread::spawn(move || { + let completed = inspection_token.with::(|_| { + let _ = entered_tx.send(()); + release_rx.recv().is_ok() + }) == Some(true); + let _ = inspection_tx.send(completed); + })); + entered_rx + .recv_timeout(TIMEOUT) + .expect("prepared inspection entered after upgrading and locking state"); + + let (racing_reached_tx, racing_reached_rx) = mpsc::channel(); + let (racing_release_tx, racing_release_rx) = mpsc::channel(); + *state + .value_lock_attempt + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { + reached: racing_reached_tx, + release: racing_release_rx, + publish_reached: true, + }); + cleanup.releases.push(racing_release_tx.clone()); + let (racing_tx, racing_rx) = mpsc::channel(); + let racing_token = Arc::clone(&token); + let racing_callback_calls = Arc::clone(&racing_callbacks); + cleanup.workers.push(thread::spawn(move || { + let accessed = racing_token + .with::(|_| { + racing_callback_calls.fetch_add(1, Ordering::SeqCst); + }) + .is_some(); + let _ = racing_tx.send(accessed); + })); + racing_reached_rx + .recv_timeout(TIMEOUT) + .expect("the racing accessor upgraded before owner closing"); + + let (closing_reached_tx, closing_reached_rx) = mpsc::channel(); + let (closing_release_tx, closing_release_rx) = mpsc::channel(); + *state + .after_closing + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(PreparedStateTestRendezvous { + reached: closing_reached_tx, + release: closing_release_rx, + publish_reached: true, + }); + cleanup.releases.push(closing_release_tx.clone()); + racing_release_tx + .send(()) + .expect("release the racing accessor to contend for the value"); + + let (finished_tx, finished_rx) = mpsc::channel(); + cleanup.workers.push(thread::spawn(move || { + if complete { + let mut output = Box::into_pin(output); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + assert!(matches!( + output.as_mut().poll(&mut context), + Poll::Ready(Ok(_)) + )); + } else { + drop(output); + } + let _ = finished_tx.send(()); + })); + closing_reached_rx + .recv_timeout(TIMEOUT) + .expect("output-wait disposal published owner closing"); + assert!( + state + .admission + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .closing, + "the output-wait owner reached the authoritative closed state" + ); + assert_eq!( + future_drops.load(Ordering::SeqCst), + 1, + "the child future drops before prepared-owner closing" + ); + assert_eq!( + finished_rx.try_recv(), + Err(mpsc::TryRecvError::Empty), + "output-wait disposal returned during immutable prepared access" + ); + assert_eq!(prepared_drops.load(Ordering::SeqCst), 0); + + release_tx + .send(()) + .expect("release the finite prepared inspection"); + assert!( + !racing_rx + .recv_timeout(TIMEOUT) + .expect("the queued prepared access completed before owner take"), + "a queued access invoked its callback after owner closing" + ); + assert_eq!(racing_callbacks.load(Ordering::SeqCst), 0); + closing_release_tx + .send(()) + .expect("release output-wait owner disposal"); + finished_rx + .recv_timeout(TIMEOUT) + .expect("output-wait disposal completes after prepared inspection release"); + assert_eq!(prepared_drops.load(Ordering::SeqCst), 1); + assert!( + token.with::(|_| ()).is_none(), + "a retained token accessed prepared state after owner destruction" + ); + assert!( + inspection_rx + .recv_timeout(TIMEOUT) + .expect("prepared inspection worker completed") + ); + cleanup.finish(); + } + + #[test] + fn cancellation_drops_child_future_before_revoking_prepared_owner() { + assert_output_wait_drops_child_before_blocking_on_owner(false); + } + + #[test] + fn completion_drops_child_future_before_revoking_prepared_owner() { + assert_output_wait_drops_child_before_blocking_on_owner(true); + } +} diff --git a/src/tokio/job_object.rs b/src/tokio/job_object.rs index 74a2929..dfa8ace 100644 --- a/src/tokio/job_object.rs +++ b/src/tokio/job_object.rs @@ -2,33 +2,40 @@ use std::{ any::Any, future::Future, io::{Error, ErrorKind, Result}, - os::windows::io::{AsRawHandle, BorrowedHandle}, + os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle}, pin::Pin, process::ExitStatus, - time::Duration, + time::{Duration, Instant}, }; -use tokio::task::spawn_blocking; #[cfg(feature = "tracing")] use tracing::{debug, instrument}; -use windows::Win32::{ - Foundation::{CloseHandle, HANDLE}, - System::Threading::PROCESS_CREATION_FLAGS, -}; +use windows::Win32::{Foundation::HANDLE, System::Threading::PROCESS_CREATION_FLAGS}; use crate::{ ChildExitStatus, windows::{ - JobPort, job_creation_flags, make_job_object, resume_threads, set_job_kill_on_drop, - terminate_job, wait_on_job, + JOB_POLL_INTERVAL, JobPort, job_creation_flags, make_job_object, poll_job_drain, + resume_threads, terminate_job, }, }; +#[cfg(feature = "kill-on-drop")] +use crate::windows::release_extracted_job_port; + +#[cfg(not(test))] +use crate::windows::set_job_kill_on_drop; +#[cfg(test)] +use crate::windows::set_job_kill_on_drop_observed; + #[cfg(feature = "creation-flags")] use super::CreationFlags; #[cfg(feature = "kill-on-drop")] use super::KillOnDrop; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, PreparedChild, PreparedChildRef, SpawnAttempt, +}; /// Wrapper which creates a job object context for a `Command`. /// @@ -42,6 +49,14 @@ use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; /// /// [`CreationFlags`] may be registered before or after `JobObject`; process-wrap preserves its flags /// and distinguishes explicit suspension from the temporary suspension needed for assignment. +/// `KillOnDrop` may likewise be registered before or after `JobObject`. With both wrappers installed, +/// successful spawn enables `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so ordinary drop while the +/// `JobObject` layer remains installed terminates every process still associated with the job. Without +/// `JobObject`, the lower Tokio child's kill-on-drop policy targets only that direct child. +/// +/// Consuming the `JobObject` layer through [`ChildWrapper::into_inner`] relinquishes whole-job waiting, +/// explicit whole-job killing, and kill-on-close supervision. The returned lower child remains usable +/// and retains only its direct-child kill-on-drop policy. #[derive(Clone, Copy, Debug)] pub struct JobObject; @@ -64,8 +79,7 @@ fn terminate_child(child: &mut dyn ChildWrapper) { #[derive(Debug)] struct PreparedJobObject { - job_port: JobPort, - final_kill_on_drop: bool, + job_port: Option, } impl JobObject { @@ -74,16 +88,10 @@ impl JobObject { child: &mut dyn ChildWrapper, core: &CommandWrap, ) -> Result { - #[cfg(feature = "kill-on-drop")] - let kill_on_drop = core.has_wrap::(); - #[cfg(not(feature = "kill-on-drop"))] - let kill_on_drop = false; - let policy = job_creation_flags(user_creation_flags(core)); #[cfg(feature = "tracing")] debug!( - ?kill_on_drop, resume_after_assignment = policy.resume_after_assignment, "options from other wrappers" ); @@ -121,8 +129,7 @@ impl JobObject { } Ok(PreparedJobObject { - job_port, - final_kill_on_drop: kill_on_drop, + job_port: Some(job_port), }) } } @@ -146,138 +153,1486 @@ impl CommandWrapper for JobObject { fn wrap_prepared_child( &mut self, - inner: Box, - prepared: Option>, + _inner: &mut dyn ChildWrapper, + prepared: Option>, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { let prepared = prepared.expect("JobObject child preparation always produces state"); - let prepared = match prepared.downcast::() { - Ok(prepared) => *prepared, - Err(_) => unreachable!("JobObject prepared state retains its concrete type"), - }; - Ok(Box::new(JobObjectChild::new( - inner, - prepared.job_port, - prepared.final_kill_on_drop, - ))) - } - - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self, inner)))] - fn wrap_child( - &mut self, - mut inner: Box, - core: &CommandWrap, - ) -> Result> { - let prepared = self.prepare_job(inner.as_mut(), core)?; - let mut child = JobObjectChild::new(inner, prepared.job_port, prepared.final_kill_on_drop); - child.disarm_job_object_layer()?; - Ok(Box::new(child)) + assert!( + prepared.is::(), + "JobObject prepared state retains its concrete type" + ); + #[cfg(feature = "kill-on-drop")] + let final_kill_on_drop = _core.has_wrap::(); + #[cfg(not(feature = "kill-on-drop"))] + let final_kill_on_drop = false; + Ok(Some(PendingChildWrapper::new(JobObjectChild::detached( + final_kill_on_drop, + )))) } } /// Wrapper for `Child` which waits on all processes within the job. +/// +/// When `KillOnDrop` is also registered, ordinary drop while this layer remains installed closes the +/// final job handle and terminates every process still associated with the job. Consuming this layer +/// relinquishes whole-job wait, kill, and kill-on-close supervision; the returned lower child retains +/// only direct-child policy. #[derive(Debug)] pub struct JobObjectChild { - inner: Box, + inner: Option>, + prepared: Option, + extracted: Option, exit_status: ChildExitStatus, - job_port: JobPort, + job_drained: bool, + poll_cadence: Option>, final_kill_on_drop: bool, spawn_finalized: bool, } impl JobObjectChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(job_port)))] - pub(crate) fn new( - inner: Box, - job_port: JobPort, - final_kill_on_drop: bool, - ) -> Self { + pub(crate) fn detached(final_kill_on_drop: bool) -> Self { Self { - inner, + inner: None, + prepared: None, + extracted: None, exit_status: ChildExitStatus::Running, - job_port, + job_drained: false, + poll_cadence: None, final_kill_on_drop, spawn_finalized: false, } } + + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed JobObject layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed JobObject layer owns its child") + } + + fn prepared(&self) -> &PreparedChild { + self.prepared + .as_ref() + .expect("an installed JobObject layer owns its prepared state") + } + + fn with_job_port(&self, inspect: impl FnOnce(&JobPort) -> R) -> R { + if let Some(prepared) = self.extracted.as_ref() { + return inspect( + prepared + .job_port + .as_ref() + .expect("the extracted JobObject layer retains its job handles"), + ); + } + self.prepared().with_required::( + || panic!("JobObject prepared state retains its concrete type"), + |prepared| { + let job_port = prepared + .job_port + .as_ref() + .expect("the installed JobObject layer retains its job handles"); + inspect(job_port) + }, + ) + } + + #[cfg(feature = "kill-on-drop")] + fn take_prepared(&mut self) -> PreparedJobObject { + if let Some(prepared) = self.extracted.take() { + return prepared; + } + self.prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type") + } + + fn arm_poll_cadence(&mut self, elapsed: Duration) { + debug_assert!(self.poll_cadence.is_none()); + let remaining = JOB_POLL_INTERVAL.saturating_sub(elapsed); + if !remaining.is_zero() { + // The blocking task owns only inert timing data. In particular, it cannot prolong or + // outlive any borrow of the JobObject or completion-port handles. + self.poll_cadence = Some(tokio::task::spawn_blocking(move || { + std::thread::sleep(remaining); + })); + } + } + + async fn wait_for_poll_cadence(&mut self) -> Result<()> { + let Some(cadence) = self.poll_cadence.as_mut() else { + return Ok(()); + }; + let result = cadence.await; + self.poll_cadence = None; + result.map_err(Error::other) + } + + fn clear_poll_cadence(&mut self) { + if let Some(cadence) = self.poll_cadence.take() { + // `abort` can prevent a queued blocking task from starting. If it has started, Tokio + // lets the short sleep finish detached; its closure owns no native resource. + cadence.abort(); + } + } +} + +impl ChildWrapperLayer for JobObjectChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) + } } impl ChildWrapper for JobObjectChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { - let Self { - inner, - job_port, - final_kill_on_drop, - spawn_finalized, - .. - } = *self; - if spawn_finalized && final_kill_on_drop { - // manually drop the completion port - let its = std::mem::ManuallyDrop::new(job_port); - // SAFETY: `its` owns the completion-port handle and suppresses `JobPort::drop`. - unsafe { CloseHandle(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. + fn into_inner(mut self: Box) -> Box { + let inner = self + .inner + .take() + .expect("an installed JobObject layer owns its child"); + #[cfg(feature = "kill-on-drop")] + if self.spawn_finalized && self.final_kill_on_drop { + let job_drained = self.job_drained; + let mut prepared = self.take_prepared(); + let job_port = prepared + .job_port + .take() + .expect("the installed JobObject layer retains its job handles"); + release_extracted_job_port(job_port, job_drained); + } + // Before spawn finalization, dropping the still-armed prepared job instead guarantees that + // removing this layer cannot let descendants escape a later lifecycle failure. inner } + fn retain_prepared_after_sidecar_removal_layer(&mut self) { + if self.extracted.is_none() { + let prepared = self + .prepared() + .take_prepared_value::() + .expect("JobObject prepared state retains its concrete type"); + self.extracted = Some(prepared); + } + } fn process_handle(&self) -> Option> { - self.inner.try_process_handle() + self.inner_ref().try_process_handle() + } + fn owns_job_object_cleanup_layer(&self) -> bool { + true } fn disarm_job_object_layer(&mut self) -> Result<()> { - set_job_kill_on_drop(self.job_port.job, self.final_kill_on_drop)?; + #[cfg(test)] + crate::windows::test_support::fail_final_owner()?; + let final_kill_on_drop = self.final_kill_on_drop; + #[cfg(test)] + let transition_probe = crate::windows::test_support::owner_transition_probe(); + #[cfg(test)] + self.with_job_port(|job_port| { + crate::windows::test_support::record_owner_event("before-owner-transition"); + set_job_kill_on_drop_observed(job_port.job, final_kill_on_drop, transition_probe) + })?; + #[cfg(not(test))] + self.with_job_port(|job_port| set_job_kill_on_drop(job_port.job, final_kill_on_drop))?; self.spawn_finalized = true; Ok(()) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - terminate_job(self.job_port.job, 1) + self.with_job_port(|job_port| terminate_job(job_port.job, 1)) } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wait(&mut self) -> Pin> + Send + '_>> { Box::pin(async { - if let ChildExitStatus::Exited(status) = &self.exit_status { - return Ok(*status); - } + let status = match self.exit_status { + ChildExitStatus::Running => { + // Cache direct-child exit independently from whole-job drain. There is no await + // between observing the status and retaining it, so cancellation cannot lose it. + let status = self.inner_mut_ref().wait().await?; + self.exit_status = ChildExitStatus::Exited(status); + status + } + ChildExitStatus::Exited(status) => status, + }; - const MAX_RETRY_ATTEMPT: usize = 10; + while !self.job_drained { + // A canceled wait leaves this handle in `self`, so its successor cannot poll again + // until the already-armed cadence completes. + self.wait_for_poll_cadence().await?; + let poll_started = Instant::now(); + if self + .with_job_port(|job_port| { + poll_job_drain( + job_port.job, + job_port.completion_port.as_handle(), + Duration::ZERO, + ) + })? + .is_break() + { + // No await occurs between the authoritative accounting result and this durable + // transition, so a canceled future cannot consume the drain state. + self.job_drained = true; + self.clear_poll_cadence(); + break; + } + self.arm_poll_cadence(poll_started.elapsed()); + } + Ok(status) + }) + } - // always wait for parent to exit first, as by the time it does, - // it's likely that all its children have already exited. - let status = self.inner.wait().await?; + #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] + fn try_wait(&mut self) -> Result> { + if matches!(self.exit_status, ChildExitStatus::Running) { + let Some(status) = self.inner_mut_ref().try_wait()? else { + return Ok(None); + }; self.exit_status = ChildExitStatus::Exited(status); + } + + if !self.job_drained + && self + .with_job_port(|job_port| { + poll_job_drain( + job_port.job, + job_port.completion_port.as_handle(), + Duration::ZERO, + ) + })? + .is_break() + { + self.job_drained = true; + self.clear_poll_cadence(); + } + + match (self.exit_status, self.job_drained) { + (ChildExitStatus::Exited(status), true) => Ok(Some(status)), + _ => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + future::{Future, poll_fn}, + os::windows::{io::BorrowedHandle, process::CommandExt}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + path::{Path, PathBuf}, + process::{Command as StdCommand, Stdio}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::Poll, + }; + + use windows::Win32::System::Threading::{ + CREATE_SUSPENDED, GetCurrentProcess, GetProcessHandleCount, + }; + + use crate::tokio::{ProviderProduct, SpawnProvider}; + use crate::windows::{ + reset_job_port_custodian_for_test, + test_support::{ + LifecycleState, OwnerError, OwnerFailure, OwnerPanic, PanickingCommittedTransaction, + ProcessGuard, TreePaths, arm_extra_prepared_owner, arm_job_port_close_probe, + arm_owner_events, arm_owner_failure, arm_owner_setter_failure_probe, + arm_owner_transition_probe, arm_spawn_cleanup_handle_probe, assert_tree_terminated, + clear_extra_prepared_owners, clear_job_port_close_probe, clear_owner_events, + clear_owner_failure, finish_owner_transition_probe, finish_spawn_cleanup_handle_probe, + job_custodian_worker_starts, job_extraction_branch_counts, + job_port_last_resort_retentions, observe_descendant, publish_process_guards, + record_owner_event, reset_job_extraction_faults, serial_job_extraction, + set_job_custodian_start_failures, set_job_extraction_disarm_failures, + set_job_extraction_post_query_failures, set_job_extraction_pre_query_failures, + set_job_extraction_query_failures, set_job_extraction_still_set_observations, + spawn_cleanup_handle_close_count, + }, + }; - // nevertheless, now try reaping all children a few times... - for _ in 1..MAX_RETRY_ATTEMPT { - if wait_on_job(self.job_port.completion_port, Some(Duration::ZERO))?.is_break() { + use super::*; + + #[derive(Debug)] + struct TreeProvider { + paths: TreePaths, + state: Arc, + } + + impl SpawnProvider for TreeProvider { + fn spawn( + &self, + attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let policy = attempt.windows_spawn_policy(); + assert!(policy.has_job_object()); + assert!(policy.is_temporarily_suspended()); + let mut command = self.paths.direct_command()?; + command.creation_flags(CREATE_SUSPENDED.0); + let mut command = tokio::process::Command::from(command); + let child = command.spawn()?; + let handle = child + .raw_handle() + .ok_or_else(|| Error::other("the provider child has no process handle"))?; + // SAFETY: `child` owns this process handle throughout both cloning operations. + let handle = unsafe { BorrowedHandle::borrow_raw(handle) }; + let rollback_guard = publish_process_guards(&self.state, handle)?; + Ok(ProviderProduct::new( + Box::new(child), + Box::new(PanickingCommittedTransaction::new( + Arc::clone(&self.state), + rollback_guard, + )), + )) + } + } + + #[derive(Debug)] + struct TreeProviderWrapper(TreeProvider); + + impl CommandWrapper for TreeProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct ObserveTree { + paths: TreePaths, + state: Arc, + } + + impl CommandWrapper for ObserveTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + observe_descendant(&self.paths, &self.state) + } + } + + #[derive(Debug)] + struct ObserveNativeTree(ObserveTree); + + impl CommandWrapper for ObserveNativeTree { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result<()> { + let handle = child + .try_process_handle() + .ok_or_else(|| Error::other("the native child has no process handle"))?; + let guard = ProcessGuard::clone_from(handle)?; + *self + .0 + .state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard); + observe_descendant(&self.0.paths, &self.0.state) + } + } + + #[derive(Debug)] + struct PostOwnerAccessorLayer { + inner: Option>, + prepared: Option, + slot_calls: Arc, + panic_on: usize, + } + + impl ChildWrapperLayer for PostOwnerAccessorLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + let call = self.slot_calls.fetch_add(1, Ordering::SeqCst) + 1; + record_owner_event("slot-accessor"); + if call == self.panic_on { + panic_any("slot accessor ran after final owner disarm"); + } + ChildWrapperSlots::new(&mut self.inner).with_prepared::<()>(&mut self.prepared) + } + } + + impl ChildWrapper for PostOwnerAccessorLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed accessor layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed accessor layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed accessor layer owns its child") + } + } + + #[derive(Debug)] + struct PostOwnerAccessor { + slot_calls: Arc, + panic_on: usize, + } + + impl CommandWrapper for PostOwnerAccessor { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> Result>> { + self.slot_calls.store(0, Ordering::SeqCst); + Ok(Some(Box::new(()))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::<()>)); + Ok(Some(PendingChildWrapper::new(PostOwnerAccessorLayer { + inner: None, + prepared: None, + slot_calls: Arc::clone(&self.slot_calls), + panic_on: self.panic_on, + }))) + } + } + + fn primary_owner_failure_preserved( + outcome: std::thread::Result>>, + identity: &Arc<()>, + was_panic: bool, + ) -> bool { + if was_panic { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => return false, + }; + match payload.downcast::() { + Ok(payload) => Arc::ptr_eq(&payload.0, identity), + Err(secondary) => { + std::mem::forget(secondary); + false + } + } + } else { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + return false; + } + }; + let error = match result { + Err(error) => error, + Ok(_) => return false, + }; + error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .is_some_and(|error| Arc::ptr_eq(&error.0, identity)) + } + } + + fn topology_command( + provider: bool, + paths: &TreePaths, + state: &Arc, + ) -> Result { + let mut command = if provider { + CommandWrap::new("provider-owned-program") + } else { + CommandWrap::from(tokio::process::Command::from(paths.direct_command()?)) + }; + command.wrap(JobObject); + let observer = ObserveTree { + paths: paths.clone(), + state: Arc::clone(state), + }; + if provider { + command + .wrap(observer) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(state), + })); + } else { + command.wrap(ObserveNativeTree(observer)); + } + Ok(command) + } + + struct ExtractionFaultCleanup; + + impl Drop for ExtractionFaultCleanup { + fn drop(&mut self) { + reset_job_extraction_faults(); + } + } + + fn extraction_command(exit_immediately: bool, job_first: bool) -> CommandWrap { + let mut command = if exit_immediately { + CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }) + } else { + CommandWrap::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "ping -n 30 127.0.0.1 >NUL"]); + }) + }; + if job_first { + command.wrap(JobObject).wrap(KillOnDrop); + } else { + command.wrap(KillOnDrop).wrap(JobObject); + } + command + } + + fn wait_for_job_port_closes( + probe: &crate::windows::test_support::JobPortCloseProbe, + expected: (usize, usize), + ) { + let deadline = Instant::now() + Duration::from_secs(5); + while probe.counts() != expected { + assert!( + Instant::now() < deadline, + "JobObject custodian did not close both handles: {:?}", + probe.counts() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + const EXTRACTION_HANDLE_COUNT_CHILD: &str = "PROCESS_WRAP_EXTRACTION_HANDLE_COUNT_CHILD"; + const EXTRACTION_DESCENDANT_PID: &str = "PROCESS_WRAP_EXTRACTION_DESCENDANT_PID"; + const EXTRACTION_DESCENDANT_RELEASE: &str = "PROCESS_WRAP_EXTRACTION_DESCENDANT_RELEASE"; + + struct BoundedTestChild(Option); + + impl BoundedTestChild { + fn wait(mut self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = self + .0 + .as_mut() + .expect("an armed test subprocess guard owns its child") + .try_wait()? + { + self.0.take(); return Ok(status); } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "isolated handle-count regression exceeded its deadline", + )); + } + std::thread::sleep(Duration::from_millis(10)); } + } + } - // ...finally, if there are some that are still alive, - // block in the background to reap them fully. - let JobPort { - completion_port, .. - } = self.job_port; - let _ = spawn_blocking(move || wait_on_job(completion_port, None)).await??; - Ok(status) - }) + impl Drop for BoundedTestChild { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } } - #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn try_wait(&mut self) -> Result> { - let _ = wait_on_job(self.job_port.completion_port, Some(Duration::ZERO))?; - self.inner.try_wait() + fn current_process_handle_count() -> Result { + let mut count = 0; + // SAFETY: GetCurrentProcess returns the current-process pseudo-handle, and `count` is writable + // storage for the duration of GetProcessHandleCount. + unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count) }.map_err(Error::other)?; + Ok(count) + } + + fn wait_for_pid_file(path: &Path) -> Result { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(pid) = + fs::read_to_string(path).and_then(|pid| pid.trim().parse().map_err(Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "extraction descendant did not report its PID", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + #[ignore = "subprocess helper"] + fn extraction_custody_descendant_helper() -> Result<()> { + let release = PathBuf::from( + std::env::var_os(EXTRACTION_DESCENDANT_RELEASE) + .ok_or_else(|| Error::other("extraction release path is missing"))?, + ); + let deadline = Instant::now() + Duration::from_secs(30); + while !release.exists() { + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "extraction descendant was not released", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(()) + } + + #[test] + #[ignore = "subprocess helper"] + fn extraction_custody_direct_helper() -> Result<()> { + let pid_file = PathBuf::from( + std::env::var_os(EXTRACTION_DESCENDANT_PID) + .ok_or_else(|| Error::other("extraction descendant PID path is missing"))?, + ); + let release = std::env::var_os(EXTRACTION_DESCENDANT_RELEASE) + .ok_or_else(|| Error::other("extraction release path is missing"))?; + let mut descendant = StdCommand::new(std::env::current_exe()?) + .args([ + "--exact", + "tokio::job_object::tests::extraction_custody_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env(EXTRACTION_DESCENDANT_RELEASE, release) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + fs::write(pid_file, descendant.id().to_string())?; + let _ = descendant.wait()?; + Ok(()) + } + + fn extraction_tree_command(pid_file: &Path, release: &Path) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args([ + "--exact", + "tokio::job_object::tests::extraction_custody_direct_helper", + "--ignored", + "--nocapture", + ]) + .env(EXTRACTION_DESCENDANT_PID, pid_file) + .env(EXTRACTION_DESCENDANT_RELEASE, release) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) + } + + fn run_handle_count_regression_in_subprocess() -> Result<()> { + let mut command = StdCommand::new(std::env::current_exe()?); + command + .args([ + "--exact", + "tokio::job_object::tests::drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly", + "--nocapture", + ]) + .env(EXTRACTION_HANDLE_COUNT_CHILD, "1") + .stdin(Stdio::null()); + let child = command.spawn()?; + let status = BoundedTestChild(Some(child)).wait(Duration::from_secs(60))?; + if status.success() { + Ok(()) + } else { + Err(Error::other(format!( + "isolated handle-count regression exited with {status}" + ))) + } + } + + async fn warm_handle_count_infrastructure() -> Result<()> { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release"); + let mut command = StdCommand::new(std::env::current_exe()?); + command + .args([ + "--exact", + "tokio::job_object::tests::extraction_custody_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env(EXTRACTION_DESCENDANT_RELEASE, &release) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let mut child = command.spawn()?; + + // Tokio registers its Windows process wait only after the first poll establishes that the + // process is still live. The helper cannot exit before `release` exists, so this poll must + // initialize RegisterWaitForSingleObject rather than take the already-exited fast path. + let mut wait = Box::pin(child.wait()); + let first_poll = poll_fn(|cx| Poll::Ready(wait.as_mut().poll(cx))).await; + assert!( + first_poll.is_pending(), + "the registered-wait warm-up child exited before release" + ); + drop(wait); + + fs::write(&release, [])?; + assert!(child.wait().await?.success()); + // Reaping drops Tokio's per-child wait registration, whose Windows implementation uses + // UnregisterWaitEx with INVALID_HANDLE_VALUE to synchronously finish any callback. + tokio::task::spawn_blocking(|| {}) + .await + .map_err(Error::other)?; + Ok(()) + } + + async fn run_drained_extraction(job_first: bool) -> Result<()> { + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(true, job_first); + let mut child = command.spawn()?; + clear_job_port_close_probe(); + let status = child.wait().await?; + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (1, 1)); + assert_eq!(lower.wait().await?, status); + assert_eq!(lower.try_wait()?, Some(status)); + Ok(()) + } + + async fn run_drained_extraction_batch() -> Result<()> { + for job_first in [false, true] { + for _ in 0..32 { + run_drained_extraction(job_first).await?; + } + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn drained_kill_on_drop_extraction_closes_complete_job_ports_repeatedly() -> Result<()> { + if std::env::var_os(EXTRACTION_HANDLE_COUNT_CHILD).is_none() { + return run_handle_count_regression_in_subprocess(); + } + let _serial = serial_job_extraction(); + + warm_handle_count_infrastructure().await?; + for job_first in [false, true] { + run_drained_extraction(job_first).await?; + } + + let warmed_baseline = current_process_handle_count()?; + run_drained_extraction_batch().await?; + let batch_a = current_process_handle_count()?; + run_drained_extraction_batch().await?; + let batch_b = current_process_handle_count()?; + assert!( + batch_a <= warmed_baseline && batch_b <= batch_a, + "drained extraction handle counts increased after warm-up: \ + warmed baseline {warmed_baseline}, batch A {batch_a}, batch B {batch_b}" + ); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn live_kill_on_drop_extraction_disarms_and_closes_before_returning() -> Result<()> { + let _serial = serial_job_extraction(); + for job_first in [false, true] { + let baseline = job_extraction_branch_counts(); + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, job_first); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (1, 1)); + let verified = job_extraction_branch_counts(); + assert!(verified.disarm_attempts > baseline.disarm_attempts); + assert!(verified.set_successes > baseline.set_successes); + assert!(verified.verification_queries > baseline.verification_queries); + assert!(verified.verified_clears > baseline.verified_clears); + assert_eq!(lower.try_wait()?, None); + lower.start_kill()?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + } + Ok(()) + } + + #[derive(Clone, Copy, Debug)] + enum LiveDisarmFault { + PreQuery, + Set, + PostQueryFailure, + StillSet, + } + + impl LiveDisarmFault { + fn arm(self, count: usize) { + match self { + Self::PreQuery => set_job_extraction_pre_query_failures(count), + Self::Set => set_job_extraction_disarm_failures(count), + Self::PostQueryFailure => set_job_extraction_post_query_failures(count), + Self::StillSet => set_job_extraction_still_set_observations(count), + } + } + } + + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn live_disarm_fault_enters_custody(fault: LiveDisarmFault) -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); + let worker_starts = job_custodian_worker_starts(); + let baseline = job_extraction_branch_counts(); + fault.arm(usize::MAX); + + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, true); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + assert_eq!( + probe.counts(), + (0, 0), + "{fault:?} must transfer the complete live port instead of closing it" + ); + assert_eq!(lower.try_wait()?, None); + let deadline = Instant::now() + Duration::from_secs(5); + while job_custodian_worker_starts() == worker_starts { + assert!( + Instant::now() < deadline, + "{fault:?} did not start the shared custodian" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job_custodian_worker_starts(), worker_starts + 1); + assert_eq!(probe.counts(), (0, 0)); + let unresolved = job_extraction_branch_counts(); + assert!(unresolved.disarm_attempts > baseline.disarm_attempts); + assert_eq!(unresolved.verified_clears, baseline.verified_clears); + match fault { + LiveDisarmFault::PreQuery | LiveDisarmFault::Set => { + assert_eq!(unresolved.set_successes, baseline.set_successes); + assert_eq!( + unresolved.verification_queries, + baseline.verification_queries + ); + } + LiveDisarmFault::PostQueryFailure | LiveDisarmFault::StillSet => { + assert!(unresolved.set_successes > baseline.set_successes); + assert!(unresolved.verification_queries > baseline.verification_queries); + } + } + + fault.arm(0); + wait_for_job_port_closes(&probe, (1, 1)); + let resolved = job_extraction_branch_counts(); + assert!(resolved.verified_clears > baseline.verified_clears); + assert_eq!( + lower.try_wait()?, + None, + "verified custodian disarm must preserve the lower child" + ); + lower.start_kill()?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_pre_query_failure_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::PreQuery).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_set_failure_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::Set).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_post_query_failure_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::PostQueryFailure).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_still_set_post_query_enters_custody() -> Result<()> { + live_disarm_fault_enters_custody(LiveDisarmFault::StillSet).await + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn extraction_failures_use_one_shared_custodian_then_close_every_port() -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); + let worker_starts = job_custodian_worker_starts(); + set_job_extraction_query_failures(usize::MAX); + set_job_extraction_disarm_failures(usize::MAX); + let mut lowers = Vec::new(); + let mut probes = Vec::new(); + for _ in 0..2 { + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, true); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (0, 0)); + assert_eq!(lower.try_wait()?, None); + lowers.push(lower); + probes.push(probe); + } + let deadline = Instant::now() + Duration::from_secs(5); + while job_custodian_worker_starts() == worker_starts { + assert!( + Instant::now() < deadline, + "the shared custodian did not start" + ); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(job_custodian_worker_starts(), worker_starts + 1); + + reset_job_extraction_faults(); + for lower in &mut lowers { + lower.start_kill()?; + let _ = lower.wait().await?; + } + for probe in &probes { + wait_for_job_port_closes(probe, (1, 1)); + } + assert_eq!(job_custodian_worker_starts(), worker_starts + 1); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn persistent_disarm_failure_closes_only_after_authoritative_tree_drain() -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release = directory.path().join("release-descendant"); + let baseline = job_extraction_branch_counts(); + let worker_starts = job_custodian_worker_starts(); + set_job_extraction_query_failures(usize::MAX); + set_job_extraction_disarm_failures(usize::MAX); + let probe = arm_job_port_close_probe(); + let mut command = extraction_tree_command(&pid_file, &release)?; + command.wrap(JobObject).wrap(KillOnDrop); + let child = command.spawn()?; + clear_job_port_close_probe(); + let direct_pid = child.id().expect("the direct child retains its PID"); + let mut direct = ProcessGuard::open(direct_pid)?; + let mut descendant = ProcessGuard::open(wait_for_pid_file(&pid_file)?)?; + let mut lower = child.into_inner(); + assert_eq!(probe.counts(), (0, 0)); + assert!( + !direct.has_exited()?, + "retained custody preserves the direct child" + ); + assert!( + !descendant.has_exited()?, + "retained custody preserves the descendant" + ); + let deadline = Instant::now() + Duration::from_secs(5); + while job_custodian_worker_starts() == worker_starts { + assert!( + Instant::now() < deadline, + "the shared custodian did not start" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let unresolved = job_extraction_branch_counts(); + assert!( + unresolved.disarm_attempts > baseline.disarm_attempts, + "custody retried native disarm" + ); + assert_eq!( + unresolved.set_successes, baseline.set_successes, + "no extracted-job setter may succeed in this branch" + ); + assert_eq!( + unresolved.verification_queries, baseline.verification_queries, + "a failed setter cannot reach post-set verification" + ); + assert_eq!( + unresolved.verified_clears, baseline.verified_clears, + "no extracted-job disarm may verify clear in this branch" + ); + assert_eq!( + unresolved.drain_observations, baseline.drain_observations, + "the live tree has not drained" + ); + + set_job_extraction_query_failures(0); + fs::File::create(&release)?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + direct.wait_for_exit("the extracted direct child")?; + descendant.wait_for_exit("the extracted descendant")?; + wait_for_job_port_closes(&probe, (1, 1)); + let drained = job_extraction_branch_counts(); + assert!( + drained.accounting_queries > baseline.accounting_queries, + "authoritative accounting was queried" + ); + assert!( + drained.drain_observations > baseline.drain_observations, + "authoritative accounting observed drain" + ); + assert!( + drained.disarm_attempts > baseline.disarm_attempts, + "native disarm remained attempted" + ); + assert_eq!( + drained.set_successes, baseline.set_successes, + "complete closure must not be attributed to setter success" + ); + assert_eq!( + drained.verified_clears, baseline.verified_clears, + "complete closure must not be attributed to verified disarm" + ); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + #[allow(clippy::await_holding_lock)] // Serializes process-global native fault injection. + async fn custodian_start_failure_uses_only_exact_last_resort_retention() -> Result<()> { + let _serial = serial_job_extraction(); + let _fault_cleanup = ExtractionFaultCleanup; + reset_job_port_custodian_for_test(); + let retained = job_port_last_resort_retentions(); + set_job_extraction_query_failures(1); + set_job_extraction_disarm_failures(1); + set_job_custodian_start_failures(1); + let probe = arm_job_port_close_probe(); + let mut command = extraction_command(false, true); + let child = command.spawn()?; + clear_job_port_close_probe(); + let mut lower = child.into_inner(); + + assert_eq!(probe.counts(), (0, 0)); + assert_eq!(job_port_last_resort_retentions(), retained + 1); + assert_eq!(lower.try_wait()?, None); + lower.start_kill()?; + let status = lower.wait().await?; + assert_eq!(lower.wait().await?, status); + assert_eq!(probe.counts(), (0, 0)); + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn native_spawn_cleanup_handle_closes_with_returned_child() -> Result<()> { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(false, &paths, &state)?; + + for attempt in 0..2 { + arm_spawn_cleanup_handle_probe(); + let mut child = command.spawn()?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "the exact duplicate process handle remains owned through public spawn return" + ); + child.start_kill()?; + let _ = child.wait().await?; + assert_eq!( + spawn_cleanup_handle_close_count(), + 0, + "child operations retain cleanup-handle custody" + ); + if attempt == 0 { + drop(child); + } else { + let child = child.into_inner(); + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "consuming the custody sidecar closes the exact duplicate once" + ); + drop(child); + } + assert_eq!( + spawn_cleanup_handle_close_count(), + 1, + "returned-child disposal closes the exact duplicate process handle once" + ); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert_tree_terminated(&paths, &state)?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn native_final_owner_failures_close_armed_spawn_cleanup_once() -> Result<()> { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + let mut command = topology_command(false, &paths, &state)?; + arm_owner_failure(failure); + arm_owner_transition_probe(); + arm_spawn_cleanup_handle_probe(); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let probe = finish_owner_transition_probe(); + assert!(!probe.setter_attempted); + assert!( + !probe.transitioned, + "the native policy transition must not run" + ); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!( + !probe.query_attempted, + "the injected final-owner failure must occur before any native limit query attempt" + ); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + assert!( + !failure_was_not_consumed, + "the final owner hook did not consume its injected failure" + ); + assert!( + primary_preserved, + "armed cleanup replaced the final owner failure" + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait().await?; + drop(child); + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn final_owner_setter_failure_records_attempt_without_transition() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_owner_setter_failure_probe(); + if !provider { + arm_spawn_cleanup_handle_probe(); + } + + let error = command + .spawn() + .expect_err("the injected native setter failure must fail spawn"); + let probe = finish_owner_transition_probe(); + assert_eq!(error.kind(), ErrorKind::Other); + assert!(probe.query_attempted); + assert!(probe.query_succeeded); + assert!(probe.query_attempted_before_setter); + assert!(probe.query_succeeded_before_setter); + assert!(probe.setter_attempted); + assert!( + !probe.transitioned, + "a failed native setter must not report a successful transition" + ); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + if !provider { + assert_eq!(spawn_cleanup_handle_close_count(), 1); + assert_eq!(finish_spawn_cleanup_handle_probe(), 1); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!( + state.residue_drops.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait().await?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn unexpected_prepared_owner_fails_native_and_provider_spawns_before_success() + -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let mut command = topology_command(provider, &paths, &state)?; + arm_extra_prepared_owner(); + + let error = command + .spawn() + .expect_err("an unexpected prepared owner must fail the lifecycle"); + let (injection_pending, retained_owners) = clear_extra_prepared_owners(); + assert!( + !injection_pending, + "the installation must consume the injection" + ); + assert_eq!(retained_owners, 1); + assert_eq!(error.kind(), ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "prepared child state has an unexpected custody topology" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 0); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + assert_tree_terminated(&paths, &state)?; + std::fs::remove_file(&paths.descendant_pid)?; + + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill()?; + let _ = child.wait().await?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = + disposal.expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + assert_tree_terminated(&paths, &state)?; + assert_eq!(state.commits.load(Ordering::SeqCst), usize::from(provider)); + assert_eq!( + state.rollbacks.load(Ordering::SeqCst), + usize::from(provider) + ); + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn final_owner_transition_has_no_later_test_or_caller_operation() -> Result<()> { + for provider in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let slot_calls = Arc::new(AtomicUsize::new(0)); + let events = Arc::new(std::sync::Mutex::new(Vec::with_capacity(2))); + let mut command = topology_command(provider, &paths, &state)?; + command.wrap(PostOwnerAccessor { + slot_calls: Arc::clone(&slot_calls), + panic_on: if provider { 4 } else { 3 }, + }); + + for attempt in 0..2 { + events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + arm_owner_transition_probe(); + arm_owner_events(Arc::clone(&events)); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let probe = finish_owner_transition_probe(); + let mut succeeded = false; + match outcome { + Ok(Ok(mut child)) => { + child.start_kill()?; + let _ = child.wait().await?; + let disposal = catch_unwind(AssertUnwindSafe(|| drop(child))); + if provider { + let payload = disposal + .expect_err("the provider fixture residue panics on disposal"); + std::mem::forget(payload); + } else { + disposal.expect("native child disposal does not panic"); + } + succeeded = true; + } + Ok(Err(_)) => {} + Err(payload) => std::mem::forget(payload), + } + assert!( + clear_owner_events(), + "the owner event barrier remained armed" + ); + assert!( + probe.query_attempted, + "the native limit query was attempted" + ); + assert!(probe.query_succeeded, "the native limit query succeeded"); + assert!( + probe.query_attempted_before_setter, + "the native limit query attempt must precede the setter attempt" + ); + assert!( + probe.query_succeeded_before_setter, + "the native limit query must complete before the setter attempt" + ); + assert!( + probe.setter_attempted, + "the terminal native setter was attempted" + ); + assert!( + probe.transitioned, + "the native final-owner transition completed" + ); + assert!( + !probe.query_attempted_after_transition, + "the terminal final-owner set must not be followed by another native limit query" + ); + assert!( + !probe.allocator_callback, + "an allocator callback ran after the native final-owner transition" + ); + assert!( + !probe.later_operation, + "a caller or test operation ran after the native final-owner transition" + ); + let tree_result = assert_tree_terminated(&paths, &state); + assert!( + succeeded, + "a post-owner-only accessor panic must remain unreachable" + ); + assert_eq!(slot_calls.load(Ordering::SeqCst), 1); + assert_eq!( + *events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ["slot-accessor", "before-owner-transition"], + "the final native transition follows the last caller-defined accessor" + ); + tree_result?; + if attempt == 0 { + std::fs::remove_file(&paths.descendant_pid)?; + } + } + } + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn final_owner_failures_terminate_the_real_job_tree_and_preserve_identity() -> Result<()> + { + for was_panic in [false, true] { + let directory = tempfile::tempdir()?; + let paths = TreePaths::new(directory.path()); + let state = Arc::new(LifecycleState::default()); + let identity = Arc::new(()); + let failure = if was_panic { + OwnerFailure::Panic(Arc::clone(&identity)) + } else { + OwnerFailure::Error(Arc::clone(&identity)) + }; + arm_owner_failure(failure); + arm_owner_transition_probe(); + + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ObserveTree { + paths: paths.clone(), + state: Arc::clone(&state), + }) + .wrap(TreeProviderWrapper(TreeProvider { + paths: paths.clone(), + state: Arc::clone(&state), + })); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let failure_was_not_consumed = clear_owner_failure(); + let primary_preserved = primary_owner_failure_preserved(outcome, &identity, was_panic); + let probe = finish_owner_transition_probe(); + let tree_result = assert_tree_terminated(&paths, &state); + + assert!(!probe.setter_attempted); + assert!(!probe.transitioned); + assert!(!probe.query_attempted_after_transition); + assert!(!probe.allocator_callback); + assert!(!probe.later_operation); + assert!(!probe.query_attempted); + assert!(!probe.query_succeeded); + assert!(!probe.query_attempted_before_setter); + assert!(!probe.query_succeeded_before_setter); + + assert!( + !failure_was_not_consumed, + "the final owner hook did not run" + ); + assert!( + primary_preserved, + "cleanup replaced the final owner failure" + ); + assert_eq!(state.commits.load(Ordering::SeqCst), 1); + assert_eq!(state.rollbacks.load(Ordering::SeqCst), 0); + assert_eq!(state.residue_drops.load(Ordering::SeqCst), 1); + assert_eq!(state.payload_drops.load(Ordering::SeqCst), 0); + tree_result?; + } + Ok(()) } } diff --git a/src/tokio/kill_on_drop.rs b/src/tokio/kill_on_drop.rs index 15b71b8..26c192c 100644 --- a/src/tokio/kill_on_drop.rs +++ b/src/tokio/kill_on_drop.rs @@ -10,7 +10,10 @@ use super::{CommandWrap, CommandWrapper, SpawnAttempt}; /// /// On Unix, dropping a process-group or session child still applies Tokio's kill-on-drop behavior to /// the direct native child only. Use the group-aware child's explicit kill or signal methods when the -/// entire process group must be targeted. +/// entire process group must be targeted. On Windows, combining this wrapper with `JobObject` in either +/// registration order enables kill-on-last-job-handle-close for every process still associated with +/// that job while the JobObject layer remains installed. Consuming that layer relinquishes whole-job +/// supervision and leaves only the lower direct-child policy. #[derive(Clone, Copy, Debug)] pub struct KillOnDrop; diff --git a/src/tokio/process_group.rs b/src/tokio/process_group.rs index 6017041..131c613 100644 --- a/src/tokio/process_group.rs +++ b/src/tokio/process_group.rs @@ -5,16 +5,16 @@ use std::{ process::ExitStatus, }; -use nix::{ - sys::signal::{Signal, killpg}, - unistd::Pid, -}; +use nix::{sys::signal::Signal, unistd::Pid}; #[cfg(feature = "tracing")] use tracing::instrument; use crate::{ChildExitStatus, unix::ProcessGroupTarget}; -use super::{ChildWrapper, CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, + PendingChildWrapper, SpawnAttempt, +}; /// Wrapper which sets the process group of a `Command`. /// @@ -51,6 +51,37 @@ impl ProcessGroup { target: ProcessGroupTarget::AttachTo(leader), } } + + pub(super) fn detached_child( + self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + ) -> Result { + if !inner.has_process_group_signal_capability() { + return Err(Error::new( + std::io::ErrorKind::Unsupported, + "the child does not expose liveness-gated process-group signalling", + )); + } + let direct_id = spawned_id.ok_or_else(|| { + Error::new( + std::io::ErrorKind::InvalidInput, + "the spawn transport did not retain its historical process ID", + ) + })?; + let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); + let pgid = match self.target { + ProcessGroupTarget::Leader => direct_pid, + ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( + i32::try_from(pgid).expect("process group IDs are validated before spawning"), + ), + }; + let exit_status = inner.try_wait()?; + Ok(PendingChildWrapper::new(ProcessGroupChild::detached( + pgid, + exit_status, + ))) + } } /// Wrapper for `Child` which signals the process group while its direct child is live. @@ -60,25 +91,32 @@ impl ProcessGroup { /// unrelated group. #[derive(Debug)] pub struct ProcessGroupChild { - inner: Box, + inner: Option>, exit_status: ChildExitStatus, pgid: Pid, } impl ProcessGroupChild { - #[cfg_attr(feature = "tracing", instrument(level = "debug"))] - pub(crate) fn new( - inner: Box, - pgid: Pid, - exit_status: Option, - ) -> Self { + pub(crate) fn detached(pgid: Pid, exit_status: Option) -> Self { Self { - inner, + inner: None, exit_status: exit_status.map_or(ChildExitStatus::Running, ChildExitStatus::Exited), pgid, } } + fn inner_ref(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed process-group layer owns its child") + } + + fn inner_mut_ref(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed process-group layer owns its child") + } + /// Get the process group ID of this child process. /// /// See: [`man 'setpgid(2)'`](https://www.man7.org/linux/man-pages/man2/setpgid.2.html) @@ -96,61 +134,67 @@ impl CommandWrapper for ProcessGroup { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - let mut direct_id = inner.id(); - #[cfg(feature = "pty")] - if direct_id.is_none() { - direct_id = inner.try_spawned_id(); - } - let direct_id = direct_id.ok_or_else(|| { - Error::new( - std::io::ErrorKind::InvalidInput, - "the child exited before process-group supervision could retain its PID", - ) - })?; - let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); - let pgid = match self.target { - ProcessGroupTarget::Leader => direct_pid, - ProcessGroupTarget::AttachTo(pgid) => Pid::from_raw( - i32::try_from(pgid).expect("process group IDs are validated before spawning"), - ), - }; - let exit_status = inner.try_wait()?; + ) -> Result> { + let spawned_id = inner.try_spawned_id(); + self.detached_child(inner, spawned_id).map(Some) + } - Ok(Box::new(ProcessGroupChild::new(inner, pgid, exit_status))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + self.detached_child(inner, spawned_id).map(Some) } } impl ProcessGroupChild { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] - fn signal_imp(&self, sig: Signal) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Exited(_)) || self.inner.id().is_none() { + fn signal_imp(&mut self, sig: Signal) -> Result<()> { + if matches!(self.exit_status, ChildExitStatus::Exited(_)) { return Ok(()); } - killpg(self.pgid, sig).map_err(Error::from) + let pgid = self.pgid.as_raw(); + let outcome = self + .inner_mut_ref() + .try_signal_process_group(pgid, sig as i32) + .ok_or_else(|| { + Error::new( + std::io::ErrorKind::Unsupported, + "the child lost its liveness-gated process-group signalling capability", + ) + })??; + if let Some(status) = outcome { + self.exit_status = ChildExitStatus::Exited(status); + } + Ok(()) + } +} + +impl ChildWrapperLayer for ProcessGroupChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) } } impl ChildWrapper for ProcessGroupChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner_ref() } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner_mut_ref() } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed process-group layer owns its child") } #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn start_kill(&mut self) -> Result<()> { - if matches!(self.exit_status, ChildExitStatus::Running) { - if let Some(status) = self.inner.try_wait()? { - self.exit_status = ChildExitStatus::Exited(status); - } - } self.signal_imp(Signal::SIGKILL) } @@ -159,7 +203,7 @@ impl ChildWrapper for ProcessGroupChild { Box::pin(async { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.wait().await?; + let status = self.inner_mut_ref().wait().await?; self.exit_status = ChildExitStatus::Exited(status); Ok(status) } @@ -172,7 +216,7 @@ impl ChildWrapper for ProcessGroupChild { fn try_wait(&mut self) -> Result> { match self.exit_status { ChildExitStatus::Running => { - let status = self.inner.try_wait()?; + let status = self.inner_mut_ref().try_wait()?; if let Some(status) = status { self.exit_status = ChildExitStatus::Exited(status); } @@ -182,7 +226,7 @@ impl ChildWrapper for ProcessGroupChild { } } - fn signal(&self, sig: i32) -> Result<()> { + fn signal(&mut self, sig: i32) -> Result<()> { self.signal_imp(Signal::try_from(sig)?) } } diff --git a/src/tokio/process_session.rs b/src/tokio/process_session.rs index 80d658f..f27edca 100644 --- a/src/tokio/process_session.rs +++ b/src/tokio/process_session.rs @@ -1,10 +1,9 @@ -use std::io::{Error, ErrorKind, Result}; +use std::io::Result; -use nix::unistd::Pid; #[cfg(feature = "tracing")] use tracing::instrument; -use super::{CommandWrap, CommandWrapper, SpawnAttempt}; +use super::{CommandWrap, CommandWrapper, PendingChildWrapper, SpawnAttempt, core::ChildWrapper}; /// Wrapper which creates a new session and group for the `Command`. /// @@ -33,27 +32,23 @@ impl CommandWrapper for ProcessSession { #[cfg_attr(feature = "tracing", instrument(level = "debug", skip(self)))] fn wrap_child( &mut self, - mut inner: Box, + inner: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - let mut direct_id = inner.id(); - #[cfg(feature = "pty")] - if direct_id.is_none() { - direct_id = inner.try_spawned_id(); - } - let direct_id = direct_id.ok_or_else(|| { - Error::new( - ErrorKind::InvalidInput, - "the child exited before session supervision could retain its PID", - ) - })?; - let direct_pid = Pid::from_raw(i32::try_from(direct_id).map_err(Error::other)?); - let exit_status = inner.try_wait()?; + ) -> Result> { + let spawned_id = inner.try_spawned_id(); + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) + } - Ok(Box::new(super::ProcessGroupChild::new( - inner, - direct_pid, - exit_status, - ))) + fn wrap_child_with_spawned_id( + &mut self, + inner: &mut dyn ChildWrapper, + spawned_id: Option, + _core: &CommandWrap, + ) -> Result> { + super::ProcessGroup::leader() + .detached_child(inner, spawned_id) + .map(Some) } } diff --git a/src/tokio/pty.rs b/src/tokio/pty.rs index b2f75ed..af96a6c 100644 --- a/src/tokio/pty.rs +++ b/src/tokio/pty.rs @@ -5,6 +5,7 @@ use std::{ }; #[cfg(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -15,14 +16,22 @@ use std::{ target_os = "openbsd", target_os = "solaris" ))] -use std::{ - future::Future, - process::ExitStatus, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, }; +#[cfg(any( + target_os = "android", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "illumos", + target_os = "linux", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris" +))] +use std::{future::Future, process::ExitStatus, sync::Arc}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; #[cfg(any( @@ -65,6 +74,7 @@ use super::{Command, CommandWrapper, ProviderProduct, SpawnAttempt, SpawnProvide ))] mod unix; #[cfg(not(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -77,7 +87,6 @@ mod unix; )))] mod unsupported; #[cfg(windows)] -#[allow(dead_code)] mod windows; #[cfg(any( target_os = "android", @@ -92,6 +101,7 @@ mod windows; ))] use unix as imp; #[cfg(not(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -103,6 +113,8 @@ use unix as imp; target_os = "solaris" )))] use unsupported as imp; +#[cfg(windows)] +use windows as imp; /// A pseudo-terminal spawn provider for Tokio commands. /// @@ -114,9 +126,14 @@ use unsupported as imp; /// # use std::io; /// use process_wrap::tokio::{Command, Pty}; /// # fn run() -> io::Result<()> { +/// #[cfg(unix)] /// let mut command = Command::with_new("sh", |command| { /// command.args(["-c", "printf terminal"]); /// }); +/// #[cfg(windows)] +/// let mut command = Command::with_new("cmd.exe", |command| { +/// command.args(["/d", "/s", "/c", "echo terminal"]); +/// }); /// command.wrap(Pty::default()); /// let mut child = command.spawn()?; /// let controller = child @@ -127,12 +144,34 @@ use unsupported as imp; /// # } /// # fn main() {} /// ``` +/// +/// On Windows, direct `.bat` and `.cmd` programs are rejected case-insensitively. Invoke `cmd.exe` +/// explicitly with arguments such as `/d /s /c ...` for shell or batch semantics. The provider +/// resolves executable entries itself, returns `NotFound` for unresolved bare programs, and does not +/// perform implicit shell or `PATHEXT` dispatch. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct Pty { size: PtySize, } impl Pty { + /// Check whether the selected PTY backend is available on this platform and runtime. + /// + /// This does not validate a [`Pty`] configuration or attempt a spawn. A successful result only + /// reports backend capability; a particular spawn can still fail for its command, configuration, + /// or environment. + pub fn check_supported() -> io::Result<()> { + imp::check_available() + } + + /// Return whether the selected PTY backend is available on this platform and runtime. + /// + /// This is equivalent to [`Pty::check_supported`].is_ok(). A `true` result does not guarantee a + /// particular spawn will succeed. + pub fn is_supported() -> bool { + Self::check_supported().is_ok() + } + /// Construct a PTY provider with the requested initial terminal size. pub fn new(size: PtySize) -> Self { Self { size } @@ -156,11 +195,11 @@ impl CommandWrapper for Pty { impl SpawnProvider for Pty { fn check_available(&self) -> io::Result<()> { - imp::check_available() + Self::check_supported() } fn validate_command(&self, _command: &Command) -> io::Result<()> { - self.size.validate() + imp::validate_size(self.size) } fn validate_attempt(&self, attempt: &SpawnAttempt, _command: &Command) -> io::Result<()> { @@ -194,8 +233,10 @@ impl SpawnProvider for Pty { /// The character and pixel dimensions of a pseudo-terminal. /// -/// Character dimensions must both be nonzero. Pixel dimensions may be zero when they are unknown or -/// not meaningful to the caller. Sizes are validated before spawning and by [`PtyResize::resize`]. +/// Character dimensions must both be nonzero. The Windows backend additionally requires each +/// character dimension to fit in a signed 16-bit console coordinate (`1..=32767`). Pixel dimensions +/// may be zero when they are unknown or not meaningful to the caller. Sizes are validated before +/// spawning and by [`PtyResize::resize`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PtySize { /// Character rows. @@ -312,7 +353,8 @@ pub struct PtyResize { impl PtyResize { /// Change the terminal size. /// - /// Returns [`io::ErrorKind::BrokenPipe`] after both strong I/O owners have been dropped. + /// Generic nonzero character-size validation runs first. After both strong I/O owners have been + /// dropped, a valid request returns [`io::ErrorKind::BrokenPipe`]. pub fn resize(&self, size: PtySize) -> io::Result<()> { size.validate()?; self.inner.resize(size) @@ -321,11 +363,14 @@ impl PtyResize { /// The I/O and resize controls for a spawned pseudo-terminal. /// -/// [`PtyInput`] and [`PtyOutput`] each strongly own one shared bidirectional master. The terminal -/// hangs up only after both owners are gone; dropping either one alone is not a half-close. A -/// [`PtyResize`] is weak and never keeps the terminal alive. There is deliberately no clonable -/// force-close handle, parent-terminal raw mode, byte relay, key handling, VT parsing, scrollback, -/// or pager policy in this transport. +/// [`PtyInput`] and [`PtyOutput`] each strongly own one shared logical bidirectional controller. +/// Dropping both releases that master ownership, and valid requests through weak [`PtyResize`] handles +/// then return `BrokenPipe`; dropping either one alone is not a half-close. On Windows, final +/// strong-owner loss schedules `ClosePseudoConsole` on a detached worker. If worker creation fails, +/// process-wrap intentionally retains the HPCON rather than potentially blocking the dropping thread, +/// so logical controller closure does not guarantee OS pseudoconsole teardown. There is deliberately +/// no clonable force-close handle, parent-terminal raw mode, byte relay, key handling, VT parsing, +/// scrollback, or pager policy in this transport. /// /// Process supervision and PTY draining are separate lifecycles. Waiting for the direct child does /// not imply output EOF on most supported Unix systems, because descendants can retain slave @@ -370,6 +415,7 @@ impl PtyController { } #[cfg(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -387,6 +433,7 @@ pub(super) struct ControllerSlot { } #[cfg(any( + windows, target_os = "android", target_os = "dragonfly", target_os = "freebsd", @@ -405,6 +452,14 @@ impl ControllerSlot { } } + #[cfg(all(test, windows))] + pub(super) fn empty_for_test() -> Self { + Self { + controller: Mutex::new(None), + committed: AtomicBool::new(false), + } + } + pub(super) fn commit(&self) { self.committed.store(true, Ordering::Release); } @@ -542,15 +597,29 @@ impl ChildWrapper for PtyChild { } #[cfg(unix)] - fn signal(&self, sig: i32) -> io::Result<()> { - ChildWrapper::signal(&*self.lock_child(), sig) + fn signal(&mut self, sig: i32) -> io::Result<()> { + ChildWrapper::signal(&mut *self.lock_child(), sig) } - #[cfg(any(feature = "process-group", feature = "process-session"))] + #[cfg(feature = "process-group")] fn spawned_id_layer(&self) -> Option { Some(self.pid) } + #[cfg(feature = "process-group")] + fn has_process_group_signal_layer(&self) -> bool { + true + } + + #[cfg(feature = "process-group")] + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + ChildWrapper::signal_process_group_layer(&mut *self.lock_child(), process_group, signal) + } + fn take_pty_controller_layer(&mut self) -> Option { self.controller.take() } diff --git a/src/tokio/pty/unix.rs b/src/tokio/pty/unix.rs index 39d1323..e3ba56b 100644 --- a/src/tokio/pty/unix.rs +++ b/src/tokio/pty/unix.rs @@ -162,6 +162,10 @@ pub(super) fn check_available() -> io::Result<()> { Ok(()) } +pub(super) fn validate_size(size: PtySize) -> io::Result<()> { + size.validate() +} + #[cfg(target_os = "netbsd")] fn check_netbsd_version() -> io::Result<()> { let mut name = std::mem::MaybeUninit::::uninit(); @@ -267,16 +271,16 @@ fn with_slave_stdio( #[derive(Debug)] struct PtyTransaction { - child: Arc>, - controller: Arc, + child: Option>>, + controller: Option>, armed: bool, } impl PtyTransaction { fn new(child: Arc>, controller: Arc) -> Self { Self { - child, - controller, + child: Some(child), + controller: Some(controller), armed: true, } } @@ -285,16 +289,25 @@ impl PtyTransaction { if !std::mem::replace(&mut self.armed, false) { return Ok(()); } - self.controller.rollback(); - terminate_and_reap(&self.child) + if let Some(controller) = self.controller.take() { + controller.rollback(); + } + self.child + .take() + .map_or(Ok(()), |child| terminate_and_reap(&child)) } } impl SpawnTransaction for PtyTransaction { fn commit(&mut self) -> io::Result<()> { if self.armed { - self.controller.commit(); + self.controller + .as_ref() + .expect("an armed PTY transaction owns its controller") + .commit(); self.armed = false; + self.child.take(); + self.controller.take(); } Ok(()) } @@ -335,10 +348,10 @@ fn terminate_and_reap(child: &Mutex) -> io::Result<()> { // Kill the direct child independently in case process-group signalling was unavailable. Holding the // only operational child lock keeps wait/kill state synchronized with every PTY child capability. - if let Err(error) = child.start_kill() { - if error.kind() != io::ErrorKind::InvalidInput { - return Err(error); - } + if let Err(error) = child.start_kill() + && error.kind() != io::ErrorKind::InvalidInput + { + return Err(error); } loop { @@ -628,14 +641,21 @@ fn closed() -> io::Error { #[cfg(test)] mod tests { - use std::os::fd::RawFd; + use std::{ + os::fd::RawFd, + time::{Duration, Instant}, + }; - use nix::unistd::pipe; + #[cfg(not(target_os = "macos"))] + use nix::unistd::pipe2; use super::*; - fn pipe_pair() -> (OwnedFd, OwnedFd) { - let (reader, writer) = pipe().unwrap(); + fn descriptor_pair() -> (OwnedFd, OwnedFd) { + #[cfg(not(target_os = "macos"))] + let (reader, writer) = pipe2(OFlag::O_CLOEXEC).unwrap(); + #[cfg(target_os = "macos")] + let (reader, writer) = open_pty(PtySize::default()).unwrap(); set_nonblocking(&reader).unwrap(); (reader, writer) } @@ -645,23 +665,73 @@ mod tests { assert_ne!(unsafe { libc::fcntl(fd, libc::F_GETFD) }, -1); } + fn wait_for_descriptor_readiness( + timeout: Duration, + mut now: impl FnMut() -> Instant, + mut poll: impl FnMut(i32) -> io::Result, + ) -> io::Result<()> { + let deadline = now().checked_add(timeout).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "descriptor wait deadline overflowed", + ) + })?; + loop { + let remaining = deadline + .checked_duration_since(now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "timed out waiting for writer closure", + ) + })?; + let timeout_millis = remaining + .as_nanos() + .div_ceil(1_000_000) + .min(i32::MAX as u128); + let timeout_millis = i32::try_from(timeout_millis).map_err(io::Error::other)?; + match poll(timeout_millis) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + } + fn assert_closed(reader: &OwnedFd) { - let mut byte = 0_u8; - // SAFETY: reader is a live nonblocking pipe descriptor and byte is writable for one byte. - let read = - unsafe { libc::read(reader.as_raw_fd(), std::ptr::from_mut(&mut byte).cast(), 1) }; - if read != 0 { - panic!( - "pipe reader did not observe writer closure: {}", - io::Error::last_os_error() - ); + let mut descriptor = libc::pollfd { + fd: reader.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + wait_for_descriptor_readiness(Duration::from_secs(5), Instant::now, |timeout| { + // SAFETY: `descriptor` is initialized for one live descriptor and remains valid for the call. + let ready = unsafe { libc::poll(&mut descriptor, 1, timeout) }; + if ready == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(ready > 0) + } + }) + .unwrap_or_else(|error| { + panic!("descriptor reader failed while waiting for writer closure: {error}") + }); + + let mut byte = [0_u8]; + match read(reader, &mut byte) { + Ok(0) => {} + Ok(read) => panic!("descriptor reader observed {read} bytes instead of closure"), + Err(error) if is_eof(&error) => {} + Err(error) => panic!("descriptor reader failed while confirming closure: {error}"), } } fn descriptors() -> (OwnedFd, OwnedFd, OwnedFd, [OwnedFd; 3], [RawFd; 3]) { - let (stdin_reader, stdin) = pipe_pair(); - let (stdout_reader, stdout) = pipe_pair(); - let (stderr_reader, stderr) = pipe_pair(); + let (stdin_reader, stdin) = descriptor_pair(); + let (stdout_reader, stdout) = descriptor_pair(); + let (stderr_reader, stderr) = descriptor_pair(); let raw = [stdin.as_raw_fd(), stdout.as_raw_fd(), stderr.as_raw_fd()]; ( stdin, @@ -672,6 +742,118 @@ mod tests { ) } + #[tokio::test] + async fn committed_transaction_releases_rollback_only_strong_owners() { + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "exit 0"]); + let child = Arc::new(Mutex::new( + command.spawn().expect("spawn transaction child"), + )); + + let (master, slave) = open_pty(PtySize::default()).expect("allocate transaction PTY"); + drop(slave); + let master = Arc::new(AsyncFd::new(master).expect("register transaction PTY master")); + let controller = Arc::new(ControllerSlot::new(PtyController::new( + Input { + master: Some(Arc::clone(&master)), + }, + Output { + master: Arc::clone(&master), + }, + Resize { + master: Arc::downgrade(&master), + }, + ))); + drop(master); + + let mut transaction = PtyTransaction::new(Arc::clone(&child), Arc::clone(&controller)); + assert_eq!(Arc::strong_count(&child), 2); + assert_eq!(Arc::strong_count(&controller), 2); + transaction.commit().expect("commit PTY transaction"); + assert_eq!(Arc::strong_count(&child), 1); + assert_eq!(Arc::strong_count(&controller), 1); + + let mut child = Arc::try_unwrap(child) + .expect("the committed transaction released its child owner") + .into_inner() + .unwrap(); + if child.try_wait().unwrap().is_none() { + child.start_kill().expect("terminate transaction child"); + child.wait().await.expect("reap transaction child"); + } + } + + #[test] + fn descriptor_fixture_descriptors_are_close_on_exec() { + let (reader, writer) = descriptor_pair(); + for descriptor in [&reader, &writer] { + let flags = FdFlag::from_bits_truncate( + fcntl(descriptor, FcntlArg::F_GETFD).expect("read fixture descriptor flags"), + ); + assert!(flags.contains(FdFlag::FD_CLOEXEC)); + } + } + + #[test] + fn interrupted_descriptor_wait_reuses_one_total_deadline() { + let start = Instant::now(); + let mut times = [ + start, + start + Duration::from_secs(1), + start + Duration::from_secs(3), + start + Duration::from_micros(4_999_500), + ] + .into_iter(); + let mut outcomes = [ + Err(io::Error::from(io::ErrorKind::Interrupted)), + Err(io::Error::from(io::ErrorKind::Interrupted)), + Ok(true), + ] + .into_iter(); + let mut timeouts = Vec::new(); + + wait_for_descriptor_readiness( + Duration::from_secs(5), + || times.next().expect("the clock has a value for every check"), + |timeout| { + timeouts.push(timeout); + outcomes.next().expect("the poll model has an outcome") + }, + ) + .expect("readiness arrives before the total deadline"); + + assert_eq!(timeouts, [4_000, 2_000, 1]); + } + + #[test] + fn interrupted_descriptor_wait_expires_before_another_poll() { + let start = Instant::now(); + let mut times = [ + start, + start + Duration::from_secs(4), + start + Duration::from_secs(5), + ] + .into_iter(); + let mut polls = 0; + + let error = wait_for_descriptor_readiness( + Duration::from_secs(5), + || times.next().expect("the clock has a value for every check"), + |_| { + polls += 1; + if polls == 1 { + Err(io::Error::from(io::ErrorKind::Interrupted)) + } else { + panic!("poll was called after the total deadline") + } + }, + ) + .expect_err("the interrupted wait reaches its total deadline"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert_eq!(polls, 1); + } + #[test] fn winsize_preserves_character_and_pixel_dimensions() { let native = winsize(PtySize { diff --git a/src/tokio/pty/unsupported.rs b/src/tokio/pty/unsupported.rs index eb53c01..19bf591 100644 --- a/src/tokio/pty/unsupported.rs +++ b/src/tokio/pty/unsupported.rs @@ -55,6 +55,10 @@ pub(super) fn check_available() -> io::Result<()> { Err(unsupported()) } +pub(super) fn validate_size(size: PtySize) -> io::Result<()> { + size.validate() +} + pub(super) fn spawn(_attempt: &mut SpawnAttempt, _size: PtySize) -> io::Result { Err(unsupported()) } diff --git a/src/tokio/pty/windows/api.rs b/src/tokio/pty/windows/api.rs new file mode 100644 index 0000000..066be22 --- /dev/null +++ b/src/tokio/pty/windows/api.rs @@ -0,0 +1,195 @@ +//! Runtime resolution of the ConPTY API surface. +//! +//! These functions must not appear in the crate's static import table: loading process-wrap on an +//! older Windows release must continue to work, with PTY spawning reporting `Unsupported` instead. + +use std::{ffi::CStr, io, sync::OnceLock}; + +use windows::{ + Win32::{ + Foundation::{FARPROC, HANDLE}, + System::{ + Console::{COORD, HPCON}, + LibraryLoader::{GetModuleHandleW, GetProcAddress}, + }, + }, + core::{HRESULT, PCSTR, w}, +}; + +pub(super) type CreatePseudoConsole = + unsafe extern "system" fn(COORD, HANDLE, HANDLE, u32, *mut HPCON) -> HRESULT; +pub(super) type ResizePseudoConsole = unsafe extern "system" fn(HPCON, COORD) -> HRESULT; +pub(super) type ReleasePseudoConsole = unsafe extern "system" fn(HPCON) -> HRESULT; +pub(super) type ClosePseudoConsole = unsafe extern "system" fn(HPCON); + +#[derive(Clone, Copy, Debug)] +pub(super) struct ConPtyApi { + pub(super) create: CreatePseudoConsole, + pub(super) resize: ResizePseudoConsole, + pub(super) release: ReleasePseudoConsole, + pub(super) close: ClosePseudoConsole, +} + +pub(super) fn get() -> io::Result<&'static ConPtyApi> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(resolve).as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "Windows pseudo-console APIs are unavailable", + ) + }) +} + +fn resolve() -> Option { + // SAFETY: kernel32.dll is loaded for every Windows process and GetModuleHandleW only borrows its + // module handle. + let kernel32 = unsafe { GetModuleHandleW(w!("kernel32.dll")) }.ok()?; + resolve_with(|name| { + // SAFETY: kernel32 remains loaded for the process lifetime, and name is a NUL-terminated C + // string which remains live for the call. Returned addresses have the module's lifetime. + unsafe { GetProcAddress(kernel32, PCSTR(name.as_ptr().cast())) } + }) +} + +fn resolve_with(mut lookup: impl FnMut(&CStr) -> FARPROC) -> Option { + let create = lookup(c"CreatePseudoConsole")?; + let resize = lookup(c"ResizePseudoConsole")?; + let release = lookup(c"ReleasePseudoConsole")?; + let close = lookup(c"ClosePseudoConsole")?; + + // SAFETY: each address was resolved under the matching exported function name. Win32 function + // pointers share one representation, and these signatures are the documented ConPTY ABI. + Some(unsafe { + ConPtyApi { + create: std::mem::transmute:: isize, CreatePseudoConsole>( + create, + ), + resize: std::mem::transmute:: isize, ResizePseudoConsole>( + resize, + ), + release: std::mem::transmute::< + unsafe extern "system" fn() -> isize, + ReleasePseudoConsole, + >(release), + close: std::mem::transmute:: isize, ClosePseudoConsole>( + close, + ), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + unsafe extern "system" fn create( + _size: COORD, + _input: HANDLE, + _output: HANDLE, + _flags: u32, + _pseudo_console: *mut HPCON, + ) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn resize(_pseudo_console: HPCON, _size: COORD) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn release(_pseudo_console: HPCON) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn close(_pseudo_console: HPCON) {} + + fn create_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>(create) + }) + } + + fn resize_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>(resize) + }) + } + + fn release_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>( + release, + ) + }) + } + + fn close_proc() -> FARPROC { + // SAFETY: tests erase this pointer only so resolve_with can restore its original signature. + Some(unsafe { + std::mem::transmute:: isize>(close) + }) + } + + fn lookup(name: &CStr) -> FARPROC { + match name.to_bytes() { + b"CreatePseudoConsole" => create_proc(), + b"ResizePseudoConsole" => resize_proc(), + b"ReleasePseudoConsole" => release_proc(), + b"ClosePseudoConsole" => close_proc(), + _ => None, + } + } + + #[test] + fn resolves_all_four_exports_by_exact_name() { + let mut requested = Vec::new(); + let api = resolve_with(|name| { + requested.push(name.to_bytes().to_vec()); + lookup(name) + }) + .unwrap(); + + let expected_create: CreatePseudoConsole = create; + let expected_resize: ResizePseudoConsole = resize; + let expected_release: ReleasePseudoConsole = release; + let expected_close: ClosePseudoConsole = close; + assert_eq!(api.create as *const (), expected_create as *const ()); + assert_eq!(api.resize as *const (), expected_resize as *const ()); + assert_eq!(api.release as *const (), expected_release as *const ()); + assert_eq!(api.close as *const (), expected_close as *const ()); + assert_eq!( + requested, + [ + b"CreatePseudoConsole".to_vec(), + b"ResizePseudoConsole".to_vec(), + b"ReleasePseudoConsole".to_vec(), + b"ClosePseudoConsole".to_vec(), + ] + ); + } + + #[test] + fn rejects_an_incomplete_capability_set() { + for missing in [ + b"CreatePseudoConsole".as_slice(), + b"ResizePseudoConsole".as_slice(), + b"ReleasePseudoConsole".as_slice(), + b"ClosePseudoConsole".as_slice(), + ] { + let api = resolve_with(|name| { + if name.to_bytes() == missing { + None + } else { + lookup(name) + } + }); + assert!( + api.is_none(), + "missing {}", + String::from_utf8_lossy(missing) + ); + } + } +} diff --git a/src/tokio/pty/windows/attributes.rs b/src/tokio/pty/windows/attributes.rs new file mode 100644 index 0000000..58835c0 --- /dev/null +++ b/src/tokio/pty/windows/attributes.rs @@ -0,0 +1,104 @@ +//! Aligned ownership for a pseudo-console process-thread attribute list. +//! +//! # Supported-Windows ABI assumption +//! +//! On every supported Windows target ABI, storage aligned to `align_of::()` is assumed to +//! satisfy the alignment requirement of `InitializeProcThreadAttributeList` and every later access +//! through `LPPROC_THREAD_ATTRIBUTE_LIST`. Microsoft documents the byte-size query but publishes no +//! independent alignment bound in the +//! [`InitializeProcThreadAttributeList` contract](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-initializeprocthreadattributelist). + +use std::{ffi::c_void, io, mem::size_of}; + +use windows::Win32::System::{ + Console::HPCON, + Threading::{ + DeleteProcThreadAttributeList, InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + UpdateProcThreadAttribute, + }, +}; + +#[derive(Debug)] +pub(super) struct AttributeList { + list: LPPROC_THREAD_ATTRIBUTE_LIST, + _storage: Box<[usize]>, +} + +impl AttributeList { + pub(super) fn new(pseudo_console: HPCON) -> io::Result { + let mut bytes = 0; + // SAFETY: a null first call is the documented size query and bytes is writable storage. + let _ = unsafe { InitializeProcThreadAttributeList(None, 1, None, &mut bytes) }; + if bytes == 0 { + return Err(io::Error::last_os_error()); + } + + let words = bytes.div_ceil(size_of::()); + let mut storage = vec![0usize; words].into_boxed_slice(); + let list = LPPROC_THREAD_ATTRIBUTE_LIST(storage.as_mut_ptr().cast()); + // SAFETY: storage starts at an address aligned to align_of::(), has at least the queried + // byte capacity, and remains owned by the returned guard until after + // DeleteProcThreadAttributeList runs. The supported-Windows ABI assumption above supplies the + // opaque Win32 list's alignment requirement. + unsafe { InitializeProcThreadAttributeList(Some(list), 1, None, &mut bytes) } + .map_err(io::Error::other)?; + + // Microsoft documents passing the HPCON value itself as lpValue, rather than a pointer to a + // separate HPCON variable. + // SAFETY: list is initialized for one attribute and pseudo_console is a live handle whose owner + // outlives this list's use by CreateProcessW. + if let Err(error) = unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE as usize, + Some(pseudo_console.0 as *const c_void), + size_of::(), + None, + None, + ) + } { + // SAFETY: the list was initialized successfully and has not yet been deleted. + unsafe { DeleteProcThreadAttributeList(list) }; + return Err(io::Error::other(error)); + } + + Ok(Self { + list, + _storage: storage, + }) + } + + pub(super) fn as_ptr(&self) -> LPPROC_THREAD_ATTRIBUTE_LIST { + self.list + } +} + +impl Drop for AttributeList { + fn drop(&mut self) { + // SAFETY: this guard owns one initialized list and drops it before its aligned storage. + unsafe { DeleteProcThreadAttributeList(self.list) }; + } +} + +#[cfg(test)] +mod tests { + use std::mem::align_of; + + use super::*; + + #[test] + fn allocates_an_aligned_single_pseudoconsole_attribute() { + let attributes = AttributeList::new(HPCON(42)).unwrap(); + assert!(!attributes.as_ptr().is_invalid()); + assert_eq!( + attributes._storage.as_ptr() as usize % align_of::(), + 0 + ); + assert_eq!( + attributes.as_ptr().0, + attributes._storage.as_ptr().cast_mut().cast() + ); + } +} diff --git a/src/tokio/pty/windows/backend.rs b/src/tokio/pty/windows/backend.rs new file mode 100644 index 0000000..84752b8 --- /dev/null +++ b/src/tokio/pty/windows/backend.rs @@ -0,0 +1,188 @@ +//! End-to-end assembly of the Windows ConPTY spawn provider. + +use std::{io, sync::Arc}; + +use crate::{ + SpawnTransaction, + tokio::{ProviderProduct, SpawnAttempt}, +}; + +use super::{ + super::{ControllerSlot, PtyController, PtySize}, + api, + attributes::AttributeList, + console::{self, PseudoConsole, Release}, + controller, + pipe::PipePair, + prepare, + spawn::{self as process, SpawnCleanup}, +}; + +pub(in crate::tokio::pty) fn check_available() -> io::Result<()> { + api::get().map(|_| ()) +} + +pub(in crate::tokio::pty) fn validate_size(size: PtySize) -> io::Result<()> { + console::coordinate(size).map(|_| ()) +} + +pub(in crate::tokio::pty) fn spawn( + attempt: &mut SpawnAttempt, + size: PtySize, +) -> io::Result { + console::coordinate(size)?; + let prepared = prepare(attempt)?; + + let input = PipePair::input()?; + let output = PipePair::output()?; + let console = PseudoConsole::create(size, input.server_handle(), output.server_handle())?; + let release = console.releaser(); + let attributes = AttributeList::new(console.handle())?; + let (input_server, input_host) = input.into_parts(); + let (output_server, output_host) = output.into_parts(); + let (input, output, resize) = controller::controller(console, input_host, output_host)?; + let controller = Arc::new(ControllerSlot::new(PtyController::new( + input, output, resize, + ))); + + let process::SpawnedChild { mut child, cleanup } = + process::spawn(prepared, &attributes, input_server, output_server)?; + child.install_controller(Arc::clone(&controller)); + + Ok(ProviderProduct::new( + Box::new(child), + Box::new(ConPtyTransaction { + cleanup: Some(cleanup), + controller: Some(controller), + release: Some(release), + }), + )) +} + +#[derive(Debug)] +struct ConPtyTransaction { + cleanup: Option, + controller: Option>, + release: Option, +} + +impl SpawnTransaction for ConPtyTransaction { + fn commit(&mut self) -> io::Result<()> { + self.release + .as_ref() + .expect("an armed ConPTY transaction owns its release capability") + .release()?; + self.cleanup + .as_mut() + .expect("an armed ConPTY transaction owns its cleanup duplicate") + .disarm(); + self.cleanup.take(); + self.controller.take(); + self.release.take(); + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + if let Some(controller) = self.controller.take() { + controller.rollback(); + } + self.release.take(); + self.cleanup + .take() + .map_or(Ok(()), |mut cleanup| cleanup.rollback()) + } +} + +#[cfg(test)] +mod tests { + use std::{ + os::windows::io::{FromRawHandle, OwnedHandle}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, + }; + + use windows::{ + Win32::{ + Foundation::HANDLE, + System::{ + Console::{COORD, HPCON}, + Threading::CreateEventW, + }, + }, + core::HRESULT, + }; + + use super::*; + + static RELEASES: AtomicUsize = AtomicUsize::new(0); + + unsafe extern "system" fn create( + _size: COORD, + _input: HANDLE, + _output: HANDLE, + _flags: u32, + _pseudo_console: *mut HPCON, + ) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn resize(_pseudo_console: HPCON, _size: COORD) -> HRESULT { + HRESULT(0) + } + + unsafe extern "system" fn release(_pseudo_console: HPCON) -> HRESULT { + RELEASES.fetch_add(1, Ordering::SeqCst); + HRESULT(0) + } + + unsafe extern "system" fn close(_pseudo_console: HPCON) {} + + static API: api::ConPtyApi = api::ConPtyApi { + create, + resize, + release, + close, + }; + + #[test] + fn committed_transaction_releases_rollback_only_owners() -> io::Result<()> { + RELEASES.store(0, Ordering::SeqCst); + // SAFETY: default security, manual reset, initially unsignaled, and no name request a new event + // handle which is transferred immediately to `OwnedHandle`. + let event = unsafe { CreateEventW(None, true, false, None) }?; + let expected_handle = event.0 as usize; + // SAFETY: the successful CreateEventW result is uniquely owned here. + let process = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let (released, observe_release) = mpsc::channel(); + let cleanup = SpawnCleanup::tracked(process, released); + let controller = Arc::new(ControllerSlot::empty_for_test()); + let controller_observer = Arc::clone(&controller); + let (release, release_observer) = Release::tracked(&API, HPCON(42)); + let mut transaction = ConPtyTransaction { + cleanup: Some(cleanup), + controller: Some(controller), + release: Some(release), + }; + + assert_eq!(Arc::strong_count(&controller_observer), 2); + assert_eq!(release_observer.strong_count(), 1); + transaction.commit()?; + + assert_eq!(RELEASES.load(Ordering::SeqCst), 1); + assert_eq!( + observe_release + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + expected_handle + ); + assert_eq!(Arc::strong_count(&controller_observer), 1); + assert_eq!(release_observer.strong_count(), 0); + drop(transaction); + assert_eq!(RELEASES.load(Ordering::SeqCst), 1); + Ok(()) + } +} diff --git a/src/tokio/pty/windows/child.rs b/src/tokio/pty/windows/child.rs new file mode 100644 index 0000000..0189f05 --- /dev/null +++ b/src/tokio/pty/windows/child.rs @@ -0,0 +1,649 @@ +//! A terminal [`ChildWrapper`] backed directly by Win32 process handles. + +use std::{ + future::Future, + io, + os::windows::{ + io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}, + process::ExitStatusExt, + }, + pin::Pin, + process::ExitStatus, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll, Waker}, +}; + +use tokio::process::{ChildStderr, ChildStdin, ChildStdout}; +#[cfg(feature = "job-object")] +use windows::Win32::System::Threading::ResumeThread; +use windows::Win32::{ + Foundation::{ + DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + System::Threading::{ + GetCurrentProcess, GetExitCodeProcess, TerminateProcess, WaitForSingleObject, + }, +}; + +use crate::{ChildExitStatus, tokio::ChildWrapper}; + +use super::super::{ControllerSlot, PtyController}; + +#[derive(Debug)] +pub(super) struct ConPtyChild { + process: OwnedHandle, + primary_thread: Option, + pid: u32, + kill_on_drop: bool, + cleanup_armed: bool, + controller: Option>, + exit_status: ChildExitStatus, + wait_task: Option, + wait_cancel: Arc, + stdin: Option, + stdout: Option, + stderr: Option, +} + +#[derive(Debug)] +struct WaitTask { + state: Arc, + _thread: std::thread::JoinHandle<()>, +} + +#[derive(Debug, Default)] +struct WaitState { + result: Mutex, +} + +#[derive(Debug, Default)] +struct WaitResult { + result: Option>, + waker: Option, +} + +impl WaitTask { + fn spawn(process: OwnedHandle, cancel: Arc) -> io::Result { + let state = Arc::new(WaitState::default()); + let thread_state = Arc::clone(&state); + let thread = std::thread::Builder::new() + .name("process-wrap-conpty-wait".into()) + .spawn(move || { + let result = loop { + if cancel.load(Ordering::Acquire) { + break Err(io::Error::new( + io::ErrorKind::Interrupted, + "ConPTY process wait was canceled because the child was dropped", + )); + } + match process_status(HANDLE(process.as_raw_handle()), 50) { + Ok(Some(status)) => break Ok(status), + Ok(None) => {} + Err(error) => break Err(error), + } + }; + thread_state.complete(result); + })?; + Ok(Self { + state, + _thread: thread, + }) + } + + fn clear_waker(&self) { + self.state + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .waker + .take(); + } + + #[cfg(test)] + fn id(&self) -> std::thread::ThreadId { + self._thread.thread().id() + } +} + +impl Future for WaitTask { + type Output = io::Result; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut state = self + .state + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(result) = state.result.take() { + return Poll::Ready(result); + } + if state + .waker + .as_ref() + .is_none_or(|waker| !waker.will_wake(cx.waker())) + { + state.waker = Some(cx.waker().clone()); + } + Poll::Pending + } +} + +impl Drop for WaitTask { + fn drop(&mut self) { + self.clear_waker(); + } +} + +impl WaitState { + fn complete(&self, result: io::Result) { + let waker = { + let mut state = self + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.result = Some(result); + state.waker.take() + }; + if let Some(waker) = waker { + waker.wake(); + } + } +} + +struct RegisteredWait<'a> { + task: &'a mut WaitTask, +} + +impl Future for RegisteredWait<'_> { + type Output = io::Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + Pin::new(&mut *self.task).poll(cx) + } +} + +impl Drop for RegisteredWait<'_> { + fn drop(&mut self) { + self.task.clear_waker(); + } +} + +impl ConPtyChild { + pub(super) fn new( + process: OwnedHandle, + primary_thread: OwnedHandle, + pid: u32, + kill_on_drop: bool, + ) -> Self { + Self { + process, + primary_thread: Some(primary_thread), + pid, + kill_on_drop, + cleanup_armed: true, + controller: None, + exit_status: ChildExitStatus::Running, + wait_task: None, + wait_cancel: Arc::new(AtomicBool::new(false)), + stdin: None, + stdout: None, + stderr: None, + } + } + + #[cfg(test)] + pub(super) fn primary_thread_handle(&self) -> Option> { + self.primary_thread.as_ref().map(OwnedHandle::as_handle) + } + + #[cfg(feature = "job-object")] + pub(super) fn resume_primary_thread(&mut self) -> io::Result<()> { + let thread = self + .primary_thread + .take() + .ok_or_else(|| io::Error::other("ConPTY child has no primary thread handle"))?; + // SAFETY: thread is the live primary-thread handle returned by CreateProcessW. + let previous_count = unsafe { ResumeThread(HANDLE(thread.as_raw_handle())) }; + match previous_count { + u32::MAX => Err(io::Error::last_os_error()), + 1 => Ok(()), + 0 => Err(io::Error::other( + "ConPTY primary thread was not suspended for job assignment", + )), + count => Err(io::Error::other(format!( + "ConPTY primary thread remains suspended with count {}", + count - 1 + ))), + } + } + + pub(super) fn install_controller(&mut self, controller: Arc) { + debug_assert!(self.controller.is_none()); + self.controller = Some(controller); + } + + fn raw_process_handle(&self) -> HANDLE { + HANDLE(self.process.as_raw_handle()) + } +} + +impl ChildWrapper for ConPtyChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn process_handle(&self) -> Option> { + Some(self.process.as_handle()) + } + + #[cfg(feature = "job-object")] + fn resume_after_job_assignment(&mut self) -> Option> { + Some(self.resume_primary_thread()) + } + + fn disarm_job_object_layer(&mut self) -> io::Result<()> { + self.primary_thread.take(); + self.cleanup_armed = false; + if let Some(controller) = &self.controller { + controller.commit(); + } + Ok(()) + } + + fn take_pty_controller_layer(&mut self) -> Option { + self.controller + .as_ref() + .and_then(|controller| controller.take()) + } + + fn stdin(&mut self) -> &mut Option { + &mut self.stdin + } + + fn stdout(&mut self) -> &mut Option { + &mut self.stdout + } + + fn stderr(&mut self) -> &mut Option { + &mut self.stderr + } + + fn id(&self) -> Option { + Some(self.pid) + } + + fn start_kill(&mut self) -> io::Result<()> { + if self.try_wait()?.is_some() { + return Ok(()); + } + // SAFETY: self owns a live process handle with process-termination access. + unsafe { TerminateProcess(self.raw_process_handle(), 1) }.map_err(io::Error::other) + } + + fn try_wait(&mut self) -> io::Result> { + if let ChildExitStatus::Exited(status) = self.exit_status { + return Ok(Some(status)); + } + let status = process_status(self.raw_process_handle(), 0)?; + if let Some(status) = status { + self.exit_status = ChildExitStatus::Exited(status); + self.wait_task.take(); + } + Ok(status) + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { + if let ChildExitStatus::Exited(status) = self.exit_status { + return Ok(status); + } + + if self.wait_task.is_none() { + let process = duplicate_handle(&self.process)?; + let cancel = Arc::clone(&self.wait_cancel); + self.wait_task = Some(WaitTask::spawn(process, cancel)?); + } + let result = RegisteredWait { + task: self + .wait_task + .as_mut() + .expect("an in-progress ConPTY wait must retain its task"), + } + .await; + self.wait_task.take(); + let status = result?; + self.exit_status = ChildExitStatus::Exited(status); + Ok(status) + }) + } +} + +impl Drop for ConPtyChild { + fn drop(&mut self) { + self.wait_cancel.store(true, Ordering::Release); + if (self.cleanup_armed || self.kill_on_drop) + && matches!(self.exit_status, ChildExitStatus::Running) + && !matches!(process_status(self.raw_process_handle(), 0), Ok(Some(_))) + { + // SAFETY: self still owns the process handle for the duration of this call. + let _ = unsafe { TerminateProcess(self.raw_process_handle(), 1) }; + } + } +} + +fn duplicate_handle(handle: &OwnedHandle) -> io::Result { + // SAFETY: GetCurrentProcess returns a permanent pseudo-handle. The source handle is live, and the + // output pointer is writable. The resulting non-inheritable duplicate is transferred once. + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + process, + HANDLE(handle.as_raw_handle()), + process, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .map_err(io::Error::other)?; + Ok(OwnedHandle::from_raw_handle(duplicate.0)) + } +} + +fn process_status(process: HANDLE, timeout: u32) -> io::Result> { + // SAFETY: process is a live process handle and timeout has the documented wait semantics. + match unsafe { WaitForSingleObject(process, timeout) } { + WAIT_TIMEOUT => Ok(None), + WAIT_OBJECT_0 => { + let mut code = 0; + // SAFETY: the process is signaled and code points to initialized writable storage. + unsafe { GetExitCodeProcess(process, &mut code) }.map_err(io::Error::other)?; + Ok(Some(ExitStatus::from_raw(code))) + } + WAIT_FAILED => Err(io::Error::last_os_error()), + other => Err(io::Error::other(format!( + "unexpected process wait result {}", + other.0 + ))), + } +} + +#[cfg(test)] +mod tests { + use std::{ + os::windows::io::{AsRawHandle, FromRawHandle}, + process::{Child, Command}, + thread::sleep, + time::{Duration, Instant}, + }; + + use super::*; + + fn duplicate_process(child: &Child) -> OwnedHandle { + // SAFETY: the current-process pseudo-handle and child's process handle are live, and duplicate + // points to initialized writable storage. Ownership of the result is transferred once. + unsafe { + let current = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + current, + HANDLE(child.as_raw_handle()), + current, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .unwrap(); + OwnedHandle::from_raw_handle(duplicate.0) + } + } + + fn wrap(child: &Child, kill_on_drop: bool) -> ConPtyChild { + ConPtyChild { + process: duplicate_process(child), + primary_thread: None, + pid: child.id(), + kill_on_drop, + cleanup_armed: false, + controller: None, + exit_status: ChildExitStatus::Running, + wait_task: None, + wait_cancel: Arc::new(AtomicBool::new(false)), + stdin: None, + stdout: None, + stderr: None, + } + } + + fn spawn_exit_259() -> Child { + Command::new("cmd.exe") + .args(["/D", "/C", "exit /b 259"]) + .spawn() + .unwrap() + } + + fn spawn_long_running() -> Child { + Command::new("ping.exe") + .args(["-n", "30", "127.0.0.1"]) + .spawn() + .unwrap() + } + + fn wait_native(child: &mut Child) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "native test child did not exit"); + sleep(Duration::from_millis(10)); + } + } + + #[tokio::test] + async fn preserves_exit_code_259_and_repeated_waits() { + let mut native = spawn_exit_259(); + let mut child = wrap(&native, false); + assert_eq!(child.id(), Some(native.id())); + assert!(child.process_handle().is_some()); + assert!(child.primary_thread_handle().is_none()); + assert!((&child as &dyn ChildWrapper).try_inner_child().is_none()); + + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.code(), Some(259)); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn start_kill_succeeds_after_natural_exit() { + let mut native = spawn_exit_259(); + let mut child = wrap(&native, false); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.code(), Some(259)); + child.start_kill().unwrap(); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn kill_succeeds_after_natural_exit() { + let mut native = spawn_exit_259(); + let mut child = wrap(&native, false); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(status.code(), Some(259)); + Pin::from(ChildWrapper::kill(&mut child)).await.unwrap(); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn kills_and_reaps_the_direct_process() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + assert!(child.try_wait().unwrap().is_none()); + child.start_kill().unwrap(); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(child.try_wait().unwrap(), Some(status)); + assert_eq!(wait_native(&mut native), status); + } + + #[tokio::test] + async fn canceled_async_waits_share_one_task_and_preserve_the_process_handle() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + assert!( + tokio::time::timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + let wait_id = child + .wait_task + .as_ref() + .expect("a canceled wait must retain its task") + .id(); + for _ in 0..7 { + assert!( + tokio::time::timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + assert_eq!( + child + .wait_task + .as_ref() + .expect("a canceled wait must retain its task") + .id(), + wait_id + ); + } + child.start_kill().unwrap(); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .unwrap() + .unwrap(); + assert_eq!(wait_native(&mut native), status); + } + + #[test] + fn canceled_wait_does_not_block_runtime_shutdown_while_child_lives() { + let mut native = spawn_long_running(); + let mut child = Some(wrap(&native, false)); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + runtime.block_on(async { + assert!( + tokio::time::timeout(Duration::from_millis(20), child.as_mut().unwrap().wait(),) + .await + .is_err() + ); + }); + + let (shutdown_finished, observe_shutdown) = std::sync::mpsc::channel(); + let shutdown = std::thread::spawn(move || { + drop(runtime); + let _ = shutdown_finished.send(()); + }); + let runtime_stopped = observe_shutdown + .recv_timeout(Duration::from_secs(2)) + .is_ok(); + if !runtime_stopped { + drop(child.take()); + } + shutdown.join().unwrap(); + assert!( + runtime_stopped, + "dropping the runtime waited for the canceled child wait" + ); + + assert!(native.try_wait().unwrap().is_none()); + drop(child.take()); + native.kill().unwrap(); + let _ = wait_native(&mut native); + } + + #[tokio::test] + async fn dropping_after_a_canceled_wait_releases_the_waiter_thread() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + assert!( + tokio::time::timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + let task = child + .wait_task + .take() + .expect("a canceled wait retains its blocking task"); + drop(child); + let error = tokio::time::timeout(Duration::from_secs(5), task) + .await + .unwrap() + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Interrupted); + assert!(native.try_wait().unwrap().is_none()); + native.kill().unwrap(); + let _ = wait_native(&mut native); + } + + #[test] + fn armed_spawn_cleanup_terminates_a_running_process() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + child.cleanup_armed = true; + drop(child); + let _ = wait_native(&mut native); + } + + #[test] + fn final_spawn_disarm_preserves_a_running_process() { + let mut native = spawn_long_running(); + let mut child = wrap(&native, false); + child.primary_thread = Some(duplicate_process(&native)); + child.cleanup_armed = true; + child.disarm_job_object_layer().unwrap(); + assert!(child.primary_thread_handle().is_none()); + drop(child); + assert!(native.try_wait().unwrap().is_none()); + native.kill().unwrap(); + let _ = wait_native(&mut native); + } + + #[test] + fn kill_on_drop_terminates_a_running_process() { + let mut native = spawn_long_running(); + let child = wrap(&native, true); + drop(child); + let _ = wait_native(&mut native); + } +} diff --git a/src/tokio/pty/windows/command.rs b/src/tokio/pty/windows/command.rs index da33365..a620713 100644 --- a/src/tokio/pty/windows/command.rs +++ b/src/tokio/pty/windows/command.rs @@ -34,6 +34,7 @@ impl WideCString { self.0.as_mut_ptr() } + #[cfg(test)] pub(super) fn as_units(&self) -> &[u16] { &self.0 } diff --git a/src/tokio/pty/windows/console.rs b/src/tokio/pty/windows/console.rs new file mode 100644 index 0000000..c4ebad8 --- /dev/null +++ b/src/tokio/pty/windows/console.rs @@ -0,0 +1,619 @@ +//! Dynamically owned pseudo-console handles, resizing, and off-thread close cleanup. + +use std::{ + io, + sync::{Arc, Mutex}, +}; + +use windows::Win32::{ + Foundation::HANDLE, + System::Console::{COORD, HPCON}, +}; + +use super::{ + super::PtySize, + api::{self, ConPtyApi}, +}; + +#[derive(Debug)] +pub(super) struct PseudoConsole { + state: Arc, +} + +#[derive(Clone, Debug)] +pub(super) struct Release { + state: Arc, +} + +#[cfg(test)] +#[derive(Debug)] +pub(super) struct ReleaseTracker(std::sync::Weak); + +#[cfg(test)] +impl ReleaseTracker { + pub(super) fn strong_count(&self) -> usize { + self.0.strong_count() + } +} + +#[derive(Debug)] +struct State { + handle: HPCON, + api: &'static ConPtyApi, + lifecycle: Mutex, +} + +#[derive(Debug, Default)] +struct Lifecycle { + released: bool, + closed: bool, +} + +impl PseudoConsole { + pub(super) fn create(size: PtySize, input: HANDLE, output: HANDLE) -> io::Result { + Self::create_with(api::get()?, size, input, output) + } + + fn create_with( + api: &'static ConPtyApi, + size: PtySize, + input: HANDLE, + output: HANDLE, + ) -> io::Result { + let mut handle = HPCON::default(); + // SAFETY: input and output are live synchronous pipe handles, handle points to initialized + // writable storage, and the resolved function has the documented ConPTY ABI. + unsafe { (api.create)(coordinate(size)?, input, output, 0, &mut handle) } + .ok() + .map_err(io::Error::other)?; + if handle.is_invalid() { + return Err(io::Error::other( + "CreatePseudoConsole returned an invalid handle", + )); + } + Ok(Self { + state: Arc::new(State { + handle, + api, + lifecycle: Mutex::new(Lifecycle::default()), + }), + }) + } + + pub(super) fn handle(&self) -> HPCON { + self.state.handle + } + + pub(super) fn releaser(&self) -> Release { + Release { + state: Arc::clone(&self.state), + } + } + + #[cfg(test)] + fn release(&self) -> io::Result<()> { + self.releaser().release() + } + + pub(super) fn resize(&self, size: PtySize) -> io::Result<()> { + let lifecycle = self + .state + .lifecycle + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if lifecycle.closed { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "the pseudo-console is closed", + )); + } + // SAFETY: this is a live pseudo-console and the resolved function has the documented ABI. + unsafe { (self.state.api.resize)(self.state.handle, coordinate(size)?) } + .ok() + .map_err(io::Error::other) + } +} + +impl Release { + #[cfg(test)] + pub(super) fn tracked(api: &'static ConPtyApi, handle: HPCON) -> (Self, ReleaseTracker) { + let state = Arc::new(State { + handle, + api, + lifecycle: Mutex::new(Lifecycle::default()), + }); + let tracker = ReleaseTracker(Arc::downgrade(&state)); + (Self { state }, tracker) + } + + pub(super) fn release(&self) -> io::Result<()> { + let mut lifecycle = self + .state + .lifecycle + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if lifecycle.released || lifecycle.closed { + return Ok(()); + } + // SAFETY: the application owns this live pseudo-console and the resolved function has the + // documented ABI. + unsafe { (self.state.api.release)(self.state.handle) } + .ok() + .map_err(io::Error::other)?; + lifecycle.released = true; + Ok(()) + } +} + +impl Drop for PseudoConsole { + fn drop(&mut self) { + let mut lifecycle = self + .state + .lifecycle + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if lifecycle.closed { + return; + } + lifecycle.closed = true; + drop(lifecycle); + schedule_close(self.state.api.close, self.state.handle); + } +} + +pub(super) fn coordinate(size: PtySize) -> io::Result { + size.validate()?; + let columns = i16::try_from(size.columns).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "PTY columns must not exceed 32767 on Windows", + ) + })?; + let rows = i16::try_from(size.rows).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "PTY rows must not exceed 32767 on Windows", + ) + })?; + Ok(COORD { + X: columns, + Y: rows, + }) +} + +fn schedule_close(close: api::ClosePseudoConsole, handle: HPCON) { + schedule_close_with(close, handle, launch_close_worker); +} + +fn launch_close_worker(close: api::ClosePseudoConsole, handle: HPCON) -> io::Result<()> { + let close_thread = std::thread::Builder::new() + .name("process-wrap-conpty-close".into()) + .spawn(move || { + // SAFETY: ownership of this live raw HPCON was transferred to exactly this close thread. + unsafe { close(handle) }; + })?; + // Dropping a JoinHandle detaches its thread. The worker exclusively owns the raw HPCON + // transferred into its closure and will close it without blocking this caller. + drop(close_thread); + Ok(()) +} + +fn schedule_close_with( + close: api::ClosePseudoConsole, + handle: HPCON, + launch: impl FnOnce(api::ClosePseudoConsole, HPCON) -> io::Result<()>, +) { + if launch(close, handle).is_err() { + // Calling ClosePseudoConsole here could indefinitely block a reactor or arbitrary dropping + // thread on older Windows versions. Leaking without diagnostics is the only fallback that + // neither blocks nor invokes application-controlled tracing on the dropping thread. + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}, + time::{Duration, Instant}, + }; + + #[cfg(feature = "tracing")] + use std::{ + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Condvar, mpsc}, + thread::{self, ThreadId}, + }; + #[cfg(feature = "tracing")] + use tracing::{ + Event, Metadata, Subscriber, + span::{Attributes, Id, Record}, + }; + + use windows::core::HRESULT; + + use super::*; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + static CREATE_SIZE: AtomicU32 = AtomicU32::new(0); + static CREATE_FLAGS: AtomicU32 = AtomicU32::new(u32::MAX); + static RESIZE_SIZE: AtomicU32 = AtomicU32::new(0); + static RELEASE_COUNT: AtomicUsize = AtomicUsize::new(0); + static BLOCK_CLOSE: AtomicBool = AtomicBool::new(false); + static CLOSE_STARTED: AtomicBool = AtomicBool::new(false); + static CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0); + #[cfg(feature = "tracing")] + static CLOSE_LAUNCH_COUNT: AtomicUsize = AtomicUsize::new(0); + + #[cfg(feature = "tracing")] + #[derive(Clone, Copy, Debug)] + enum DiagnosticBehavior { + Record, + Panic, + Reenter, + Block, + } + + #[cfg(feature = "tracing")] + #[derive(Debug, Default)] + struct DiagnosticGate { + events: usize, + thread: Option, + released: bool, + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct DiagnosticState { + behavior: DiagnosticBehavior, + gate: Mutex, + changed: Condvar, + reentered: AtomicBool, + } + + #[cfg(feature = "tracing")] + impl DiagnosticState { + fn new(behavior: DiagnosticBehavior) -> Arc { + Arc::new(Self { + behavior, + gate: Mutex::new(DiagnosticGate::default()), + changed: Condvar::new(), + reentered: AtomicBool::new(false), + }) + } + + fn event_count(&self) -> usize { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .events + } + + fn event_thread(&self) -> Option { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .thread + } + + fn release(&self) { + let mut gate = self + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.released = true; + self.changed.notify_all(); + } + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct AdversarialSubscriber { + state: Arc, + } + + #[cfg(feature = "tracing")] + impl Subscriber for AdversarialSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) { + let mut gate = self + .state + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.events += 1; + gate.thread = Some(thread::current().id()); + self.state.changed.notify_all(); + match self.state.behavior { + DiagnosticBehavior::Record => {} + DiagnosticBehavior::Panic => { + drop(gate); + panic!("injected close diagnostic panic"); + } + DiagnosticBehavior::Reenter => { + drop(gate); + self.state.reentered.store(true, Ordering::SeqCst); + tracing::warn!("re-entered tracing from the close diagnostic subscriber"); + } + DiagnosticBehavior::Block => { + while !gate.released { + gate = self.state.changed.wait(gate).unwrap(); + } + } + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} + } + + #[cfg(feature = "tracing")] + fn diagnostic_dispatch(state: Arc) -> tracing::Dispatch { + tracing::Dispatch::new(AdversarialSubscriber { state }) + } + + #[cfg(feature = "tracing")] + fn fail_close_worker_start() { + schedule_close_with(close, HPCON(42), |_, _| { + CLOSE_LAUNCH_COUNT.fetch_add(1, Ordering::SeqCst); + Err(io::Error::other("injected close-worker startup failure")) + }); + } + + #[cfg(feature = "tracing")] + fn assert_failed_launch_without_close() { + assert_eq!(CLOSE_LAUNCH_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 0); + } + + unsafe extern "system" fn create( + size: COORD, + _input: HANDLE, + _output: HANDLE, + flags: u32, + pseudo_console: *mut HPCON, + ) -> HRESULT { + CREATE_SIZE.store(pack(size), Ordering::SeqCst); + CREATE_FLAGS.store(flags, Ordering::SeqCst); + // SAFETY: the test caller passes writable storage for the output handle. + unsafe { *pseudo_console = HPCON(42) }; + HRESULT(0) + } + + unsafe extern "system" fn resize(_pseudo_console: HPCON, size: COORD) -> HRESULT { + RESIZE_SIZE.store(pack(size), Ordering::SeqCst); + HRESULT(0) + } + + unsafe extern "system" fn release(_pseudo_console: HPCON) -> HRESULT { + RELEASE_COUNT.fetch_add(1, Ordering::SeqCst); + HRESULT(0) + } + + unsafe extern "system" fn close(_pseudo_console: HPCON) { + CLOSE_STARTED.store(true, Ordering::SeqCst); + for _ in 0..1_000 { + if !BLOCK_CLOSE.load(Ordering::SeqCst) { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + CLOSE_COUNT.fetch_add(1, Ordering::SeqCst); + } + + static TEST_API: ConPtyApi = ConPtyApi { + create, + resize, + release, + close, + }; + + fn pack(size: COORD) -> u32 { + u32::from(size.X as u16) | (u32::from(size.Y as u16) << 16) + } + + fn wait_for(predicate: impl Fn() -> bool) { + for _ in 0..1_000 { + if predicate() { + return; + } + std::thread::sleep(Duration::from_millis(1)); + } + panic!("timed out waiting for mock ConPTY operation"); + } + + fn reset_close() { + RELEASE_COUNT.store(0, Ordering::SeqCst); + BLOCK_CLOSE.store(false, Ordering::SeqCst); + CLOSE_STARTED.store(false, Ordering::SeqCst); + CLOSE_COUNT.store(0, Ordering::SeqCst); + #[cfg(feature = "tracing")] + CLOSE_LAUNCH_COUNT.store(0, Ordering::SeqCst); + } + + #[test] + fn converts_character_dimensions_and_rejects_windows_overflow() { + assert_eq!( + coordinate(PtySize::new(24, 80).unwrap()).unwrap(), + COORD { X: 80, Y: 24 } + ); + let columns = coordinate(PtySize { + rows: 1, + columns: 32_768, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap_err(); + assert_eq!(columns.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + columns.to_string(), + "PTY columns must not exceed 32767 on Windows" + ); + let rows = coordinate(PtySize { + rows: 32_768, + columns: 1, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap_err(); + assert_eq!(rows.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + rows.to_string(), + "PTY rows must not exceed 32767 on Windows" + ); + } + + #[test] + fn creates_and_resizes_through_the_resolved_capabilities() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + CREATE_SIZE.store(0, Ordering::SeqCst); + CREATE_FLAGS.store(u32::MAX, Ordering::SeqCst); + RESIZE_SIZE.store(0, Ordering::SeqCst); + + let console = PseudoConsole::create_with( + &TEST_API, + PtySize::new(25, 81).unwrap(), + HANDLE(1usize as _), + HANDLE(2usize as _), + ) + .unwrap(); + assert_eq!(console.handle(), HPCON(42)); + assert_eq!( + CREATE_SIZE.load(Ordering::SeqCst), + pack(COORD { X: 81, Y: 25 }) + ); + assert_eq!(CREATE_FLAGS.load(Ordering::SeqCst), 0); + console.resize(PtySize::new(40, 120).unwrap()).unwrap(); + assert_eq!( + RESIZE_SIZE.load(Ordering::SeqCst), + pack(COORD { X: 120, Y: 40 }) + ); + console.release().unwrap(); + console.release().unwrap(); + assert_eq!(RELEASE_COUNT.load(Ordering::SeqCst), 1); + console.resize(PtySize::new(41, 121).unwrap()).unwrap(); + assert_eq!( + RESIZE_SIZE.load(Ordering::SeqCst), + pack(COORD { X: 121, Y: 41 }) + ); + + drop(console); + wait_for(|| CLOSE_COUNT.load(Ordering::SeqCst) == 1); + } + + #[test] + fn drop_schedules_exactly_one_close_away_from_the_caller() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + BLOCK_CLOSE.store(true, Ordering::SeqCst); + let console = PseudoConsole { + state: Arc::new(State { + handle: HPCON(42), + api: &TEST_API, + lifecycle: Mutex::new(Lifecycle::default()), + }), + }; + + let started = Instant::now(); + drop(console); + assert!(started.elapsed() < Duration::from_millis(250)); + wait_for(|| CLOSE_STARTED.load(Ordering::SeqCst)); + assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 0); + + BLOCK_CLOSE.store(false, Ordering::SeqCst); + wait_for(|| CLOSE_COUNT.load(Ordering::SeqCst) == 1); + std::thread::sleep(Duration::from_millis(10)); + assert_eq!(CLOSE_COUNT.load(Ordering::SeqCst), 1); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_invoke_a_subscriber_on_the_caller() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let caller = thread::current().id(); + let state = DiagnosticState::new(DiagnosticBehavior::Record); + let dispatch = diagnostic_dispatch(Arc::clone(&state)); + + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + + assert_ne!( + state.event_thread(), + Some(caller), + "worker-start diagnostics must not call the subscriber on the dropping thread" + ); + assert_eq!(state.event_count(), 0); + assert_failed_launch_without_close(); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_invoke_a_panicking_subscriber() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let state = DiagnosticState::new(DiagnosticBehavior::Panic); + let dispatch = diagnostic_dispatch(state); + + let result = catch_unwind(AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + })); + + assert!( + result.is_ok(), + "worker-start failure must not invoke a panicking subscriber" + ); + assert_failed_launch_without_close(); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_reenter_tracing() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let state = DiagnosticState::new(DiagnosticBehavior::Reenter); + let dispatch = diagnostic_dispatch(Arc::clone(&state)); + + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + + assert!(!state.reentered.load(Ordering::SeqCst)); + assert_eq!(state.event_count(), 0); + assert_failed_launch_without_close(); + } + + #[cfg(feature = "tracing")] + #[test] + fn close_worker_start_failure_does_not_block_in_a_subscriber() { + let _serial = TEST_LOCK.lock().unwrap(); + reset_close(); + let state = DiagnosticState::new(DiagnosticBehavior::Block); + let dispatch = diagnostic_dispatch(Arc::clone(&state)); + let (finished, finished_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + tracing::dispatcher::with_default(&dispatch, fail_close_worker_start); + finished.send(()).unwrap(); + }); + + let returned_before_watchdog = finished_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + state.release(); + worker.join().unwrap(); + + assert!( + returned_before_watchdog, + "worker-start failure blocked in an inline subscriber callback" + ); + assert_eq!(state.event_count(), 0); + assert_failed_launch_without_close(); + } +} diff --git a/src/tokio/pty/windows/controller.rs b/src/tokio/pty/windows/controller.rs new file mode 100644 index 0000000..7790d04 --- /dev/null +++ b/src/tokio/pty/windows/controller.rs @@ -0,0 +1,164 @@ +//! Shared Windows PTY controller ownership and Tokio named-pipe I/O. + +use std::{ + io, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + pin::Pin, + sync::{Arc, Weak}, + task::{Context, Poll}, +}; + +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::windows::named_pipe::NamedPipeClient, +}; +use windows::Win32::{ + Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE}, + System::Threading::GetCurrentProcess, +}; + +use super::{super::PtySize, console::PseudoConsole}; + +type SharedMaster = Arc; + +#[derive(Debug)] +struct Master { + _input_lifetime: OwnedHandle, + _output_lifetime: OwnedHandle, + console: PseudoConsole, +} + +#[derive(Debug)] +pub(in crate::tokio::pty) struct Input { + pipe: Option, + master: Option, +} + +impl AsyncWrite for Input { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Err(closed())); + }; + Pin::new(pipe).poll_write(cx, buffer) + } + + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffers: &[io::IoSlice<'_>], + ) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Err(closed())); + }; + Pin::new(pipe).poll_write_vectored(cx, buffers) + } + + fn is_write_vectored(&self) -> bool { + self.pipe + .as_ref() + .is_some_and(AsyncWrite::is_write_vectored) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Err(closed())); + }; + Pin::new(pipe).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(pipe) = self.pipe.as_mut() else { + return Poll::Ready(Ok(())); + }; + match Pin::new(pipe).poll_shutdown(cx) { + Poll::Ready(Ok(())) => { + self.pipe.take(); + self.master.take(); + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +#[derive(Debug)] +pub(in crate::tokio::pty) struct Output { + pipe: NamedPipeClient, + _master: SharedMaster, +} + +impl AsyncRead for Output { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.pipe).poll_read(cx, buffer) + } +} + +#[derive(Clone, Debug)] +pub(in crate::tokio::pty) struct Resize { + master: Weak, +} + +impl Resize { + pub(in crate::tokio::pty) fn resize(&self, size: PtySize) -> io::Result<()> { + let master = self.master.upgrade().ok_or_else(closed)?; + master.console.resize(size) + } +} + +pub(super) fn controller( + console: PseudoConsole, + input: NamedPipeClient, + output: NamedPipeClient, +) -> io::Result<(Input, Output, Resize)> { + let input_lifetime = duplicate(HANDLE(input.as_raw_handle()))?; + let output_lifetime = duplicate(HANDLE(output.as_raw_handle()))?; + let master = Arc::new(Master { + _input_lifetime: input_lifetime, + _output_lifetime: output_lifetime, + console, + }); + let resize = Resize { + master: Arc::downgrade(&master), + }; + let input = Input { + pipe: Some(input), + master: Some(Arc::clone(&master)), + }; + let output = Output { + pipe: output, + _master: master, + }; + Ok((input, output, resize)) +} + +fn duplicate(handle: HANDLE) -> io::Result { + // SAFETY: the current-process pseudo-handle and source named-pipe handle are live, duplicate points + // to writable storage, and ownership of the non-inheritable result is transferred exactly once. + unsafe { + let process = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + process, + handle, + process, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .map_err(io::Error::other)?; + Ok(OwnedHandle::from_raw_handle(duplicate.0)) + } +} + +fn closed() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "PTY master is closed") +} diff --git a/src/tokio/pty/windows/environment.rs b/src/tokio/pty/windows/environment.rs index be36e53..63130c9 100644 --- a/src/tokio/pty/windows/environment.rs +++ b/src/tokio/pty/windows/environment.rs @@ -29,6 +29,7 @@ pub(super) enum PreparedEnvironment { } impl PreparedEnvironment { + #[cfg(test)] pub(super) fn as_ptr(&self) -> *const u16 { match self { Self::Inherit => std::ptr::null(), @@ -36,6 +37,7 @@ impl PreparedEnvironment { } } + #[cfg(test)] pub(super) fn is_inherited(&self) -> bool { matches!(self, Self::Inherit) } @@ -139,7 +141,7 @@ fn set_variable(variables: &mut Vec, variable: EnvironmentV } } -fn windows_equal(left: &[u16], right: &[u16]) -> bool { +pub(super) fn windows_equal(left: &[u16], right: &[u16]) -> bool { windows_compare(left, right) == Ordering::Equal } diff --git a/src/tokio/pty/windows/mod.rs b/src/tokio/pty/windows/mod.rs index 6ef721d..afc8a79 100644 --- a/src/tokio/pty/windows/mod.rs +++ b/src/tokio/pty/windows/mod.rs @@ -12,8 +12,20 @@ use super::super::SpawnAttempt; use command::{PreparedCommandLine, WideCString, prepare_command_line}; use environment::{PreparedEnvironment, prepare_environment}; +mod api; +mod attributes; +mod backend; +mod child; pub(super) mod command; +mod console; +mod controller; pub(super) mod environment; +mod pipe; +mod program; +mod spawn; + +pub(super) use backend::{check_available, spawn, validate_size}; +pub(super) use controller::{Input, Output, Resize}; #[derive(Debug, Eq, PartialEq)] struct PreparedWindowsCommand { @@ -25,9 +37,11 @@ struct PreparedWindowsCommand { } fn prepare(attempt: &SpawnAttempt) -> io::Result { - prepare_with(attempt, |inherits, changes| { + let mut prepared = prepare_with(attempt, |inherits, changes| { prepare_environment(inherits, changes) - }) + })?; + prepared.application_name = program::resolve(attempt.get_program(), attempt.get_envs())?; + Ok(prepared) } fn prepare_with<'a>( @@ -109,7 +123,10 @@ mod tests { *self .0 .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(prepare(attempt)); + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(prepare_with(attempt, |inherits, changes| { + prepare_environment(inherits, changes) + })); Err(io::Error::other("Windows command model captured")) } diff --git a/src/tokio/pty/windows/pipe.rs b/src/tokio/pty/windows/pipe.rs new file mode 100644 index 0000000..f4a5e13 --- /dev/null +++ b/src/tokio/pty/windows/pipe.rs @@ -0,0 +1,188 @@ +//! Split named pipes for the ConPTY-facing synchronous and host-facing overlapped endpoints. + +use std::{ + ffi::OsString, + io, + os::windows::{ + ffi::OsStrExt, + io::{AsRawHandle, FromRawHandle, OwnedHandle}, + }, + sync::atomic::{AtomicU64, Ordering}, +}; + +use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; +use windows::{ + Win32::{ + Foundation::{HANDLE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAGS_AND_ATTRIBUTES, PIPE_ACCESS_INBOUND, + PIPE_ACCESS_OUTBOUND, + }, + System::Pipes::{ + CreateNamedPipeW, NAMED_PIPE_MODE, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, + PIPE_TYPE_BYTE, PIPE_WAIT, + }, + }, + core::PCWSTR, +}; + +#[derive(Clone, Copy, Debug)] +enum Direction { + Input, + Output, +} + +impl Direction { + fn suffix(self) -> &'static str { + match self { + Self::Input => "in", + Self::Output => "out", + } + } + + fn server_access(self) -> FILE_FLAGS_AND_ATTRIBUTES { + let access = match self { + Self::Input => PIPE_ACCESS_INBOUND, + Self::Output => PIPE_ACCESS_OUTBOUND, + }; + access | FILE_FLAG_FIRST_PIPE_INSTANCE + } + + fn configure_client(self, options: &mut ClientOptions) { + match self { + Self::Input => { + options.read(false).write(true); + } + Self::Output => { + options.read(true).write(false); + } + } + } +} + +#[derive(Debug)] +pub(super) struct PipePair { + server: OwnedHandle, + host: NamedPipeClient, +} + +impl PipePair { + pub(super) fn input() -> io::Result { + Self::create(Direction::Input) + } + + pub(super) fn output() -> io::Result { + Self::create(Direction::Output) + } + + fn create(direction: Direction) -> io::Result { + static NEXT_PIPE: AtomicU64 = AtomicU64::new(0); + + let sequence = NEXT_PIPE.fetch_add(1, Ordering::Relaxed); + let name = OsString::from(format!( + r"\\.\pipe\process-wrap-conpty-{}-{sequence}-{}", + std::process::id(), + direction.suffix() + )); + let wide_name = name.encode_wide().chain([0]).collect::>(); + let pipe_mode: NAMED_PIPE_MODE = + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS; + + // SAFETY: wide_name is NUL-terminated and remains live for the call. Null security attributes + // produce a non-inheritable synchronous server handle, as required by ConPTY. + let server = unsafe { + CreateNamedPipeW( + PCWSTR(wide_name.as_ptr()), + direction.server_access(), + pipe_mode, + 1, + 0, + 0, + 0, + None, + ) + }; + if server == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateNamedPipeW returned a unique owned handle which is transferred exactly once. + let server = unsafe { OwnedHandle::from_raw_handle(server.0) }; + + let mut options = ClientOptions::new(); + direction.configure_client(&mut options); + let host = options.open(&name)?; + + Ok(Self { server, host }) + } + + pub(super) fn server_handle(&self) -> HANDLE { + HANDLE(self.server.as_raw_handle()) + } + + pub(super) fn into_parts(self) -> (OwnedHandle, NamedPipeClient) { + (self.server, self.host) + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs::File, + io::{Read, Write}, + os::windows::io::AsRawHandle, + }; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use windows::Win32::Foundation::{GetHandleInformation, HANDLE_FLAG_INHERIT}; + + use super::*; + + fn assert_not_inheritable(handle: HANDLE) { + let mut flags = 0; + // SAFETY: handle is borrowed from a live owner and flags points to initialized writable storage. + unsafe { GetHandleInformation(handle, &mut flags) }.unwrap(); + assert_eq!(flags & HANDLE_FLAG_INHERIT.0, 0); + } + + #[tokio::test] + async fn input_pair_has_a_synchronous_reader_and_overlapped_writer() { + let pair = PipePair::input().unwrap(); + assert_not_inheritable(pair.server_handle()); + assert_not_inheritable(HANDLE(pair.host.as_raw_handle())); + let (server, mut host) = pair.into_parts(); + + let reader = std::thread::spawn(move || { + let mut server = File::from(server); + let mut bytes = [0; 4]; + server.read_exact(&mut bytes).unwrap(); + bytes + }); + host.write_all(b"ping").await.unwrap(); + host.shutdown().await.unwrap(); + assert_eq!(reader.join().unwrap(), *b"ping"); + } + + #[tokio::test] + async fn output_pair_has_a_synchronous_writer_and_overlapped_reader() { + let pair = PipePair::output().unwrap(); + assert_not_inheritable(pair.server_handle()); + assert_not_inheritable(HANDLE(pair.host.as_raw_handle())); + let (server, mut host) = pair.into_parts(); + + let writer = std::thread::spawn(move || { + let mut server = File::from(server); + server.write_all(b"pong").unwrap(); + }); + let mut bytes = [0; 4]; + host.read_exact(&mut bytes).await.unwrap(); + writer.join().unwrap(); + assert_eq!(bytes, *b"pong"); + } + + #[tokio::test] + async fn pipe_names_are_unique_across_live_pairs() { + let first = PipePair::input().unwrap(); + let second = PipePair::input().unwrap(); + assert_ne!(first.server.as_raw_handle(), second.server.as_raw_handle()); + } +} diff --git a/src/tokio/pty/windows/program.rs b/src/tokio/pty/windows/program.rs new file mode 100644 index 0000000..078f243 --- /dev/null +++ b/src/tokio/pty/windows/program.rs @@ -0,0 +1,383 @@ +//! Resolution of tracked program intent to the explicit `CreateProcessW` application name. + +use std::{ + env, + ffi::{OsStr, OsString}, + io, + os::windows::ffi::OsStringExt, + path::{Component, Path, PathBuf}, +}; + +use windows::{ + Win32::{ + Storage::FileSystem::{GetFileAttributesW, INVALID_FILE_ATTRIBUTES}, + System::SystemInformation::{GetSystemDirectoryW, GetWindowsDirectoryW}, + }, + core::PCWSTR, +}; + +use super::{ + command::{WideCString, encode}, + environment::windows_equal, +}; + +pub(super) fn resolve<'a>( + program: &OsStr, + changes: impl IntoIterator)>, +) -> io::Result { + let encoded = encode(program, "PTY program")?; + if has_suffix(&encoded, b".bat") || has_suffix(&encoded, b".cmd") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Windows PTY spawning does not execute batch scripts directly", + )); + } + + let path = Path::new(program); + if program.is_empty() || path.file_name().is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "PTY program path has no file name", + )); + } + + if !is_file_name(path, program) { + if has_suffix(&encoded, b".exe") { + return WideCString::from_os(program, "PTY program"); + } + let with_exe = append_exe(path); + if entry_exists(&with_exe) { + return WideCString::from_os(with_exe.as_os_str(), "PTY program"); + } + return WideCString::from_os(program, "PTY program"); + } + + let has_extension = encoded.contains(&(b'.' as u16)); + let child_path = child_path(changes)?; + if let Some(path) = child_path.as_deref() + && let Some(program) = search_path(path, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + if let Ok(mut directory) = env::current_exe() { + directory.pop(); + if let Some(program) = candidate(directory, program, has_extension) { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + } + if let Ok(directory) = system_directory() + && let Some(program) = candidate(directory, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + if let Ok(directory) = windows_directory() + && let Some(program) = candidate(directory, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + if let Some(path) = env::var_os("PATH") + && let Some(program) = search_path(&path, program, has_extension) + { + return WideCString::from_os(program.as_os_str(), "PTY program"); + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + "PTY program not found", + )) +} + +fn child_path<'a>( + changes: impl IntoIterator)>, +) -> io::Result> { + let path_key = "PATH".encode_utf16().collect::>(); + let mut child_path = None; + for (key, value) in changes { + if windows_equal(&encode(key, "PTY environment key")?, &path_key) { + child_path = value.map(OsStr::to_owned); + } + } + Ok(child_path) +} + +fn search_path(paths: &OsStr, program: &OsStr, has_extension: bool) -> Option { + env::split_paths(paths) + .filter(|path| !path.as_os_str().is_empty()) + .find_map(|path| candidate(path, program, has_extension)) +} + +fn candidate(mut directory: PathBuf, program: &OsStr, has_extension: bool) -> Option { + directory.push(program); + if !has_extension { + directory = append_exe(&directory); + } + entry_exists(&directory).then_some(directory) +} + +fn entry_exists(path: &Path) -> bool { + let Ok(encoded) = WideCString::from_os(path.as_os_str(), "PTY program candidate") else { + return false; + }; + // SAFETY: `WideCString::from_os` rejects interior NULs and appends a trailing + // NUL. `encoded.as_ptr()` points to that readable allocation, which remains + // live and unmodified while `GetFileAttributesW` reads it during this call. + unsafe { GetFileAttributesW(PCWSTR(encoded.as_ptr())) != INVALID_FILE_ATTRIBUTES } +} + +fn append_exe(path: &Path) -> PathBuf { + let mut path = path.as_os_str().to_os_string(); + path.push(".exe"); + PathBuf::from(path) +} + +fn is_file_name(path: &Path, program: &OsStr) -> bool { + let mut components = path.components(); + matches!(components.next(), Some(Component::Normal(name)) if name == program) + && components.next().is_none() +} + +fn has_suffix(value: &[u16], suffix: &[u8]) -> bool { + value.len() >= suffix.len() + && value[value.len() - suffix.len()..] + .iter() + .zip(suffix) + .all(|(&left, &right)| { + u8::try_from(left).is_ok_and(|left| left.eq_ignore_ascii_case(&right)) + }) +} + +fn system_directory() -> io::Result { + query_directory(|buffer| { + // SAFETY: the optional slice is writable for its reported length. + unsafe { GetSystemDirectoryW(buffer) } + }) +} + +fn windows_directory() -> io::Result { + query_directory(|buffer| { + // SAFETY: the optional slice is writable for its reported length. + unsafe { GetWindowsDirectoryW(buffer) } + }) +} + +fn query_directory(mut query: impl FnMut(Option<&mut [u16]>) -> u32) -> io::Result { + let required = query(None); + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0; required as usize]; + loop { + let length = query(Some(&mut buffer)); + if length == 0 { + return Err(io::Error::last_os_error()); + } + if (length as usize) < buffer.len() { + buffer.truncate(length as usize); + return Ok(PathBuf::from(OsString::from_wide(&buffer))); + } + buffer.resize(length as usize + 1, 0); + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs::{self, File}, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + fs::symlink_file, + }, + process::{Command, Stdio}, + }; + + use tempfile::tempdir; + use windows::{ + Win32::Storage::FileSystem::{GetFileAttributesW, INVALID_FILE_ATTRIBUTES}, + core::PCWSTR, + }; + + use super::*; + + type EnvChange = (OsString, Option); + + fn resolve_program( + program: impl AsRef, + changes: &[EnvChange], + ) -> io::Result { + resolve( + program.as_ref(), + changes + .iter() + .map(|(key, value)| (key.as_os_str(), value.as_deref())), + ) + } + + fn units(path: &OsStr) -> Vec { + path.encode_wide().chain([0]).collect() + } + + fn create_dangling_executable_entry(entry: &Path) { + let target = entry.with_file_name("missing-target"); + File::create(&target).unwrap(); + + if let Err(symlink_error) = symlink_file(&target, entry) { + fs::remove_file(&target).unwrap(); + fs::create_dir(&target).unwrap(); + let junction = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(entry) + .arg(&target) + .output() + .unwrap(); + assert!( + junction.status.success(), + "file symlink creation failed ({symlink_error}); junction creation failed (status {}; stdout: {}; stderr: {})", + junction.status, + String::from_utf8_lossy(&junction.stdout), + String::from_utf8_lossy(&junction.stderr), + ); + fs::remove_dir(&target).unwrap(); + } else { + fs::remove_file(&target).unwrap(); + } + + assert!( + fs::symlink_metadata(entry).is_ok(), + "dangling reparse entry was not created" + ); + assert!(!entry.exists(), "reparse target must be absent"); + + let encoded = units(entry.as_os_str()); + // SAFETY: `encoded` has an explicit trailing NUL; `encoded.as_ptr()` points + // to its readable allocation, which stays live and unmodified during the call. + let attributes = unsafe { GetFileAttributesW(PCWSTR(encoded.as_ptr())) }; + assert_ne!( + attributes, INVALID_FILE_ATTRIBUTES, + "GetFileAttributesW could not query the dangling reparse entry" + ); + } + + #[test] + fn preserves_explicit_executable_paths_and_wtf16() { + let current = env::current_exe().unwrap(); + let resolved = resolve_program(current.as_os_str(), &[]).unwrap(); + assert_eq!(resolved.as_units(), units(current.as_os_str())); + + let raw = OsString::from_wide(&[ + b'C' as u16, + b':' as u16, + b'\\' as u16, + 0xd800, + b'.' as u16, + b'e' as u16, + b'x' as u16, + b'e' as u16, + ]); + let resolved = resolve_program(&raw, &[]).unwrap(); + assert_eq!(resolved.as_units(), units(&raw)); + } + + #[test] + fn searches_the_application_and_system_directories() { + let current = env::current_exe().unwrap(); + let file_name = current.file_name().unwrap().to_os_string(); + let resolved = resolve_program(file_name, &[]).unwrap(); + assert_eq!(resolved.as_units(), units(current.as_os_str())); + + let command = resolve_program("cmd", &[]).unwrap(); + let command = PathBuf::from(OsString::from_wide( + &command.as_units()[..command.as_units().len() - 1], + )); + assert!(command.exists()); + assert!( + command + .file_name() + .unwrap() + .to_string_lossy() + .eq_ignore_ascii_case("cmd.exe") + ); + } + + #[test] + fn searches_the_explicit_child_path_case_insensitively() { + let directory = tempdir().unwrap(); + let executable = directory.path().join("tracked.exe"); + File::create(&executable).unwrap(); + let changes = [( + OsString::from("Path"), + Some(directory.path().as_os_str().to_os_string()), + )]; + + let resolved = resolve_program("tracked", &changes).unwrap(); + assert_eq!(resolved.as_units(), units(executable.as_os_str())); + } + + #[test] + fn stops_path_search_at_a_dangling_executable_entry() { + let directory = tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + fs::create_dir(&first).unwrap(); + fs::create_dir(&second).unwrap(); + + let dangling = first.join("tool.exe"); + create_dangling_executable_entry(&dangling); + fs::copy(env::current_exe().unwrap(), second.join("tool.exe")).unwrap(); + + let path = env::join_paths([first.as_os_str(), second.as_os_str()]).unwrap(); + let changes = [(OsString::from("PATH"), Some(path.clone()))]; + let resolved = resolve_program("tool", &changes).unwrap(); + assert_eq!(resolved.as_units(), units(dangling.as_os_str())); + + let spawn = Command::new("tool") + .arg("--help") + .env("PATH", path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut child) = spawn { + let _ = child.kill(); + let _ = child.wait(); + panic!("native Command continued to the second PATH entry"); + } + } + + #[test] + fn appends_exe_to_an_existing_direct_path() { + let directory = tempdir().unwrap(); + let requested = directory.path().join("direct"); + let executable = directory.path().join("direct.exe"); + File::create(&executable).unwrap(); + + let resolved = resolve_program(requested.as_os_str(), &[]).unwrap(); + assert_eq!(resolved.as_units(), units(executable.as_os_str())); + } + + #[test] + fn appends_exe_to_a_dangling_direct_path_entry() { + let directory = tempdir().unwrap(); + let requested = directory.path().join("direct"); + let dangling = directory.path().join("direct.exe"); + create_dangling_executable_entry(&dangling); + + let resolved = resolve_program(requested.as_os_str(), &[]).unwrap(); + assert_eq!(resolved.as_units(), units(dangling.as_os_str())); + } + + #[test] + fn rejects_batch_files_and_missing_bare_programs() { + for program in ["script.bat", "SCRIPT.CMD"] { + let error = resolve_program(program, &[]).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "Windows PTY spawning does not execute batch scripts directly" + ); + } + + let error = resolve_program("process-wrap-definitely-missing-program", &[]).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!(error.to_string(), "PTY program not found"); + } +} diff --git a/src/tokio/pty/windows/spawn.rs b/src/tokio/pty/windows/spawn.rs new file mode 100644 index 0000000..353b9e9 --- /dev/null +++ b/src/tokio/pty/windows/spawn.rs @@ -0,0 +1,1857 @@ +//! Exact `CreateProcessW` spawning for a prepared ConPTY command. +//! +//! # ConPTY platform assumption +//! +//! Process creation with `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` is assumed to attach the process to +//! a new console for the null-standard-handle replacement rule when `STARTF_USESTDHANDLES` is +//! present. This assumption reconciles the +//! [`CreateProcessW` contract](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw), +//! which requires valid standard-handle slots, with the +//! [`GetStdHandle` contract](https://learn.microsoft.com/en-us/windows/console/getstdhandle), which +//! describes replacement of null slots when attaching to a new console. The native standard-handle +//! regression checks deployed behavior but does not establish this platform contract. + +use std::{ + ffi::c_void, + io, + mem::size_of, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, + sync::{Arc, Mutex, OnceLock}, +}; + +use windows::{ + Win32::{ + Foundation::{ + DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0, + WAIT_TIMEOUT, WIN32_ERROR, + }, + System::Threading::{ + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, + GetCurrentProcess, GetProcessIdOfThread, INFINITE, OpenProcess, PROCESS_CREATION_FLAGS, + PROCESS_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, STARTF_USESTDHANDLES, + STARTUPINFOEXW, TerminateProcess, WaitForSingleObject, + }, + }, + core::{PCWSTR, PWSTR}, +}; + +use super::{ + PreparedWindowsCommand, attributes::AttributeList, child::ConPtyChild, + environment::PreparedEnvironment, +}; + +pub(super) fn spawn( + mut command: PreparedWindowsCommand, + attributes: &AttributeList, + input_server: OwnedHandle, + output_server: OwnedHandle, +) -> io::Result { + let startup = startup_info(attributes); + + let mut flags = PROCESS_CREATION_FLAGS(command.creation.spawn_creation_flags()) + | EXTENDED_STARTUPINFO_PRESENT; + let environment = match &command.environment { + PreparedEnvironment::Inherit => None, + PreparedEnvironment::Block(block) => { + flags |= CREATE_UNICODE_ENVIRONMENT; + Some(block.as_ptr().cast::()) + } + }; + let current_dir = command + .current_dir + .as_ref() + .map_or(PCWSTR::null(), |directory| PCWSTR(directory.as_ptr())); + let mut information = PROCESS_INFORMATION::default(); + + // SAFETY: every pointer references live, NUL-terminated or explicitly bounded storage for the + // duration of the call. The command line is uniquely mutable, the startup attribute list owns its + // aligned backing storage, and no native handles are inherited. Under the ConPTY platform + // assumption above, STARTF_USESTDHANDLES with three null slots causes ConPTY to install its console + // handles. The process-information value is writable output storage. + let created = unsafe { + CreateProcessW( + PCWSTR(command.application_name.as_ptr()), + Some(PWSTR(command.command_line.as_mut_ptr())), + None, + None, + false, + flags, + environment, + current_dir, + &startup.StartupInfo, + &mut information, + ) + }; + // ConPTY retains these synchronous endpoints internally. Dropping the parent copies before any + // validation or handle duplication lets both host-facing channels observe closure on every path. + drop(input_server); + drop(output_server); + created.map_err(win32_io_error)?; + + let handles = SpawnedProcess::new(information)?; + let cleanup = handles.cleanup()?; + let child = handles.into_child(command.creation.kills_on_drop()); + Ok(SpawnedChild { child, cleanup }) +} + +fn startup_info(attributes: &AttributeList) -> STARTUPINFOEXW { + let mut startup = STARTUPINFOEXW::default(); + startup.StartupInfo.cb = size_of::() as u32; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = HANDLE::default(); + startup.StartupInfo.hStdOutput = HANDLE::default(); + startup.StartupInfo.hStdError = HANDLE::default(); + startup.lpAttributeList = attributes.as_ptr(); + startup +} + +#[derive(Debug)] +pub(super) struct SpawnedChild { + pub(super) child: ConPtyChild, + pub(super) cleanup: SpawnCleanup, +} + +#[derive(Debug)] +pub(super) struct SpawnCleanup { + process: Option, +} + +impl SpawnCleanup { + pub(super) fn disarm(&mut self) { + self.process.take(); + } + + pub(super) fn rollback(&mut self) -> io::Result<()> { + self.process.take().map_or(Ok(()), |process| { + terminate_and_reap_with(process, WindowsCleanupOperations) + }) + } + + #[cfg(test)] + pub(super) fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { + Self { + process: Some(ReapHandle::tracked(process, released)), + } + } +} + +impl Drop for SpawnCleanup { + fn drop(&mut self) { + let _ = self.rollback(); + } +} + +struct SpawnedProcess { + process: Option, + primary_thread: Option, + pid: u32, +} + +impl SpawnedProcess { + fn new(information: PROCESS_INFORMATION) -> io::Result { + if information.hProcess.is_invalid() || information.hThread.is_invalid() { + cleanup_invalid_information(information); + return Err(io::Error::other( + "CreateProcessW returned invalid process information", + )); + } + + // SAFETY: CreateProcessW returned two distinct owned handles, each transferred exactly once. + let process = unsafe { OwnedHandle::from_raw_handle(information.hProcess.0) }; + // SAFETY: as above, ownership of the primary-thread handle is transferred exactly once. + let primary_thread = unsafe { OwnedHandle::from_raw_handle(information.hThread.0) }; + Ok(Self { + process: Some(process), + primary_thread: Some(primary_thread), + pid: information.dwProcessId, + }) + } + + fn cleanup(&self) -> io::Result { + let process = self + .process + .as_ref() + .expect("a spawned process guard must own its process handle"); + Ok(SpawnCleanup { + process: Some(ReapHandle::new(duplicate(process)?)), + }) + } + + fn into_child(mut self, kill_on_drop: bool) -> ConPtyChild { + let process = self + .process + .take() + .expect("a spawned process guard must own its process handle"); + let primary_thread = self + .primary_thread + .take() + .expect("a spawned process guard must own its primary-thread handle"); + ConPtyChild::new(process, primary_thread, self.pid, kill_on_drop) + } +} + +impl Drop for SpawnedProcess { + fn drop(&mut self) { + if let Some(process) = self.process.take() { + let _ = terminate_and_reap(process); + } + } +} + +fn cleanup_invalid_information(information: PROCESS_INFORMATION) { + let thread = (!information.hThread.is_invalid()).then(|| { + // SAFETY: a non-invalid CreateProcessW output handle is uniquely owned here. + unsafe { OwnedHandle::from_raw_handle(information.hThread.0) } + }); + let pid = thread.as_ref().map_or(0, |thread| { + // SAFETY: thread owns the live primary-thread handle returned by CreateProcessW. + unsafe { GetProcessIdOfThread(HANDLE(thread.as_raw_handle())) } + }); + let pid = if pid != 0 && (information.dwProcessId == 0 || information.dwProcessId == pid) { + pid + } else { + 0 + }; + let process = if !information.hProcess.is_invalid() { + // SAFETY: a non-invalid CreateProcessW output handle is uniquely owned here. + Some(unsafe { OwnedHandle::from_raw_handle(information.hProcess.0) }) + } else if pid != 0 { + // SAFETY: pid identifies the process CreateProcessW reported creating. The recovered handle is + // non-inheritable and is transferred exactly once if it can still be opened. + unsafe { OpenProcess(PROCESS_TERMINATE | PROCESS_SYNCHRONIZE, false, pid) } + .ok() + .map(|handle| unsafe { OwnedHandle::from_raw_handle(handle.0) }) + } else { + None + }; + if let Some(process) = process { + let _ = terminate_and_reap(process); + } +} + +fn duplicate(handle: &OwnedHandle) -> io::Result { + // SAFETY: the current-process pseudo-handle and source process handle are live, duplicate points + // to writable storage, and ownership of the non-inheritable result is transferred exactly once. + unsafe { + let current = GetCurrentProcess(); + let mut duplicate = HANDLE::default(); + DuplicateHandle( + current, + HANDLE(handle.as_raw_handle()), + current, + &mut duplicate, + 0, + false, + DUPLICATE_SAME_ACCESS, + ) + .map_err(win32_io_error)?; + Ok(OwnedHandle::from_raw_handle(duplicate.0)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WaitOutcome { + Signaled, + Timeout, +} + +#[cfg(feature = "tracing")] +#[derive(Clone)] +struct CleanupDiagnostic { + id: u64, + action: &'static str, + error: String, + kind: io::ErrorKind, + raw_os_error: Option, + dispatch: tracing::Dispatch, +} + +#[cfg(feature = "tracing")] +#[derive(Default)] +struct DiagnosticState { + // At most one of `emitting` and `queued` is true. `queued` reserves exactly one shared-state + // clone for the retry queue, including the short interval before that clone is inserted. + next_id: u64, + pending: Vec, + dispatch: Option, + emitting: bool, + queued: bool, +} + +#[derive(Clone, Default)] +struct PendingDiagnostics { + #[cfg(feature = "tracing")] + state: Arc>, +} + +impl PendingDiagnostics { + fn bind_current_dispatch(&self) { + #[cfg(feature = "tracing")] + { + let dispatch = tracing::dispatcher::get_default(Clone::clone); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.dispatch.get_or_insert(dispatch); + } + } + + fn push(&self, action: &'static str, error: &io::Error) { + #[cfg(feature = "tracing")] + { + let fallback_dispatch = tracing::dispatcher::get_default(Clone::clone); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dispatch = state.dispatch.get_or_insert(fallback_dispatch).clone(); + let id = state.next_id; + state.next_id = state.next_id.wrapping_add(1); + state.pending.push(CleanupDiagnostic { + id, + action, + error: error.to_string(), + kind: error.kind(), + raw_os_error: error.raw_os_error(), + dispatch, + }); + } + #[cfg(not(feature = "tracing"))] + let _ = (action, error); + } + + fn try_begin_emit(&self, queued_retry: bool) -> bool { + #[cfg(feature = "tracing")] + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if queued_retry { + if !state.queued { + return false; + } + debug_assert!(!state.emitting); + state.queued = false; + } else if state.queued { + return false; + } + if state.pending.is_empty() || state.emitting { + return false; + } + state.emitting = true; + true + } + #[cfg(not(feature = "tracing"))] + { + let _ = queued_retry; + false + } + } + + fn emission_start_failed(&self) -> bool { + #[cfg(feature = "tracing")] + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(state.emitting); + debug_assert!(!state.queued); + state.emitting = false; + if state.pending.is_empty() || state.queued { + false + } else { + state.queued = true; + true + } + } + #[cfg(not(feature = "tracing"))] + { + false + } + } + + /// Emit one snapshot. Returns true after atomically reserving a later retry for entries whose + /// diagnostic emission hit an unwinding panic or that arrived while this emitter was active. + fn emit(&self) -> bool { + #[cfg(feature = "tracing")] + { + let pending = { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(state.emitting); + debug_assert!(!state.queued); + state.pending.clone() + }; + let mut emitted = Vec::with_capacity(pending.len()); + for diagnostic in pending { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&diagnostic.dispatch, || { + tracing::warn!( + error = %diagnostic.error, + error_kind = ?diagnostic.kind, + raw_os_error = ?diagnostic.raw_os_error, + action = diagnostic.action, + "secondary ConPTY cleanup failure" + ); + }); + })); + match result { + Ok(()) => emitted.push(diagnostic.id), + Err(payload) => std::mem::forget(payload), + } + } + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !emitted.is_empty() { + state + .pending + .retain(|diagnostic| !emitted.contains(&diagnostic.id)); + } + debug_assert!(state.emitting); + debug_assert!(!state.queued); + state.emitting = false; + if state.pending.is_empty() || state.queued { + false + } else { + state.queued = true; + true + } + } + #[cfg(not(feature = "tracing"))] + { + false + } + } + + #[cfg(all(test, feature = "tracing"))] + fn len(&self) -> usize { + self.snapshot().2 + } + + #[cfg(all(test, feature = "tracing"))] + fn snapshot(&self) -> (bool, bool, usize) { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.emitting, state.queued, state.pending.len()) + } +} + +/// Sole owner of the exact handle selected for rollback cleanup. +/// +/// `process` is `Some` from construction until `Drop`; moving this value transfers that ownership +/// unchanged between the caller, reaper handoff cell, reaper thread, and quarantine. Only `Drop` +/// takes and closes the handle. Pending diagnostics move with this owner or share their state with +/// an off-caller emitter and retry queue only after the handle has reached a durable owner. +struct ReapHandle { + process: Option, + diagnostics: PendingDiagnostics, + #[cfg(test)] + released: Option>, +} + +impl std::fmt::Debug for ReapHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ReapHandle") + .field("process", &self.process) + .finish_non_exhaustive() + } +} + +impl ReapHandle { + fn new(process: OwnedHandle) -> Self { + Self { + process: Some(process), + diagnostics: PendingDiagnostics::default(), + #[cfg(test)] + released: None, + } + } + + #[cfg(test)] + fn tracked(process: OwnedHandle, released: std::sync::mpsc::Sender) -> Self { + Self { + process: Some(process), + diagnostics: PendingDiagnostics::default(), + released: Some(released), + } + } + + fn raw(&self) -> HANDLE { + HANDLE( + self.process + .as_ref() + .expect("a reap handle owns its exact process handle until release") + .as_raw_handle(), + ) + } + + fn add_diagnostic(&self, action: &'static str, error: &io::Error) { + self.diagnostics.push(action, error); + } + + fn pending_diagnostics(&self) -> PendingDiagnostics { + self.diagnostics.clone() + } +} + +impl Drop for ReapHandle { + fn drop(&mut self) { + #[cfg(test)] + let raw = self.raw().0 as usize; + // Dropping the OwnedHandle closes exactly the handle transferred into this owner. Do this before + // notifying tests so release observations cannot race the close. + drop(self.process.take()); + #[cfg(test)] + if let Some(released) = self.released.take() { + let _ = released.send(raw); + } + } +} + +trait CleanupOperations: Clone + Send + Sync + 'static { + fn terminate(&self, process: HANDLE) -> io::Result<()>; + fn wait(&self, process: HANDLE, timeout: u32) -> io::Result; + + /// Start the named reaper task. Returning an error means the task was not started. + fn spawn_reaper(&self, task: Box) -> io::Result<()>; + + /// Start an off-caller diagnostic task. Returning an error means the task was not started. + fn spawn_diagnostic(&self, task: Box) -> io::Result<()>; + + fn queue_diagnostics(&self, diagnostics: PendingDiagnostics); + fn take_queued_diagnostics(&self) -> Vec; + fn quarantine(&self, process: ReapHandle); + fn take_quarantined(&self) -> Vec; +} + +#[derive(Clone, Copy, Debug)] +struct WindowsCleanupOperations; + +impl CleanupOperations for WindowsCleanupOperations { + fn terminate(&self, process: HANDLE) -> io::Result<()> { + // SAFETY: every production ReapHandle originates from CreateProcessW, DuplicateHandle with + // DUPLICATE_SAME_ACCESS, or OpenProcess with PROCESS_TERMINATE. Its OwnedHandle remains inside the + // ReapHandle for this call, so the exact handle stays open throughout the operation. + unsafe { TerminateProcess(process, 1) }.map_err(win32_io_error) + } + + fn wait(&self, process: HANDLE, timeout: u32) -> io::Result { + // SAFETY: every production ReapHandle originates from CreateProcessW, DuplicateHandle with + // DUPLICATE_SAME_ACCESS, or OpenProcess with PROCESS_SYNCHRONIZE. Its OwnedHandle remains inside the + // ReapHandle for this call. Callers pass either zero for a nonblocking probe or INFINITE on the + // dedicated reaper thread. + match unsafe { WaitForSingleObject(process, timeout) } { + WAIT_OBJECT_0 => Ok(WaitOutcome::Signaled), + WAIT_TIMEOUT => Ok(WaitOutcome::Timeout), + WAIT_FAILED => Err(io::Error::last_os_error()), + other => Err(io::Error::other(format!( + "unexpected ConPTY rollback wait result {}", + other.0 + ))), + } + } + + fn spawn_reaper(&self, task: Box) -> io::Result<()> { + std::thread::Builder::new() + .name("process-wrap-conpty-reaper".into()) + .spawn(task) + .map(drop) + } + + fn spawn_diagnostic(&self, task: Box) -> io::Result<()> { + std::thread::Builder::new() + .name("process-wrap-conpty-diagnostic".into()) + .spawn(task) + .map(drop) + } + + fn queue_diagnostics(&self, diagnostics: PendingDiagnostics) { + queued_diagnostics() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(diagnostics); + } + + fn take_queued_diagnostics(&self) -> Vec { + std::mem::take( + &mut *queued_diagnostics() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } + + fn quarantine(&self, process: ReapHandle) { + quarantined_processes() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(process); + } + + fn take_quarantined(&self) -> Vec { + std::mem::take( + &mut *quarantined_processes() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } +} + +fn queued_diagnostics() -> &'static Mutex> { + static QUEUED: OnceLock>> = OnceLock::new(); + QUEUED.get_or_init(|| Mutex::new(Vec::new())) +} + +fn quarantined_processes() -> &'static Mutex> { + static QUARANTINED: OnceLock>> = OnceLock::new(); + QUARANTINED.get_or_init(|| Mutex::new(Vec::new())) +} + +struct ReaperStartError { + error: io::Error, + process: ReapHandle, +} + +fn terminate_and_reap(process: OwnedHandle) -> io::Result<()> { + terminate_and_reap_with(ReapHandle::new(process), WindowsCleanupOperations) +} + +fn terminate_and_reap_with( + process: ReapHandle, + operations: O, +) -> io::Result<()> { + process.diagnostics.bind_current_dispatch(); + retry_queued_diagnostics(operations.clone()); + let retry_error = retry_quarantined(operations.clone()); + let termination_error = operations.terminate(process.raw()).err(); + + let current_error = match operations.wait(process.raw(), 0) { + Ok(WaitOutcome::Signaled) => None, + Ok(WaitOutcome::Timeout) => { + if let Some(error) = termination_error.as_ref() { + process.add_diagnostic( + "failed to terminate an unsignaled ConPTY rollback process", + error, + ); + } + let scheduling_error = defer_reap(process, operations.clone()).err(); + termination_error.or(scheduling_error) + } + Err(wait_error) => { + if let Some(error) = termination_error.as_ref() { + process.add_diagnostic("failed to terminate a ConPTY rollback process", error); + } + process.add_diagnostic("failed to probe a ConPTY rollback process", &wait_error); + let _ = defer_reap(process, operations.clone()); + Some(wait_error) + } + }; + + current_error.or(retry_error).map_or(Ok(()), Err) +} + +fn retry_quarantined(operations: O) -> Option { + let mut first_error = None; + for process in operations.take_quarantined() { + let diagnostics = process.pending_diagnostics(); + match start_reaper(process, operations.clone()) { + Ok(()) => schedule_diagnostics(diagnostics, operations.clone()), + Err(failure) => { + let ReaperStartError { error, process } = failure; + process.add_diagnostic( + "failed to retry a quarantined ConPTY rollback process", + &error, + ); + let diagnostics = process.pending_diagnostics(); + operations.quarantine(process); + schedule_diagnostics(diagnostics, operations.clone()); + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + first_error +} + +fn defer_reap(process: ReapHandle, operations: O) -> io::Result<()> { + let diagnostics = process.pending_diagnostics(); + match start_reaper(process, operations.clone()) { + Ok(()) => { + schedule_diagnostics(diagnostics, operations); + Ok(()) + } + Err(failure) => { + let ReaperStartError { error, process } = failure; + process.add_diagnostic( + "failed to start a ConPTY rollback reaper; quarantining the exact process handle", + &error, + ); + let diagnostics = process.pending_diagnostics(); + operations.quarantine(process); + schedule_diagnostics(diagnostics, operations); + Err(error) + } + } +} + +fn retry_queued_diagnostics(operations: O) { + for diagnostics in operations.take_queued_diagnostics() { + if diagnostics.try_begin_emit(true) { + start_diagnostic_worker(diagnostics, operations.clone()); + } + } +} + +fn schedule_diagnostics(diagnostics: PendingDiagnostics, operations: O) { + if diagnostics.try_begin_emit(false) { + start_diagnostic_worker(diagnostics, operations); + } +} + +fn start_diagnostic_worker(diagnostics: PendingDiagnostics, operations: O) { + let task_diagnostics = diagnostics.clone(); + let task_operations = operations.clone(); + let task = Box::new(move || { + if task_diagnostics.emit() { + task_operations.queue_diagnostics(task_diagnostics); + } + }); + if operations.spawn_diagnostic(task).is_err() && diagnostics.emission_start_failed() { + // Diagnosing this recursively could repeat the same thread-creation failure. The state was + // atomically returned to queued/non-emitting and is retried by a later cleanup opportunity. + operations.queue_diagnostics(diagnostics); + } +} + +fn start_reaper( + process: ReapHandle, + operations: O, +) -> Result<(), ReaperStartError> { + // The caller's Arc keeps the transfer cell and exact handle alive if thread creation fails. On + // success, the task takes the handle before waiting and the detached JoinHandle owns no process + // resource. + let transfer = Arc::new(Mutex::new(Some(process))); + let task_transfer = Arc::clone(&transfer); + let task_operations = operations.clone(); + let task = Box::new(move || { + let process = task_transfer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("a started ConPTY reaper receives the exact handle once"); + match task_operations.wait(process.raw(), INFINITE) { + Ok(WaitOutcome::Signaled) => drop(process), + Ok(WaitOutcome::Timeout) => { + let error = io::Error::other( + "an infinite ConPTY rollback reaper wait unexpectedly timed out", + ); + process.add_diagnostic( + "ConPTY rollback reaper could not establish process completion; quarantining the exact process handle", + &error, + ); + let diagnostics = process.pending_diagnostics(); + task_operations.quarantine(process); + schedule_diagnostics(diagnostics, task_operations.clone()); + } + Err(error) => { + process.add_diagnostic( + "ConPTY rollback reaper wait failed; quarantining the exact process handle", + &error, + ); + let diagnostics = process.pending_diagnostics(); + task_operations.quarantine(process); + schedule_diagnostics(diagnostics, task_operations.clone()); + } + } + }); + + if let Err(error) = operations.spawn_reaper(task) { + let process = transfer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("a failed ConPTY reaper start leaves the exact handle with its caller"); + return Err(ReaperStartError { error, process }); + } + Ok(()) +} + +fn win32_io_error(error: windows::core::Error) -> io::Error { + WIN32_ERROR::from_error(&error) + .and_then(|error| i32::try_from(error.0).ok()) + .map_or_else(|| io::Error::other(error), io::Error::from_raw_os_error) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::VecDeque, + io, + os::windows::io::{AsRawHandle, FromRawHandle}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread::ThreadId, + time::Duration, + }; + + #[cfg(feature = "tracing")] + use tracing::{ + Event, Metadata, Subscriber, + span::{Attributes, Id, Record}, + }; + use windows::{ + Win32::System::{ + Console::HPCON, + Threading::{CreateEventW, STARTF_USESTDHANDLES, SetEvent}, + }, + core::PCWSTR, + }; + + use crate::{ + SpawnTransaction, + tokio::{ + ChildWrapper, Command, CommandWrapper, ProviderProduct, SpawnAttempt, SpawnProvider, + }, + }; + + use super::*; + + #[derive(Clone, Copy, Debug)] + enum LifecycleFailure { + Error, + Panic, + } + + #[derive(Debug)] + struct FinalizationFailureChild(LifecycleFailure); + + impl ChildWrapper for FinalizationFailureChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + match self.0 { + LifecycleFailure::Error => Err(io::Error::other("finalization failed")), + LifecycleFailure::Panic => panic_any("finalization failed"), + } + } + } + + #[derive(Debug)] + struct TrackedCleanupTransaction(SpawnCleanup); + + impl SpawnTransaction for TrackedCleanupTransaction { + fn commit(&mut self) -> io::Result<()> { + self.0.disarm(); + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + self.0.rollback() + } + } + + struct CleanupControl { + signal: OwnedHandle, + released: mpsc::Receiver, + expected_handle: usize, + } + + #[derive(Debug)] + struct TrackedCleanupProvider { + failure: LifecycleFailure, + control: mpsc::Sender, + } + + impl SpawnProvider for TrackedCleanupProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> io::Result { + // SAFETY: default security, manual reset, initially unsignaled, and no name request an event + // handle which is transferred immediately to `OwnedHandle`. + let event = unsafe { CreateEventW(None, true, false, None) }?; + // SAFETY: the successful CreateEventW result is uniquely owned here. + let process = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let signal = duplicate(&process)?; + let expected_handle = process.as_raw_handle() as usize; + let (released, released_rx) = mpsc::channel(); + let cleanup = SpawnCleanup::tracked(process, released); + self.control + .send(CleanupControl { + signal, + released: released_rx, + expected_handle, + }) + .expect("the lifecycle test still receives cleanup ownership"); + Ok(ProviderProduct::new( + Box::new(FinalizationFailureChild(self.failure)), + Box::new(TrackedCleanupTransaction(cleanup)), + )) + } + } + + #[derive(Debug)] + struct TrackedCleanupWrapper(TrackedCleanupProvider); + + impl CommandWrapper for TrackedCleanupWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[test] + fn finalization_failures_transfer_the_exact_transaction_handle_to_the_reaper() -> io::Result<()> + { + for failure in [LifecycleFailure::Error, LifecycleFailure::Panic] { + let (control, control_rx) = mpsc::channel(); + let mut command = Command::new("provider-owned-program"); + command.wrap(TrackedCleanupWrapper(TrackedCleanupProvider { + failure, + control, + })); + + match failure { + LifecycleFailure::Error => { + let error = command.spawn().expect_err("finalization must fail"); + assert_eq!(error.to_string(), "finalization failed"); + } + LifecycleFailure::Panic => { + let payload = catch_unwind(AssertUnwindSafe(|| command.spawn())) + .expect_err("finalization must panic"); + assert_eq!( + *payload + .downcast::<&'static str>() + .expect("the original panic payload is preserved"), + "finalization failed" + ); + } + } + + let control = control_rx + .recv_timeout(Duration::from_secs(2)) + .expect("the provider reports its exact cleanup handle"); + assert_eq!(control.released.try_recv(), Err(mpsc::TryRecvError::Empty)); + // SAFETY: `signal` owns a live duplicate of the manual-reset event waited on by the reaper. + unsafe { SetEvent(HANDLE(control.signal.as_raw_handle())) }?; + assert_eq!( + control + .released + .recv_timeout(Duration::from_secs(2)) + .expect("the reaper releases the exact handle after completion"), + control.expected_handle + ); + } + Ok(()) + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + enum SubscriberBehavior { + Block, + Panic, + PanicDrop { + fired: std::sync::atomic::AtomicBool, + drops: Arc, + }, + Record, + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct PanickingDiagnosticPayload(Arc); + + #[cfg(feature = "tracing")] + impl Drop for PanickingDiagnosticPayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a caught diagnostic panic payload was dropped"); + } + } + + #[cfg(feature = "tracing")] + #[derive(Debug, Default)] + struct SubscriberGate { + events: usize, + released: bool, + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct SubscriberState { + behavior: SubscriberBehavior, + gate: Mutex, + changed: Condvar, + } + + #[cfg(feature = "tracing")] + impl SubscriberState { + fn new(behavior: SubscriberBehavior) -> Arc { + Arc::new(Self { + behavior, + gate: Mutex::new(SubscriberGate::default()), + changed: Condvar::new(), + }) + } + + fn wait_for_event(&self) { + self.wait_for_events(1); + } + + fn wait_for_events(&self, count: usize) { + assert!( + self.observe_events(count), + "cleanup diagnostic was not emitted" + ); + } + + fn observe_events(&self, count: usize) -> bool { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while self.event_count() < count { + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(1)); + } + true + } + + fn event_count(&self) -> usize { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .events + } + + fn release(&self) { + let mut gate = self + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.released = true; + self.changed.notify_all(); + } + } + + #[cfg(feature = "tracing")] + #[derive(Debug)] + struct AdversarialSubscriber { + state: Arc, + } + + #[cfg(feature = "tracing")] + impl Subscriber for AdversarialSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, _event: &Event<'_>) { + let mut gate = self + .state + .gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + gate.events += 1; + self.state.changed.notify_all(); + match &self.state.behavior { + SubscriberBehavior::Block => { + while !gate.released { + gate = self.state.changed.wait(gate).unwrap(); + } + } + SubscriberBehavior::Panic => panic!("injected cleanup diagnostic panic"), + SubscriberBehavior::PanicDrop { fired, drops } => { + if !fired.swap(true, Ordering::SeqCst) { + panic_any(PanickingDiagnosticPayload(Arc::clone(drops))); + } + } + SubscriberBehavior::Record => {} + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} + } + + #[cfg(feature = "tracing")] + fn diagnostic_dispatch(state: Arc) -> tracing::Dispatch { + tracing::Dispatch::new(AdversarialSubscriber { state }) + } + + #[derive(Clone, Copy, Debug)] + enum WaitPlan { + Signaled, + Timeout, + Error(i32), + } + + impl WaitPlan { + fn result(self) -> io::Result { + match self { + Self::Signaled => Ok(WaitOutcome::Signaled), + Self::Timeout => Ok(WaitOutcome::Timeout), + Self::Error(code) => Err(io::Error::from_raw_os_error(code)), + } + } + } + + #[derive(Debug)] + struct WaitRecord { + handle: usize, + timeout: u32, + thread: ThreadId, + } + + #[derive(Debug, Default)] + struct ReaperGate { + signaled: bool, + waiters: usize, + } + + struct ActiveDiagnosticThread<'a>(&'a AtomicUsize); + + impl Drop for ActiveDiagnosticThread<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } + } + + struct TestState { + terminate_error: Option, + probes: Mutex>, + reaper_results: Mutex>, + spawn_failures: AtomicUsize, + spawn_calls: AtomicUsize, + diagnostic_spawn_failures: AtomicUsize, + diagnostic_spawn_calls: AtomicUsize, + active_diagnostic_threads: AtomicUsize, + max_active_diagnostic_threads: AtomicUsize, + waits: Mutex>, + gate: Mutex, + gate_changed: Condvar, + queued_diagnostics: Mutex>, + quarantine: Mutex>, + } + + #[derive(Clone)] + struct TestOperations { + state: Arc, + } + + impl TestOperations { + fn new( + terminate_error: Option, + probes: impl IntoIterator, + spawn_failures: usize, + ) -> Self { + Self { + state: Arc::new(TestState { + terminate_error, + probes: Mutex::new(probes.into_iter().collect()), + reaper_results: Mutex::new(VecDeque::new()), + spawn_failures: AtomicUsize::new(spawn_failures), + spawn_calls: AtomicUsize::new(0), + diagnostic_spawn_failures: AtomicUsize::new(0), + diagnostic_spawn_calls: AtomicUsize::new(0), + active_diagnostic_threads: AtomicUsize::new(0), + max_active_diagnostic_threads: AtomicUsize::new(0), + waits: Mutex::new(Vec::new()), + gate: Mutex::new(ReaperGate::default()), + gate_changed: Condvar::new(), + queued_diagnostics: Mutex::new(Vec::new()), + quarantine: Mutex::new(Vec::new()), + }), + } + } + + fn wait_for_reaper(&self) { + let gate = self.state.gate.lock().unwrap(); + let (gate, timeout) = self + .state + .gate_changed + .wait_timeout_while(gate, Duration::from_secs(2), |gate| gate.waiters == 0) + .unwrap(); + assert!(!timeout.timed_out(), "the injected reaper did not start"); + assert!(gate.waiters > 0); + } + + fn signal_reapers(&self) { + let mut gate = self.state.gate.lock().unwrap(); + gate.signaled = true; + self.state.gate_changed.notify_all(); + } + + #[cfg(feature = "tracing")] + fn set_reaper_results(&self, results: impl IntoIterator) { + self.state.reaper_results.lock().unwrap().extend(results); + } + + fn quarantine_len(&self) -> usize { + self.state.quarantine.lock().unwrap().len() + } + + #[cfg(feature = "tracing")] + fn queued_diagnostics_len(&self) -> usize { + self.state.queued_diagnostics.lock().unwrap().len() + } + + #[cfg(feature = "tracing")] + fn quarantined_diagnostic_count(&self) -> usize { + self.state + .quarantine + .lock() + .unwrap() + .iter() + .map(|process| process.diagnostics.len()) + .sum() + } + + fn spawn_calls(&self) -> usize { + self.state.spawn_calls.load(Ordering::SeqCst) + } + + #[cfg(feature = "tracing")] + fn set_diagnostic_spawn_failures(&self, failures: usize) { + self.state + .diagnostic_spawn_failures + .store(failures, Ordering::SeqCst); + } + + #[cfg(feature = "tracing")] + fn diagnostic_spawn_calls(&self) -> usize { + self.state.diagnostic_spawn_calls.load(Ordering::SeqCst) + } + + #[cfg(feature = "tracing")] + fn active_diagnostic_threads(&self) -> usize { + self.state.active_diagnostic_threads.load(Ordering::SeqCst) + } + + #[cfg(feature = "tracing")] + fn wait_for_no_diagnostic_threads(&self) { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while self.active_diagnostic_threads() != 0 { + assert!( + std::time::Instant::now() < deadline, + "diagnostic emitters did not finish" + ); + std::thread::sleep(Duration::from_millis(1)); + } + } + + #[cfg(feature = "tracing")] + fn max_active_diagnostic_threads(&self) -> usize { + self.state + .max_active_diagnostic_threads + .load(Ordering::SeqCst) + } + } + + impl CleanupOperations for TestOperations { + fn terminate(&self, _process: HANDLE) -> io::Result<()> { + match self.state.terminate_error { + Some(code) => Err(io::Error::from_raw_os_error(code)), + None => Ok(()), + } + } + + fn wait(&self, process: HANDLE, timeout: u32) -> io::Result { + self.state.waits.lock().unwrap().push(WaitRecord { + handle: process.0 as usize, + timeout, + thread: std::thread::current().id(), + }); + if timeout == 0 { + return self + .state + .probes + .lock() + .unwrap() + .pop_front() + .expect("each cleanup has an injected zero-time probe") + .result(); + } + assert_eq!(timeout, INFINITE); + + let mut gate = self.state.gate.lock().unwrap(); + gate.waiters += 1; + self.state.gate_changed.notify_all(); + while !gate.signaled { + gate = self.state.gate_changed.wait(gate).unwrap(); + } + drop(gate); + self.state + .reaper_results + .lock() + .unwrap() + .pop_front() + .unwrap_or(WaitPlan::Signaled) + .result() + } + + fn spawn_reaper(&self, task: Box) -> io::Result<()> { + self.state.spawn_calls.fetch_add(1, Ordering::SeqCst); + let fail = self + .state + .spawn_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok(); + if fail { + return Err(io::Error::other("injected ConPTY reaper start failure")); + } + std::thread::Builder::new() + .name("test-process-wrap-conpty-reaper".into()) + .spawn(task) + .map(drop) + } + + fn spawn_diagnostic(&self, task: Box) -> io::Result<()> { + self.state + .diagnostic_spawn_calls + .fetch_add(1, Ordering::SeqCst); + let fail = self + .state + .diagnostic_spawn_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok(); + if fail { + return Err(io::Error::other( + "injected ConPTY diagnostic thread start failure", + )); + } + let state = Arc::clone(&self.state); + std::thread::Builder::new() + .name("test-process-wrap-conpty-diagnostic".into()) + .spawn(move || { + let active = state + .active_diagnostic_threads + .fetch_add(1, Ordering::SeqCst) + + 1; + let _active = ActiveDiagnosticThread(&state.active_diagnostic_threads); + state + .max_active_diagnostic_threads + .fetch_max(active, Ordering::SeqCst); + task(); + }) + .map(drop) + } + + fn queue_diagnostics(&self, diagnostics: PendingDiagnostics) { + self.state + .queued_diagnostics + .lock() + .unwrap() + .push(diagnostics); + } + + fn take_queued_diagnostics(&self) -> Vec { + std::mem::take(&mut *self.state.queued_diagnostics.lock().unwrap()) + } + + fn quarantine(&self, process: ReapHandle) { + self.state.quarantine.lock().unwrap().push(process); + } + + fn take_quarantined(&self) -> Vec { + std::mem::take(&mut *self.state.quarantine.lock().unwrap()) + } + } + + fn tracked_handle() -> (ReapHandle, usize, mpsc::Receiver) { + // SAFETY: null security attributes and name request a new, unnamed event. On success the returned + // handle is uniquely owned by this test and is transferred exactly once to OwnedHandle. + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.unwrap(); + let raw = event.0 as usize; + // SAFETY: CreateEventW returned a new owned handle, transferred exactly once here. + let event = unsafe { OwnedHandle::from_raw_handle(event.0) }; + let (released, observe_release) = mpsc::channel(); + (ReapHandle::tracked(event, released), raw, observe_release) + } + + fn assert_not_released(released: &mpsc::Receiver) { + assert_eq!( + released.try_recv(), + Err(mpsc::TryRecvError::Empty), + "the exact handle was released before its reaper completed" + ); + } + + fn assert_released(released: &mpsc::Receiver, raw: usize) { + assert_eq!(released.recv_timeout(Duration::from_secs(2)).unwrap(), raw); + } + + #[test] + fn requests_conpty_replacement_for_all_standard_handles() { + let attributes = AttributeList::new(HPCON(42)).unwrap(); + let startup = startup_info(&attributes); + assert_eq!(startup.StartupInfo.dwFlags, STARTF_USESTDHANDLES); + assert_eq!(startup.StartupInfo.hStdInput, HANDLE::default()); + assert_eq!(startup.StartupInfo.hStdOutput, HANDLE::default()); + assert_eq!(startup.StartupInfo.hStdError, HANDLE::default()); + } + + #[test] + fn unsignaled_termination_hands_the_exact_handle_to_an_off_thread_reaper() { + let operations = TestOperations::new(None, [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let (completed, observe_completion) = mpsc::channel(); + let caller_operations = operations.clone(); + let caller = std::thread::spawn(move || { + let caller = std::thread::current().id(); + let result = terminate_and_reap_with(process, caller_operations); + completed.send((caller, result)).unwrap(); + }); + + let completion = observe_completion.recv_timeout(Duration::from_secs(2)); + if completion.is_err() { + operations.signal_reapers(); + caller.join().unwrap(); + panic!("cleanup waited for eventual signaling on its caller thread"); + } + let (caller_id, result) = completion.unwrap(); + result.unwrap(); + caller.join().unwrap(); + operations.wait_for_reaper(); + assert_not_released(&released); + + let waits = operations.state.waits.lock().unwrap(); + assert_eq!(waits.len(), 2); + assert_eq!(waits[0].handle, raw); + assert_eq!(waits[0].timeout, 0); + assert_eq!(waits[0].thread, caller_id); + assert_eq!(waits[1].handle, raw); + assert_eq!(waits[1].timeout, INFINITE); + assert_ne!(waits[1].thread, caller_id); + drop(waits); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[test] + fn failed_termination_of_an_unsignaled_process_is_reported_and_reaped() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + + let error = terminate_and_reap_with(process, operations.clone()).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(5)); + operations.wait_for_reaper(); + assert_not_released(&released); + assert_eq!(operations.quarantine_len(), 0); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[test] + fn failed_termination_of_an_already_signaled_process_is_a_natural_exit_race() { + let operations = TestOperations::new(Some(5), [WaitPlan::Signaled], 0); + let (process, raw, released) = tracked_handle(); + + terminate_and_reap_with(process, operations.clone()).unwrap(); + assert_eq!(operations.spawn_calls(), 0); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&released, raw); + } + + #[test] + fn failed_zero_time_wait_is_reported_while_the_exact_handle_stays_owned() { + let operations = TestOperations::new(None, [WaitPlan::Error(6)], 0); + let (process, raw, released) = tracked_handle(); + + let error = terminate_and_reap_with(process, operations.clone()).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(6)); + operations.wait_for_reaper(); + assert_not_released(&released); + assert_eq!(operations.quarantine_len(), 0); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[test] + fn failed_reaper_start_quarantines_then_retries_the_exact_handle() { + let operations = TestOperations::new(None, [WaitPlan::Timeout, WaitPlan::Signaled], 1); + let (process, raw, released) = tracked_handle(); + + let error = terminate_and_reap_with(process, operations.clone()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + let (next_process, next_raw, next_released) = tracked_handle(); + terminate_and_reap_with(next_process, operations.clone()).unwrap(); + operations.wait_for_reaper(); + assert_eq!(operations.spawn_calls(), 2); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&next_released, next_raw); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn blocking_diagnostic_subscriber_runs_before_signal_without_blocking_cleanup_or_reaping() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let subscriber = SubscriberState::new(SubscriberBehavior::Block); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (completed, observe_completion) = mpsc::channel(); + let caller_operations = operations.clone(); + let caller = std::thread::spawn(move || { + let result = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, caller_operations) + }); + completed.send(result).unwrap(); + }); + + let completion = observe_completion.recv_timeout(Duration::from_secs(2)); + if completion.is_err() { + subscriber.release(); + let _ = observe_completion.recv_timeout(Duration::from_secs(2)); + operations.wait_for_reaper(); + operations.signal_reapers(); + let _ = released.recv_timeout(Duration::from_secs(2)); + caller.join().unwrap(); + panic!("cleanup dispatched a blocking diagnostic on its caller thread"); + } + assert_eq!(completion.unwrap().unwrap_err().raw_os_error(), Some(5)); + caller.join().unwrap(); + operations.wait_for_reaper(); + subscriber.wait_for_event(); + assert_eq!(operations.diagnostic_spawn_calls(), 1); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_eq!(subscriber.event_count(), 1); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + subscriber.release(); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(subscriber.event_count(), 1); + } + + #[cfg(feature = "tracing")] + #[test] + fn repeated_start_failures_keep_one_blocked_diagnostic_emitter() { + let operations = TestOperations::new( + None, + [WaitPlan::Timeout, WaitPlan::Signaled, WaitPlan::Signaled], + 2, + ); + let subscriber = SubscriberState::new(SubscriberBehavior::Block); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap_err(); + subscriber.wait_for_event(); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + let (second, second_raw, second_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(second, operations.clone()) + }) + .unwrap_err(); + assert_released(&second_released, second_raw); + let spawn_calls_while_blocked = operations.diagnostic_spawn_calls(); + let max_active_while_blocked = operations.max_active_diagnostic_threads(); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + subscriber.release(); + operations.wait_for_no_diagnostic_threads(); + + let (third, third_raw, third_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(third, operations.clone()) + }) + .unwrap(); + operations.wait_for_reaper(); + subscriber.wait_for_events(2); + assert_released(&third_released, third_raw); + + operations.signal_reapers(); + assert_released(&released, raw); + assert_eq!(spawn_calls_while_blocked, 1); + assert_eq!(max_active_while_blocked, 1); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_eq!(subscriber.event_count(), 2); + } + + #[cfg(feature = "tracing")] + #[test] + fn diagnostic_start_failure_retries_once_while_reaper_remains_unsignaled() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout, WaitPlan::Signaled], 0); + operations.set_diagnostic_spawn_failures(1); + let subscriber = SubscriberState::new(SubscriberBehavior::Record); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + let error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap_err(); + assert_eq!(error.raw_os_error(), Some(5)); + operations.wait_for_reaper(); + assert_eq!(operations.diagnostic_spawn_calls(), 1); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(subscriber.event_count(), 0); + assert_not_released(&released); + + let (next, next_raw, next_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(next, operations.clone()) + }) + .unwrap(); + assert_released(&next_released, next_raw); + let emitted_before_signal = subscriber.observe_events(1); + operations.wait_for_no_diagnostic_threads(); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + assert!( + emitted_before_signal, + "a later cleanup did not retry the retained diagnostic" + ); + assert_eq!(operations.diagnostic_spawn_calls(), 2); + assert_eq!(operations.queued_diagnostics_len(), 0); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_eq!(subscriber.event_count(), 1); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_drop_diagnostic_payload_is_quarantined_and_retried() { + let operations = TestOperations::new(None, [WaitPlan::Timeout, WaitPlan::Signaled], 1); + let payload_drops = Arc::new(AtomicUsize::new(0)); + let subscriber = SubscriberState::new(SubscriberBehavior::PanicDrop { + fired: std::sync::atomic::AtomicBool::new(false), + drops: Arc::clone(&payload_drops), + }); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + let diagnostics = process.pending_diagnostics(); + + let error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .expect_err("the first reaper start failure remains the primary result"); + assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); + subscriber.wait_for_event(); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(diagnostics.snapshot(), (false, true, 1)); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(operations.quarantine_len(), 1); + assert_not_released(&released); + + let (next, next_raw, next_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(next, operations.clone()) + }) + .expect("the later cleanup retains its own primary result"); + operations.wait_for_reaper(); + subscriber.wait_for_events(2); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(diagnostics.snapshot(), (false, false, 0)); + assert_eq!(operations.queued_diagnostics_len(), 0); + assert_eq!(operations.quarantine_len(), 0); + assert_eq!(operations.max_active_diagnostic_threads(), 1); + assert_released(&next_released, next_raw); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostic_subscriber_cannot_replace_the_primary_error() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&dispatch, || { + let _ = terminate_and_reap_with(process, operations.clone()); + Err::<(), _>(io::Error::other("primary lifecycle error")) + }) + })); + let error = result + .expect("cleanup diagnostics must not unwind through the caller") + .unwrap_err(); + assert_eq!(error.to_string(), "primary lifecycle error"); + operations.wait_for_reaper(); + assert_not_released(&released); + + operations.signal_reapers(); + subscriber.wait_for_event(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostic_subscriber_cannot_replace_the_primary_panic() { + let operations = TestOperations::new(Some(5), [WaitPlan::Timeout], 0); + let (process, raw, released) = tracked_handle(); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + tracing::dispatcher::with_default(&dispatch, || { + let _ = terminate_and_reap_with(process, operations.clone()); + panic!("primary lifecycle panic"); + }) + })) + .expect_err("the primary lifecycle panic must be resumed"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "primary lifecycle panic" + ); + operations.wait_for_reaper(); + assert_not_released(&released); + + operations.signal_reapers(); + subscriber.wait_for_event(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostics_leave_start_and_retry_failures_with_quarantined_handle() { + let operations = TestOperations::new( + None, + [WaitPlan::Timeout, WaitPlan::Signaled, WaitPlan::Signaled], + 2, + ); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + let error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap_err(); + assert_eq!(error.to_string(), "injected ConPTY reaper start failure"); + subscriber.wait_for_events(1); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(operations.quarantine_len(), 1); + assert_eq!(operations.quarantined_diagnostic_count(), 1); + assert_not_released(&released); + + let (second, second_raw, second_released) = tracked_handle(); + let retry_error = tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(second, operations.clone()) + }) + .unwrap_err(); + assert_eq!( + retry_error.to_string(), + "injected ConPTY reaper start failure" + ); + subscriber.wait_for_events(2); + operations.wait_for_no_diagnostic_threads(); + let events_after_second_attempt = subscriber.event_count(); + assert!(events_after_second_attempt <= 3); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(operations.quarantine_len(), 1); + assert_eq!(operations.quarantined_diagnostic_count(), 2); + assert_released(&second_released, second_raw); + assert_not_released(&released); + + let (third, third_raw, third_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(third, operations.clone()) + }) + .unwrap(); + operations.wait_for_reaper(); + subscriber.wait_for_events(events_after_second_attempt + 2); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&third_released, third_raw); + assert_not_released(&released); + + operations.signal_reapers(); + assert_released(&released, raw); + } + + #[cfg(feature = "tracing")] + #[test] + fn panicking_diagnostic_preserves_a_handle_after_reaper_wait_failure() { + let operations = TestOperations::new(None, [WaitPlan::Timeout, WaitPlan::Signaled], 0); + operations.set_reaper_results([WaitPlan::Error(6), WaitPlan::Signaled]); + let subscriber = SubscriberState::new(SubscriberBehavior::Panic); + let dispatch = diagnostic_dispatch(Arc::clone(&subscriber)); + let (process, raw, released) = tracked_handle(); + + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(process, operations.clone()) + }) + .unwrap(); + operations.wait_for_reaper(); + operations.signal_reapers(); + subscriber.wait_for_events(1); + operations.wait_for_no_diagnostic_threads(); + assert_eq!(operations.queued_diagnostics_len(), 1); + assert_eq!(operations.quarantine_len(), 1); + assert_eq!(operations.quarantined_diagnostic_count(), 1); + assert_not_released(&released); + + let (next, next_raw, next_released) = tracked_handle(); + tracing::dispatcher::with_default(&dispatch, || { + terminate_and_reap_with(next, operations.clone()) + }) + .unwrap(); + subscriber.wait_for_events(2); + assert_eq!(operations.quarantine_len(), 0); + assert_released(&next_released, next_raw); + assert_released(&released, raw); + } +} diff --git a/src/windows.rs b/src/windows.rs index ce283cd..f6904ac 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -3,14 +3,20 @@ use std::{ io::{Error, Result}, ops::ControlFlow, + os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle as StdOwnedHandle}, time::Duration, }; +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +use std::sync::{Mutex, OnceLock, mpsc}; + #[cfg(feature = "tracing")] use tracing::{debug, instrument}; use windows::{ Win32::{ - Foundation::{CloseHandle, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE}, + Foundation::{ + CloseHandle, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + }, System::{ Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, @@ -19,13 +25,14 @@ use windows::{ IO::{CreateIoCompletionPort, GetQueuedCompletionStatus, OVERLAPPED}, JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - JOBOBJECT_ASSOCIATE_COMPLETION_PORT, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, - JobObjectAssociateCompletionPortInformation, JobObjectExtendedLimitInformation, - SetInformationJobObject, TerminateJobObject, + JOBOBJECT_ASSOCIATE_COMPLETION_PORT, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectAssociateCompletionPortInformation, + JobObjectBasicAccountingInformation, JobObjectExtendedLimitInformation, + QueryInformationJobObject, SetInformationJobObject, TerminateJobObject, }, Threading::{ - CREATE_SUSPENDED, GetProcessId, INFINITE, OpenThread, PROCESS_CREATION_FLAGS, - ResumeThread, THREAD_SUSPEND_RESUME, + CREATE_SUSPENDED, GetProcessId, OpenThread, PROCESS_CREATION_FLAGS, ResumeThread, + THREAD_SUSPEND_RESUME, }, }, }, @@ -86,6 +93,828 @@ mod creation_flag_tests { } } +#[cfg(test)] +mod job_wait_tests { + use std::os::windows::io::AsHandle; + + use windows::Win32::System::IO::{CreateIoCompletionPort, PostQueuedCompletionStatus}; + + use super::*; + + const JOB_OBJECT_MSG_NEW_PROCESS: u32 = 6; + + #[test] + fn finite_timeout_milliseconds_never_uses_infinite() { + assert_eq!(finite_timeout_millis(Duration::ZERO), 0); + assert_eq!(finite_timeout_millis(Duration::from_millis(17)), 17); + assert_eq!( + finite_timeout_millis(Duration::from_millis(u64::from(u32::MAX))), + u32::MAX - 1 + ); + assert_eq!( + finite_timeout_millis(Duration::from_millis(u64::from(u32::MAX) + 1)), + u32::MAX - 1 + ); + } + + #[test] + fn nonterminal_completion_packet_does_not_report_job_drain() { + // SAFETY: these arguments create a new completion port which is immediately owned below. + let raw = unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }.unwrap(); + // SAFETY: `raw` is a newly created, uniquely owned completion-port handle. + let port = unsafe { StdOwnedHandle::from_raw_handle(raw.0) }; + // SAFETY: `port` owns a live completion port; the scalar payload does not borrow memory. + unsafe { + PostQueuedCompletionStatus( + HANDLE(port.as_raw_handle()), + JOB_OBJECT_MSG_NEW_PROCESS, + 0, + None, + ) + } + .unwrap(); + + assert_eq!( + poll_job_drain_with(port.as_handle(), Duration::ZERO, || Ok(false)).unwrap(), + ControlFlow::Continue(()), + "a new-process notification is only a wake hint, not proof that the job drained" + ); + } +} + +#[cfg(test)] +mod job_limit_tests { + use windows::Win32::System::JobObjects::{ + JOB_OBJECT_LIMIT_ACTIVE_PROCESS, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + JOB_OBJECT_LIMIT_PROCESS_MEMORY, + }; + + use super::*; + + fn query_extended_limits(job: JobHandle) -> Result { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: `job` is borrowed from the live owner below; `info` is initialized writable storage of + // exactly the size required by `JobObjectExtendedLimitInformation` and outlives the call. + unsafe { + QueryInformationJobObject( + Some(job.0), + JobObjectExtendedLimitInformation, + &mut info as *mut _ as _, + std::mem::size_of_val(&info) + .try_into() + .expect("extended job limits cannot exceed a DWORD"), + None, + ) + } + .map_err(Error::other)?; + Ok(info) + } + + fn set_extended_limits( + job: JobHandle, + info: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + ) -> Result<()> { + // SAFETY: `job` is borrowed from the live owner below; `info` is initialized readable storage of + // exactly the size required by `JobObjectExtendedLimitInformation` and outlives the call. + unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + info as *const _ as _, + std::mem::size_of_val(info) + .try_into() + .expect("extended job limits cannot exceed a DWORD"), + ) + } + .map_err(Error::other) + } + + fn running_under_wine() -> bool { + std::env::var_os("WINELOADER").is_some() && std::env::var_os("WINEDLLDIR0").is_some() + } + + #[test] + fn kill_on_close_bit_mutation_preserves_complete_structure() { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + info.BasicLimitInformation.PerProcessUserTimeLimit = 11; + info.BasicLimitInformation.PerJobUserTimeLimit = 12; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_BREAKAWAY_OK + | JOB_OBJECT_LIMIT_PROCESS_MEMORY; + info.BasicLimitInformation.MinimumWorkingSetSize = 13; + info.BasicLimitInformation.MaximumWorkingSetSize = 14; + info.BasicLimitInformation.ActiveProcessLimit = 15; + info.BasicLimitInformation.Affinity = 16; + info.BasicLimitInformation.PriorityClass = 17; + info.BasicLimitInformation.SchedulingClass = 18; + info.IoInfo.ReadOperationCount = 19; + info.IoInfo.WriteOperationCount = 20; + info.IoInfo.OtherOperationCount = 21; + info.IoInfo.ReadTransferCount = 22; + info.IoInfo.WriteTransferCount = 23; + info.IoInfo.OtherTransferCount = 24; + info.ProcessMemoryLimit = 25; + info.JobMemoryLimit = 26; + info.PeakProcessMemoryUsed = 27; + info.PeakJobMemoryUsed = 28; + let original = info; + + set_job_kill_on_drop_flag(&mut info, true); + let mut enabled = original; + enabled.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + assert_eq!(info, enabled); + + set_job_kill_on_drop_flag(&mut info, false); + assert_eq!(info, original); + } + + #[test] + fn kill_on_close_transition_preserves_peer_limit_fields() -> Result<()> { + if running_under_wine() { + // Wine's current extended-limit query zeroes the output structure, so the native preservation + // oracle is not available there. The structure-level test above remains selected under Wine. + return Ok(()); + } + // SAFETY: null attributes and name request a new private job; the successful handle is owned below. + let owned_job = OwnedHandle(unsafe { CreateJobObjectW(None, None) }.map_err(Error::other)?); + let job = JobHandle(owned_job.0); + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + info.BasicLimitInformation.ActiveProcessLimit = 7; + set_extended_limits(job, &info)?; + let initial = query_extended_limits(job)?; + assert!( + initial + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS), + "the isolated test job must install the peer limit before toggling kill-on-close" + ); + assert_eq!(initial.BasicLimitInformation.ActiveProcessLimit, 7); + + set_job_kill_on_drop(job, true)?; + let enabled = query_extended_limits(job)?; + assert!( + enabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS), + "enabling kill-on-close must preserve the peer limit flag" + ); + assert!( + enabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), + "enabling kill-on-close must install its own flag" + ); + assert_eq!(enabled.BasicLimitInformation.ActiveProcessLimit, 7); + + set_job_kill_on_drop(job, false)?; + let disabled = query_extended_limits(job)?; + assert!( + disabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS), + "disabling kill-on-close must preserve the peer limit flag" + ); + assert!( + !disabled + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE), + "disabling kill-on-close must clear only its own flag" + ); + assert_eq!(disabled.BasicLimitInformation.ActiveProcessLimit, 7); + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod test_support { + use std::{ + any::Any, + cell::RefCell, + fs, + io::{Error, ErrorKind, Result}, + os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}, + panic::panic_any, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use windows::Win32::{ + Foundation::{HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, + WaitForSingleObject, + }, + }; + + use crate::SpawnTransaction; + + pub const DIRECT_HELPER: &str = "windows::test_support::final_owner_direct_child_helper"; + const DESCENDANT_HELPER: &str = "windows::test_support::final_owner_descendant_helper"; + const DESCENDANT_PID_ENV: &str = "PROCESS_WRAP_FINAL_OWNER_DESCENDANT_PID"; + const MARKER_ENV: &str = "PROCESS_WRAP_FINAL_OWNER_MARKER"; + const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + const MARKER_DELAY: Duration = Duration::from_millis(750); + + #[derive(Debug)] + pub struct OwnerError(pub Arc<()>); + + impl std::fmt::Display for OwnerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("final JobObject owner failed") + } + } + + impl std::error::Error for OwnerError {} + + #[derive(Debug)] + pub struct OwnerPanic(pub Arc<()>); + + pub enum OwnerFailure { + Error(Arc<()>), + Panic(Arc<()>), + } + + #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] + mod job_extraction { + use super::*; + + #[derive(Debug, Default)] + pub struct JobPortCloseProbe { + pub job_closes: AtomicUsize, + pub completion_port_closes: AtomicUsize, + } + + impl JobPortCloseProbe { + pub fn counts(&self) -> (usize, usize) { + ( + self.job_closes.load(Ordering::SeqCst), + self.completion_port_closes.load(Ordering::SeqCst), + ) + } + } + + static JOB_EXTRACTION_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_PRE_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DISARM_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_POST_QUERY_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_STILL_SET_OBSERVATIONS: AtomicUsize = AtomicUsize::new(0); + static JOB_CUSTODIAN_START_FAILURES: AtomicUsize = AtomicUsize::new(0); + static JOB_CUSTODIAN_WORKER_STARTS: AtomicUsize = AtomicUsize::new(0); + static JOB_PORT_LAST_RESORT_RETENTIONS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_ACCOUNTING_QUERIES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DRAIN_OBSERVATIONS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_DISARM_ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_SET_SUCCESSES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_VERIFICATION_QUERIES: AtomicUsize = AtomicUsize::new(0); + static JOB_EXTRACTION_VERIFIED_CLEARS: AtomicUsize = AtomicUsize::new(0); + + thread_local! { + static JOB_PORT_CLOSE_PROBE: RefCell>> = const { RefCell::new(None) }; + } + + fn take_failure(counter: &AtomicUsize) -> bool { + counter + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + } + + pub fn serial_job_extraction() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn arm_job_port_close_probe() -> Arc { + let probe = Arc::new(JobPortCloseProbe::default()); + JOB_PORT_CLOSE_PROBE.with(|slot| { + assert!(slot.borrow_mut().replace(Arc::clone(&probe)).is_none()); + }); + probe + } + + pub fn current_job_port_close_probe() -> Option> { + JOB_PORT_CLOSE_PROBE.with(|slot| slot.borrow().clone()) + } + + pub fn clear_job_port_close_probe() { + JOB_PORT_CLOSE_PROBE.with(|slot| { + assert!(slot.borrow_mut().take().is_some()); + }); + } + + #[derive(Clone, Copy, Debug)] + pub struct JobExtractionBranchCounts { + pub accounting_queries: usize, + pub drain_observations: usize, + pub disarm_attempts: usize, + pub set_successes: usize, + pub verification_queries: usize, + pub verified_clears: usize, + } + + pub fn observe_job_extraction_query(drained: bool) { + JOB_EXTRACTION_ACCOUNTING_QUERIES.fetch_add(1, Ordering::SeqCst); + if drained { + JOB_EXTRACTION_DRAIN_OBSERVATIONS.fetch_add(1, Ordering::SeqCst); + } + } + + pub fn observe_job_extraction_disarm_attempt() { + JOB_EXTRACTION_DISARM_ATTEMPTS.fetch_add(1, Ordering::SeqCst); + } + + pub fn observe_job_extraction_set_success() { + JOB_EXTRACTION_SET_SUCCESSES.fetch_add(1, Ordering::SeqCst); + } + + pub fn observe_job_extraction_verification_query() { + JOB_EXTRACTION_VERIFICATION_QUERIES.fetch_add(1, Ordering::SeqCst); + } + + pub fn observe_job_extraction_verified_clear() { + JOB_EXTRACTION_VERIFIED_CLEARS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_extraction_branch_counts() -> JobExtractionBranchCounts { + JobExtractionBranchCounts { + accounting_queries: JOB_EXTRACTION_ACCOUNTING_QUERIES.load(Ordering::SeqCst), + drain_observations: JOB_EXTRACTION_DRAIN_OBSERVATIONS.load(Ordering::SeqCst), + disarm_attempts: JOB_EXTRACTION_DISARM_ATTEMPTS.load(Ordering::SeqCst), + set_successes: JOB_EXTRACTION_SET_SUCCESSES.load(Ordering::SeqCst), + verification_queries: JOB_EXTRACTION_VERIFICATION_QUERIES.load(Ordering::SeqCst), + verified_clears: JOB_EXTRACTION_VERIFIED_CLEARS.load(Ordering::SeqCst), + } + } + + pub fn set_job_extraction_query_failures(failures: usize) { + JOB_EXTRACTION_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_QUERY_FAILURES) + } + + pub fn set_job_extraction_pre_query_failures(failures: usize) { + JOB_EXTRACTION_PRE_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_pre_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_PRE_QUERY_FAILURES) + } + + pub fn set_job_extraction_disarm_failures(failures: usize) { + JOB_EXTRACTION_DISARM_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_disarm_failure() -> bool { + take_failure(&JOB_EXTRACTION_DISARM_FAILURES) + } + + pub fn set_job_extraction_post_query_failures(failures: usize) { + JOB_EXTRACTION_POST_QUERY_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_extraction_post_query_failure() -> bool { + take_failure(&JOB_EXTRACTION_POST_QUERY_FAILURES) + } + + pub fn set_job_extraction_still_set_observations(observations: usize) { + JOB_EXTRACTION_STILL_SET_OBSERVATIONS.store(observations, Ordering::SeqCst); + } + + pub fn take_job_extraction_still_set_observation() -> bool { + take_failure(&JOB_EXTRACTION_STILL_SET_OBSERVATIONS) + } + + pub fn set_job_custodian_start_failures(failures: usize) { + JOB_CUSTODIAN_START_FAILURES.store(failures, Ordering::SeqCst); + } + + pub fn take_job_custodian_start_failure() -> bool { + take_failure(&JOB_CUSTODIAN_START_FAILURES) + } + + pub fn observe_job_custodian_worker_start() { + JOB_CUSTODIAN_WORKER_STARTS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_custodian_worker_starts() -> usize { + JOB_CUSTODIAN_WORKER_STARTS.load(Ordering::SeqCst) + } + + pub fn observe_job_port_last_resort_retention() { + JOB_PORT_LAST_RESORT_RETENTIONS.fetch_add(1, Ordering::SeqCst); + } + + pub fn job_port_last_resort_retentions() -> usize { + JOB_PORT_LAST_RESORT_RETENTIONS.load(Ordering::SeqCst) + } + + pub fn reset_job_extraction_faults() { + JOB_EXTRACTION_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_PRE_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_DISARM_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_POST_QUERY_FAILURES.store(0, Ordering::SeqCst); + JOB_EXTRACTION_STILL_SET_OBSERVATIONS.store(0, Ordering::SeqCst); + JOB_CUSTODIAN_START_FAILURES.store(0, Ordering::SeqCst); + } + } + + #[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] + pub use job_extraction::*; + + thread_local! { + static OWNER_FAILURE: RefCell> = const { RefCell::new(None) }; + static OWNER_EVENTS: RefCell>>>> = const { RefCell::new(None) }; + static EXTRA_PREPARED_OWNER: RefCell = RefCell::new(ExtraPreparedOwner::default()); + } + + #[derive(Default)] + struct ExtraPreparedOwner { + inject: bool, + owners: Vec>, + } + + pub fn arm_extra_prepared_owner() { + EXTRA_PREPARED_OWNER.with(|state| { + let mut state = state.borrow_mut(); + assert!(!state.inject); + assert!(state.owners.is_empty()); + state.inject = true; + }); + } + + pub fn take_extra_prepared_owner_injection() -> bool { + EXTRA_PREPARED_OWNER.with(|state| std::mem::take(&mut state.borrow_mut().inject)) + } + + pub fn retain_extra_prepared_owner(owner: Box) { + EXTRA_PREPARED_OWNER.with(|state| state.borrow_mut().owners.push(owner)); + } + + pub fn clear_extra_prepared_owners() -> (bool, usize) { + EXTRA_PREPARED_OWNER.with(|state| { + let mut state = state.borrow_mut(); + let inject = std::mem::take(&mut state.inject); + let owner_count = state.owners.len(); + state.owners.clear(); + (inject, owner_count) + }) + } + + pub fn arm_owner_failure(failure: OwnerFailure) { + OWNER_FAILURE.with(|slot| { + assert!(slot.borrow_mut().replace(failure).is_none()); + }); + } + + pub fn fail_final_owner() -> Result<()> { + OWNER_FAILURE.with(|slot| match slot.borrow_mut().take() { + Some(OwnerFailure::Error(identity)) => Err(Error::other(OwnerError(identity))), + Some(OwnerFailure::Panic(identity)) => panic_any(OwnerPanic(identity)), + None => Ok(()), + }) + } + + pub fn clear_owner_failure() -> bool { + OWNER_FAILURE.with(|slot| slot.borrow_mut().take().is_some()) + } + + pub fn arm_owner_events(events: Arc>>) { + OWNER_EVENTS.with(|slot| { + assert!(slot.borrow_mut().replace(events).is_none()); + }); + } + + pub fn arm_owner_transition_probe() { + crate::test_allocator::current_probe().arm(); + } + + pub fn arm_owner_setter_failure_probe() { + crate::test_allocator::current_probe().arm_setter_failure(); + } + + pub fn owner_transition_probe() -> &'static crate::test_allocator::PostTransitionProbe { + crate::test_allocator::current_probe() + } + + pub fn finish_owner_transition_probe() -> crate::test_allocator::PostTransitionProbeState { + crate::test_allocator::current_probe().finish() + } + + pub fn arm_spawn_cleanup_handle_probe() { + crate::test_allocator::current_spawn_cleanup_handle_probe().arm(); + } + + pub fn spawn_cleanup_handle_close_count() -> usize { + crate::test_allocator::current_spawn_cleanup_handle_probe().close_count() + } + + pub fn finish_spawn_cleanup_handle_probe() -> usize { + crate::test_allocator::current_spawn_cleanup_handle_probe().finish() + } + + pub fn record_owner_event(event: &'static str) { + crate::test_allocator::current_probe().observe_operation(); + OWNER_EVENTS.with(|slot| { + if let Some(events) = slot.borrow().as_ref() { + events + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(event); + } + }); + } + + pub fn clear_owner_events() -> bool { + OWNER_EVENTS.with(|slot| slot.borrow_mut().take().is_some()) + } + + #[derive(Debug)] + pub struct ProcessGuard { + handle: OwnedHandle, + armed: bool, + } + + impl ProcessGuard { + pub fn open(pid: u32) -> Result { + // SAFETY: the requested rights operate on the process identified by `pid`; success is + // immediately converted into an owned handle. + let handle = + unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } + .map_err(Error::other)?; + // SAFETY: `OpenProcess` returned a newly owned process handle. + let handle = unsafe { OwnedHandle::from_raw_handle(handle.0) }; + Ok(Self { + handle, + armed: true, + }) + } + + pub fn clone_from(handle: BorrowedHandle<'_>) -> Result { + Ok(Self { + handle: handle.try_clone_to_owned()?, + armed: true, + }) + } + + pub fn disarm(&mut self) { + self.armed = false; + } + + pub fn has_exited(&self) -> Result { + // SAFETY: `self.handle` remains live and is used only for this nonblocking wait. + match unsafe { WaitForSingleObject(HANDLE(self.handle.as_raw_handle()), 0) } { + WAIT_OBJECT_0 => Ok(true), + WAIT_TIMEOUT => Ok(false), + _ => Err(Error::last_os_error()), + } + } + + pub fn wait_for_exit(&mut self, label: &str) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + // SAFETY: `self.handle` remains live and is used only for a nonblocking wait. + match unsafe { WaitForSingleObject(HANDLE(self.handle.as_raw_handle()), 0) } { + WAIT_OBJECT_0 => { + self.disarm(); + return Ok(()); + } + WAIT_TIMEOUT if Instant::now() < deadline => { + sleep(Duration::from_millis(10)); + } + WAIT_TIMEOUT => { + return Err(Error::new( + ErrorKind::TimedOut, + format!("{label} survived final-owner cleanup"), + )); + } + _ => return Err(Error::last_os_error()), + } + } + } + } + + impl Drop for ProcessGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + // SAFETY: this guard owns a live handle with termination and synchronization rights. + let _ = unsafe { TerminateProcess(HANDLE(self.handle.as_raw_handle()), 1) }; + // SAFETY: the same handle remains live; the finite timeout bounds fallback cleanup. + let _ = unsafe { WaitForSingleObject(HANDLE(self.handle.as_raw_handle()), 1_000) }; + } + } + + #[derive(Clone, Debug)] + pub struct TreePaths { + pub descendant_pid: PathBuf, + pub marker: PathBuf, + } + + impl TreePaths { + pub fn new(directory: &Path) -> Self { + Self { + descendant_pid: directory.join("descendant.pid"), + marker: directory.join("delayed-marker"), + } + } + + pub fn direct_command(&self) -> Result { + let mut command = Command::new(std::env::current_exe()?); + command + .args(["--exact", DIRECT_HELPER, "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_ENV, &self.descendant_pid) + .env(MARKER_ENV, &self.marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + Ok(command) + } + } + + #[derive(Debug, Default)] + pub struct LifecycleState { + pub commits: AtomicUsize, + pub rollbacks: AtomicUsize, + pub residue_drops: AtomicUsize, + pub payload_drops: Arc, + pub direct: Mutex>, + pub descendant: Mutex>, + } + + #[derive(Debug)] + struct ResiduePayload(Arc); + + impl Drop for ResiduePayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a quarantined committed-residue payload was dropped"); + } + } + + #[derive(Debug)] + pub struct PanickingCommittedTransaction { + state: Arc, + rollback_guard: Option, + committed: bool, + } + + impl PanickingCommittedTransaction { + pub fn new(state: Arc, rollback_guard: ProcessGuard) -> Self { + Self { + state, + rollback_guard: Some(rollback_guard), + committed: false, + } + } + } + + impl SpawnTransaction for PanickingCommittedTransaction { + fn commit(&mut self) -> Result<()> { + self.state.commits.fetch_add(1, Ordering::SeqCst); + self.committed = true; + if let Some(guard) = self.rollback_guard.as_mut() { + guard.disarm(); + } + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + self.state.rollbacks.fetch_add(1, Ordering::SeqCst); + self.rollback_guard.take(); + Ok(()) + } + } + + impl Drop for PanickingCommittedTransaction { + fn drop(&mut self) { + if self.committed { + self.state.residue_drops.fetch_add(1, Ordering::SeqCst); + panic_any(ResiduePayload(Arc::clone(&self.state.payload_drops))); + } + } + } + + pub fn publish_process_guards( + state: &Arc, + handle: BorrowedHandle<'_>, + ) -> Result { + let external = ProcessGuard::clone_from(handle)?; + let rollback = ProcessGuard::clone_from(handle)?; + *state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(external); + Ok(rollback) + } + + pub fn observe_descendant(paths: &TreePaths, state: &Arc) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + let pid = loop { + if let Ok(pid) = fs::read_to_string(&paths.descendant_pid) + .and_then(|pid| pid.trim().parse().map_err(Error::other)) + { + break pid; + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "the final-owner descendant did not report its process ID", + )); + } + sleep(Duration::from_millis(10)); + }; + let guard = ProcessGuard::open(pid)?; + *state + .descendant + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(guard); + Ok(()) + } + + pub fn assert_tree_terminated(paths: &TreePaths, state: &Arc) -> Result<()> { + let mut direct = state + .direct + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| Error::other("the provider did not publish its direct child handle"))?; + let mut descendant = state + .descendant + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| Error::other("the hook did not publish its descendant handle"))?; + direct.wait_for_exit("the direct child")?; + descendant.wait_for_exit("the descendant")?; + + let marker_deadline = Instant::now() + MARKER_DELAY + Duration::from_millis(250); + while Instant::now() < marker_deadline { + if paths.marker.exists() { + return Err(Error::other( + "the terminated descendant wrote its delayed marker", + )); + } + sleep(Duration::from_millis(10)); + } + if paths.marker.exists() { + return Err(Error::other( + "the terminated descendant wrote its delayed marker", + )); + } + Ok(()) + } + + #[test] + #[ignore = "subprocess helper"] + fn final_owner_direct_child_helper() -> Result<()> { + let descendant_pid = std::env::var_os(DESCENDANT_PID_ENV) + .ok_or_else(|| Error::other("the descendant PID path is missing"))?; + let marker = std::env::var_os(MARKER_ENV) + .ok_or_else(|| Error::other("the delayed-marker path is missing"))?; + let mut descendant = Command::new(std::env::current_exe()?) + .args(["--exact", DESCENDANT_HELPER, "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_ENV, descendant_pid) + .env(MARKER_ENV, marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let _ = descendant.wait()?; + Ok(()) + } + + #[test] + #[ignore = "subprocess helper"] + fn final_owner_descendant_helper() -> Result<()> { + let descendant_pid = std::env::var_os(DESCENDANT_PID_ENV) + .ok_or_else(|| Error::other("the descendant PID path is missing"))?; + let marker = std::env::var_os(MARKER_ENV) + .ok_or_else(|| Error::other("the delayed-marker path is missing"))?; + fs::write(descendant_pid, std::process::id().to_string())?; + sleep(MARKER_DELAY); + fs::File::create(marker)?; + Ok(()) + } +} + #[derive(Clone, Copy, Debug)] pub struct JobHandle(pub HANDLE); @@ -94,54 +923,162 @@ unsafe impl Send for JobHandle {} // SAFETY: shared access exposes only the handle value, not mutable Rust memory. unsafe impl Sync for JobHandle {} -#[derive(Clone, Copy, Debug)] -pub struct PortHandle(pub HANDLE); - -// SAFETY: this non-owning wrapper contains only a process-wide kernel handle value. -unsafe impl Send for PortHandle {} -// SAFETY: shared access exposes only the handle value, not mutable Rust memory. -unsafe impl Sync for PortHandle {} - /// A JobObject and its associated completion port. /// /// This struct closes the handles when dropped. #[derive(Debug)] pub(crate) struct JobPort { pub job: JobHandle, - pub completion_port: PortHandle, + pub completion_port: StdOwnedHandle, + #[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] + close_probe: Option>, } impl Drop for JobPort { fn drop(&mut self) { // SAFETY: `JobPort` solely owns this job handle. unsafe { CloseHandle(self.job.0) }.ok(); - // SAFETY: `JobPort` solely owns this distinct completion-port handle. - unsafe { CloseHandle(self.completion_port.0) }.ok(); + #[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] + if let Some(probe) = self.close_probe.as_ref() { + probe + .job_closes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // The completion-port OwnedHandle drops immediately after this Drop implementation returns. + probe + .completion_port_closes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } } } /// 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<()> { + set_job_kill_on_drop_inner( + job, + kill_on_drop, + #[cfg(test)] + None, + ) +} + +#[cfg(test)] +pub(crate) fn set_job_kill_on_drop_observed( + job: JobHandle, + kill_on_drop: bool, + probe: &'static crate::test_allocator::PostTransitionProbe, +) -> Result<()> { + set_job_kill_on_drop_inner(job, kill_on_drop, Some(probe)) +} + +fn query_job_extended_limits( + job: JobHandle, + #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, +) -> Result { let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + let info_size = std::mem::size_of_val(&info) + .try_into() + .expect("extended JobObject limit information cannot exceed a DWORD"); + #[cfg(test)] + if let Some(probe) = probe { + probe.observe_query_attempt(); + } + // SAFETY: + // - `job` is borrowed from a live `JobPort` or newly created job owner and carries query access. + // - `info` is fully initialized, writable `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` storage. + // - `info_size` is its exact byte size, and the storage outlives this synchronous call. + // On success the information-class contract initializes the current complete extended-limit state. + let result = unsafe { + QueryInformationJobObject( + Some(job.0), + JobObjectExtendedLimitInformation, + &mut info as *mut _ as _, + info_size, + None, + ) + }; + result.map_err(Error::other)?; + #[cfg(test)] + if let Some(probe) = probe { + probe.observe_query_success(); + } + Ok(info) +} + +fn set_job_kill_on_drop_flag(info: &mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION, kill_on_drop: bool) { if kill_on_drop { - info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + } else { + info.BasicLimitInformation.LimitFlags &= !JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; } +} - // SAFETY: `job` is live, and initialized `info` has the reported size and outlives the call. - unsafe { +fn set_job_kill_on_drop_inner( + job: JobHandle, + kill_on_drop: bool, + #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, +) -> Result<()> { + let mut info = query_job_extended_limits( + job, + #[cfg(test)] + probe, + )?; + set_job_kill_on_drop_flag(&mut info, kill_on_drop); + #[cfg(feature = "tracing")] + debug!( + kill_on_drop, + ?info, + "setting SetInformationJobObject(limit)" + ); + set_job_extended_limits_native( + job, + &info, + #[cfg(test)] + probe, + ) +} + +fn set_job_extended_limits_native( + job: JobHandle, + info: &JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + #[cfg(test)] probe: Option<&crate::test_allocator::PostTransitionProbe>, +) -> Result<()> { + let info_size = std::mem::size_of_val(info) + .try_into() + .expect("extended JobObject limit information cannot exceed a DWORD"); + #[cfg(test)] + let info_size = match probe { + Some(probe) if probe.take_setter_failure() => 0, + _ => info_size, + }; + // No caller-controlled callback runs in this helper. All querying, formatting, tracing, test-probe + // lookup, and size conversion have completed. After a successful native transition, this function + // performs only test-only scalar/atomic observations and result moves before returning. + #[cfg(test)] + if let Some(probe) = probe { + probe.observe_setter_attempt(); + } + // SAFETY: + // - `job` is borrowed from a live `JobPort` or newly created job owner and carries set access. + // - `info` is initialized readable `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` storage containing the + // complete queried state with only the requested bit changed. + // - Production `info_size` is the storage's exact byte size. The test-only failure seam may reduce + // it to zero, which authorizes no structure bytes and cannot extend the native read beyond `info`. + // - The storage outlives this synchronous call. + let result = unsafe { SetInformationJobObject( job.0, JobObjectExtendedLimitInformation, - &info as *const _ as _, - std::mem::size_of_val(&info) - .try_into() - .expect("cannot safely cast to DWORD"), + info as *const _ as _, + info_size, ) - }?; - #[cfg(feature = "tracing")] - debug!(?info, "done SetInformationJobObject(limit)"); - Ok(()) + }; + #[cfg(test)] + if result.is_ok() + && let Some(probe) = probe + { + probe.observe_transition(); + } + result.map_err(Error::other) } /// Create a JobObject and an associated completion port. @@ -156,14 +1093,15 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res debug!(?job, "done CreateJobObjectW"); // SAFETY: these arguments create a new port; the successful handle is immediately owned. - let completion_port = - OwnedHandle(unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1) }?); + let completion_port = unsafe { + StdOwnedHandle::from_raw_handle(CreateIoCompletionPort(INVALID_HANDLE_VALUE, None, 0, 1)?.0) + }; #[cfg(feature = "tracing")] debug!(?completion_port, "done CreateIoCompletionPort"); let associate_completion = JOBOBJECT_ASSOCIATE_COMPLETION_PORT { CompletionKey: job.0.0 as _, - CompletionPort: completion_port.0, + CompletionPort: HANDLE(completion_port.as_raw_handle()), }; // SAFETY: the handles stay live, and initialized `associate_completion` has the reported size. @@ -193,7 +1131,9 @@ pub(crate) fn make_job_object(process_handle: HANDLE, kill_on_drop: bool) -> Res Ok(JobPort { job: JobHandle(job.into_raw()), - completion_port: PortHandle(completion_port.into_raw()), + completion_port, + #[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] + close_probe: test_support::current_job_port_close_probe(), }) } @@ -267,35 +1207,269 @@ pub(crate) fn terminate_job(job: JobHandle, exit_code: u32) -> Result<()> { unsafe { TerminateJobObject(job.0, exit_code) }.map_err(Error::other) } -/// Wait for a job to complete. -#[cfg_attr(feature = "tracing", instrument(level = "debug"))] -pub(crate) fn wait_on_job( - completion_port: PortHandle, - timeout: Option, +/// Maximum interval between authoritative JobObject accounting checks. +pub(crate) const JOB_POLL_INTERVAL: Duration = Duration::from_millis(10); + +fn job_is_drained(job: JobHandle) -> Result { + let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default(); + // SAFETY: `job` is borrowed from a live `JobPort`; the information class requires exactly the + // writable accounting structure and byte count supplied here. The call returns before that stack + // storage is read or dropped. + unsafe { + QueryInformationJobObject( + Some(job.0), + JobObjectBasicAccountingInformation, + &mut accounting as *mut _ as _, + std::mem::size_of_val(&accounting) + .try_into() + .expect("job accounting information cannot exceed a DWORD"), + None, + ) + } + .map_err(Error::other)?; + Ok(accounting.ActiveProcesses == 0) +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn extracted_job_is_drained(job: JobHandle) -> Result { + #[cfg(test)] + if test_support::take_job_extraction_query_failure() { + test_support::observe_job_extraction_query(false); + return Err(Error::other( + "injected extracted JobObject accounting failure", + )); + } + let result = job_is_drained(job); + #[cfg(test)] + test_support::observe_job_extraction_query(matches!(result, Ok(true))); + result +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn disarm_extracted_job(job: JobHandle) -> Result<()> { + #[cfg(test)] + test_support::observe_job_extraction_disarm_attempt(); + #[cfg(test)] + if test_support::take_job_extraction_pre_query_failure() { + return Err(Error::other( + "injected extracted JobObject pre-set limit query failure", + )); + } + let mut info = query_job_extended_limits( + job, + #[cfg(test)] + None, + )?; + set_job_kill_on_drop_flag(&mut info, false); + + #[cfg(test)] + if test_support::take_job_extraction_disarm_failure() { + return Err(Error::other("injected extracted JobObject disarm failure")); + } + set_job_extended_limits_native( + job, + &info, + #[cfg(test)] + None, + )?; + #[cfg(test)] + test_support::observe_job_extraction_set_success(); + + #[cfg(test)] + test_support::observe_job_extraction_verification_query(); + #[cfg(test)] + if test_support::take_job_extraction_post_query_failure() { + return Err(Error::other( + "injected extracted JobObject post-set limit query failure", + )); + } + let effective = query_job_extended_limits( + job, + #[cfg(test)] + None, + )?; + let kill_on_close = effective + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE); + #[cfg(test)] + let kill_on_close = kill_on_close || test_support::take_job_extraction_still_set_observation(); + if kill_on_close { + return Err(Error::other( + "extracted JobObject still reports kill-on-close after disarm", + )); + } + #[cfg(test)] + test_support::observe_job_extraction_verified_clear(); + Ok(()) +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn extracted_job_can_close(job_port: &JobPort) -> bool { + if disarm_extracted_job(job_port.job).is_ok() { + return true; + } + matches!(extracted_job_is_drained(job_port.job), Ok(true)) +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +struct JobPortCustodian { + sender: Option>, +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn job_port_custodian() -> &'static Mutex { + static CUSTODIAN: OnceLock> = OnceLock::new(); + CUSTODIAN.get_or_init(|| Mutex::new(JobPortCustodian { sender: None })) +} + +#[cfg(all(test, feature = "tokio1", feature = "kill-on-drop"))] +pub(crate) fn reset_job_port_custodian_for_test() { + job_port_custodian() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .sender = None; +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn start_job_port_custodian(receiver: mpsc::Receiver) -> Result<()> { + #[cfg(test)] + if test_support::take_job_custodian_start_failure() { + return Err(Error::other("injected JobObject custodian start failure")); + } + std::thread::Builder::new() + .name("process-wrap-job-custodian".into()) + .spawn(move || { + #[cfg(test)] + test_support::observe_job_custodian_worker_start(); + let mut retained = Vec::new(); + loop { + match receiver.recv_timeout(JOB_POLL_INTERVAL) { + Ok(job_port) => { + retained.push(job_port); + retained.extend(receiver.try_iter()); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) if retained.is_empty() => return, + Err(mpsc::RecvTimeoutError::Disconnected) => {} + } + let mut index = 0; + while index < retained.len() { + if extracted_job_can_close(&retained[index]) { + retained.swap_remove(index); + } else { + index += 1; + } + } + } + }) + .map(drop) +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn retain_extracted_job_port(mut job_port: JobPort) -> std::result::Result<(), JobPort> { + let mut custodian = job_port_custodian() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + loop { + if let Some(sender) = custodian.sender.as_ref() { + match sender.send(job_port) { + Ok(()) => return Ok(()), + Err(error) => { + job_port = error.0; + custodian.sender = None; + } + } + } + + let (sender, receiver) = mpsc::channel(); + if start_job_port_custodian(receiver).is_err() { + return Err(job_port); + } + custodian.sender = Some(sender); + } +} + +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +fn retain_extracted_job_port_last_resort(job_port: JobPort) { + #[cfg(test)] + test_support::observe_job_port_last_resort_retention(); + // The infallible extraction API cannot report failure to establish shared custody. Forgetting this + // exact complete port keeps the possibly armed final job handle open and preserves the lower child. + std::mem::forget(job_port); +} + +/// Relinquish an extracted finalized Tokio JobObject without terminating its returned lower child. +#[cfg(all(feature = "tokio1", feature = "kill-on-drop"))] +pub(crate) fn release_extracted_job_port(job_port: JobPort, known_drained: bool) { + if known_drained || matches!(extracted_job_is_drained(job_port.job), Ok(true)) { + drop(job_port); + return; + } + if disarm_extracted_job(job_port.job).is_ok() { + drop(job_port); + return; + } + if let Err(job_port) = retain_extracted_job_port(job_port) { + retain_extracted_job_port_last_resort(job_port); + } +} + +fn finite_timeout_millis(timeout: Duration) -> u32 { + timeout + .as_millis() + .try_into() + .unwrap_or(u32::MAX - 1) + .min(u32::MAX - 1) +} + +fn poll_job_drain_with( + completion_port: BorrowedHandle<'_>, + timeout: Duration, + mut is_drained: impl FnMut() -> Result, ) -> Result> { - let mut code: u32 = 0; - let mut key: usize = 0; - let mut overlapped = OVERLAPPED::default(); - let mut lp_overlapped = &mut overlapped as *mut OVERLAPPED; - - // SAFETY: `completion_port` is borrowed from a live `JobPort`, and every output pointer refers - // to live stack storage. On failure we inspect `code` and `key` only when a packet was dequeued, - // as indicated by a non-null `lp_overlapped`. - let result = unsafe { + if is_drained()? { + return Ok(ControlFlow::Break(())); + } + + let mut code = 0; + let mut key = 0; + let mut overlapped: *mut OVERLAPPED = std::ptr::null_mut(); + let timeout_ms = finite_timeout_millis(timeout); + + // SAFETY: `completion_port` is lifetime-bound to a live owned handle, and every output pointer + // refers to initialized writable stack storage for the duration of the call. The finite timeout + // cannot equal `INFINITE`. A dequeued packet is only a wake hint; none of its scalar fields is + // treated as proof that the job drained. + let wake = unsafe { GetQueuedCompletionStatus( - completion_port.0, + HANDLE(completion_port.as_raw_handle()), &mut code, &mut key, - &mut lp_overlapped as *mut _, - timeout.map_or(INFINITE, |d| d.as_millis().try_into().unwrap_or(INFINITE)), + &mut overlapped, + timeout_ms, ) }; + if let Err(error) = wake + && overlapped.is_null() + && error.code() != HRESULT::from_win32(WAIT_TIMEOUT.0) + { + return Err(Error::other(error)); + } - // ignore timing out errors unless the timeout was specified to INFINITE - // https://docs.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatus - if timeout.is_some() && result.is_err() && lp_overlapped.is_null() { - return Ok(ControlFlow::Continue(())); + if is_drained()? { + Ok(ControlFlow::Break(())) + } else { + Ok(ControlFlow::Continue(())) } +} - Ok(ControlFlow::Break(())) +/// Poll whether a job has no active processes, waiting at most `timeout` for a wake hint. +#[cfg_attr(feature = "tracing", instrument(level = "debug"))] +pub(crate) fn poll_job_drain( + job: JobHandle, + completion_port: BorrowedHandle<'_>, + timeout: Duration, +) -> Result> { + poll_job_drain_with(completion_port, timeout, || job_is_drained(job)) } diff --git a/tests/child_wrapper_contract.rs b/tests/child_wrapper_contract.rs index fcd387e..e443519 100644 --- a/tests/child_wrapper_contract.rs +++ b/tests/child_wrapper_contract.rs @@ -54,7 +54,9 @@ mod std_frontend { use process_wrap::std::ChildWrapper; #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] - use process_wrap::std::{CommandWrap, CommandWrapper}; + use process_wrap::std::{ + ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, PendingChildWrapper, + }; #[derive(Debug)] struct Layer { @@ -227,36 +229,49 @@ mod std_frontend { #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] #[derive(Debug)] - struct MarkerChild(Box); + struct MarkerChild(Option>); + + #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] + impl ChildWrapperLayer for MarkerChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl CommandWrapper for MarkerCommand { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> std::io::Result> { - Ok(Box::new(MarkerChild(child))) + ) -> std::io::Result> { + Ok(Some(PendingChildWrapper::new(MarkerChild(None)))) } } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl ChildWrapper for MarkerChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed marker layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed marker layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed marker layer owns its child") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } @@ -555,7 +570,9 @@ mod tokio_frontend { use process_wrap::tokio::ChildWrapper; #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] - use process_wrap::tokio::{CommandWrap, CommandWrapper}; + use process_wrap::tokio::{ + ChildWrapperLayer, ChildWrapperSlots, CommandWrap, CommandWrapper, PendingChildWrapper, + }; use tokio::process::{Child, Command}; #[derive(Debug)] @@ -729,36 +746,49 @@ mod tokio_frontend { #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] #[derive(Debug)] - struct MarkerChild(Box); + struct MarkerChild(Option>); + + #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] + impl ChildWrapperLayer for MarkerChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl CommandWrapper for MarkerCommand { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> std::io::Result> { - Ok(Box::new(MarkerChild(child))) + ) -> std::io::Result> { + Ok(Some(PendingChildWrapper::new(MarkerChild(None)))) } } #[cfg(all(unix, any(feature = "process-group", feature = "process-session")))] impl ChildWrapper for MarkerChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed marker layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed marker layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed marker layer owns its child") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } diff --git a/tests/process_identity.rs b/tests/process_identity.rs new file mode 100644 index 0000000..2900888 --- /dev/null +++ b/tests/process_identity.rs @@ -0,0 +1,615 @@ +#![cfg(all(unix, feature = "tokio1", feature = "process-group"))] + +use std::{ + future::Future, + io, + os::unix::process::ExitStatusExt, + pin::Pin, + process::ExitStatus, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::{Duration, Instant}, +}; + +use process_wrap::{ + ProcessGroupTarget, SpawnTransaction, + tokio::{ + ChildWrapper, Command, CommandWrapper, ProcessGroup, ProviderProduct, SpawnAttempt, + SpawnProvider, + }, +}; + +const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug)] +struct CompletedTransaction; + +impl SpawnTransaction for CompletedTransaction { + fn commit(&mut self) -> io::Result<()> { + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct HistoricalChild { + child: Arc>, + spawned_id: u32, +} + +impl HistoricalChild { + fn lock(&self) -> std::sync::MutexGuard<'_, tokio::process::Child> { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ChildWrapper for HistoricalChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn id(&self) -> Option { + self.lock().id() + } + + fn spawned_id_layer(&self) -> Option { + Some(self.spawned_id) + } + + fn has_process_group_signal_layer(&self) -> bool { + true + } + + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + ChildWrapper::signal_process_group_layer(&mut *self.lock(), process_group, signal) + } + + fn start_kill(&mut self) -> io::Result<()> { + ChildWrapper::start_kill(&mut *self.lock()) + } + + fn try_wait(&mut self) -> io::Result> { + self.lock().try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + let child = Arc::clone(&self.child); + Box::pin(std::future::poll_fn(move |context| { + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wait = Box::pin(child.wait()); + wait.as_mut().poll(context) + })) + } +} + +#[derive(Debug)] +struct HistoricalProvider; + +impl SpawnProvider for HistoricalProvider { + fn spawn(&self, attempt: &mut SpawnAttempt, _command: &Command) -> io::Result { + assert_eq!( + attempt.process_group_target(), + Some(ProcessGroupTarget::Leader) + ); + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "exit 29"]).process_group(0); + let mut child = command.spawn()?; + let spawned_id = child + .id() + .ok_or_else(|| io::Error::other("the provider child has no spawned PID"))?; + let deadline = Instant::now() + EXIT_TIMEOUT; + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the provider child did not exit", + )); + } + std::thread::yield_now(); + } + assert_eq!(child.id(), None); + Ok(ProviderProduct::new( + Box::new(HistoricalChild { + child: Arc::new(Mutex::new(child)), + spawned_id, + }), + Box::new(CompletedTransaction), + )) + } +} + +#[derive(Debug)] +struct ProviderWrapper; + +impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&HistoricalProvider) + } +} + +#[tokio::test] +async fn provider_historical_identity_works_without_pty_in_both_registration_orders() { + for provider_first in [false, true] { + let mut command = Command::new("provider-owned-program"); + if provider_first { + command.wrap(ProviderWrapper).wrap(ProcessGroup::leader()); + } else { + command.wrap(ProcessGroup::leader()).wrap(ProviderWrapper); + } + + let mut child = command.spawn().expect("install process-group supervision"); + assert_eq!(child.id(), None); + let first = child.wait().await.expect("read the cached provider status"); + assert_eq!(first.code(), Some(29)); + assert_eq!(child.wait().await.unwrap(), first); + assert_eq!(child.try_wait().unwrap(), Some(first)); + child.signal(nix::libc::SIGCONT).unwrap(); + } +} + +#[derive(Debug)] +struct StickyState { + child: Mutex, + group_signals: AtomicUsize, +} + +#[derive(Debug)] +struct StickyIdChild { + state: Arc, + spawned_id: u32, +} + +impl ChildWrapper for StickyIdChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn try_clone(&self) -> Option> { + Some(Box::new(Self { + state: Arc::clone(&self.state), + spawned_id: self.spawned_id, + })) + } + + fn id(&self) -> Option { + Some(self.spawned_id) + } + + fn spawned_id_layer(&self) -> Option { + Some(self.spawned_id) + } + + fn has_process_group_signal_layer(&self) -> bool { + true + } + + fn signal_process_group_layer( + &mut self, + process_group: i32, + signal: i32, + ) -> Option>> { + let mut child = self + .state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = ChildWrapper::signal_process_group_layer(&mut *child, process_group, signal)?; + if matches!(result, Ok(None)) { + self.state.group_signals.fetch_add(1, Ordering::SeqCst); + } + Some(result) + } + + fn start_kill(&mut self) -> io::Result<()> { + ChildWrapper::start_kill( + &mut *self + .state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } + + fn try_wait(&mut self) -> io::Result> { + self.state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + let state = Arc::clone(&self.state); + Box::pin(std::future::poll_fn(move |context| { + let mut child = state + .child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wait = Box::pin(child.wait()); + wait.as_mut().poll(context) + })) + } +} + +fn sticky_child( + child: tokio::process::Child, + publish: &Arc>>>, +) -> Box { + let spawned_id = child.id().expect("the fresh child has a PID"); + let state = Arc::new(StickyState { + child: Mutex::new(child), + group_signals: AtomicUsize::new(0), + }); + *publish + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&state)); + Box::new(StickyIdChild { state, spawned_id }) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sticky_custom_id_does_not_authorize_a_signal_after_wait() { + let published = Arc::new(Mutex::new(None)); + let mut command = Command::with_new("sh", |command| { + command.args(["-c", "exit 31"]); + }); + command.wrap(ProcessGroup::leader()); + let mut child = command + .spawn_with_child({ + let published = Arc::clone(&published); + move |native| Ok(sticky_child(native.spawn()?, &published)) + }) + .unwrap(); + let state = published + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap(); + let status = child.inner_mut().wait().await.unwrap(); + assert!(child.inner().id().is_some()); + + child.signal(nix::libc::SIGCONT).unwrap(); + child.start_kill().unwrap(); + assert_eq!(state.group_signals.load(Ordering::SeqCst), 0); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); +} + +#[derive(Debug)] +struct SyntheticSharedState { + status: Mutex>, + wait_acquired: Mutex>>, + signal_acquisition: Mutex>>, + completion: Mutex>>, + group_signals: AtomicUsize, +} + +impl SyntheticSharedState { + fn wait_under_custody(&self) -> io::Result { + let mut status = self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(status) = *status { + return Ok(status); + } + if let Some(acquired) = self + .wait_acquired + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = acquired.send(()); + } + let completion = self + .completion + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(|| { + io::Error::other("synthetic completion receiver was already consumed") + })?; + let completed = completion.recv_timeout(EXIT_TIMEOUT).map_err(|error| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("synthetic child completion was not released: {error}"), + ) + })?; + *status = Some(completed); + Ok(completed) + } +} + +#[derive(Debug)] +struct SyntheticSharedChild { + state: Arc, + spawned_id: u32, +} + +impl ChildWrapper for SyntheticSharedChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn try_clone(&self) -> Option> { + Some(Box::new(Self { + state: Arc::clone(&self.state), + spawned_id: self.spawned_id, + })) + } + + fn id(&self) -> Option { + Some(self.spawned_id) + } + + fn spawned_id_layer(&self) -> Option { + Some(self.spawned_id) + } + + fn has_process_group_signal_layer(&self) -> bool { + true + } + + fn signal_process_group_layer( + &mut self, + _process_group: i32, + _signal: i32, + ) -> Option>> { + if let Some(acquisition) = self + .state + .signal_acquisition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + match self.state.status.try_lock() { + Err(std::sync::TryLockError::WouldBlock) => { + let _ = acquisition.send(()); + } + Ok(status) => { + drop(status); + panic!("signal unexpectedly acquired the shared status mutex"); + } + Err(std::sync::TryLockError::Poisoned(error)) => { + drop(error.into_inner()); + panic!("the shared status mutex was poisoned before signal acquisition"); + } + } + } + let status = self + .state + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(status) = *status { + Some(Ok(Some(status))) + } else { + self.state.group_signals.fetch_add(1, Ordering::SeqCst); + Some(Ok(None)) + } + } + + fn try_wait(&mut self) -> io::Result> { + Ok(*self + .state + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner)) + } + + fn wait(&mut self) -> Pin> + Send + '_>> { + let state = Arc::clone(&self.state); + Box::pin(async move { state.wait_under_custody() }) + } +} + +type SharedSignalResult = (Box, io::Result<()>); + +struct SharedSignalWorker { + result: Option>, + handle: Option>, +} + +struct SharedSignalCleanup { + completion: Option>, + status: ExitStatus, + waiter: Option>, + signal: Option, +} + +impl SharedSignalCleanup { + fn new( + completion: mpsc::Sender, + status: ExitStatus, + waiter: std::thread::JoinHandle<()>, + ) -> Self { + Self { + completion: Some(completion), + status, + waiter: Some(waiter), + signal: None, + } + } + + fn start_signal(&mut self, mut child: Box) { + assert!(self.signal.is_none(), "the signal worker starts once"); + let (result_tx, result_rx) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let result = child.signal(nix::libc::SIGCONT); + let _ = result_tx.send((child, result)); + }); + self.signal = Some(SharedSignalWorker { + result: Some(result_rx), + handle: Some(handle), + }); + } + + fn try_signal_result(&self) -> Result { + self.signal + .as_ref() + .expect("the signal worker is owned before inspection") + .result + .as_ref() + .expect("the signal result remains available") + .try_recv() + } + + fn receive_signal_result(&mut self) -> SharedSignalResult { + self.signal + .as_mut() + .expect("the signal worker is owned before collection") + .result + .take() + .expect("the signal result is collected once") + .recv_timeout(EXIT_TIMEOUT) + .expect("signal completes after wait releases custody") + } + + fn release(&mut self) { + if let Some(completion) = self.completion.take() { + let _ = completion.send(self.status); + } + } + + fn record_join( + handle: &mut Option>, + first_payload: &mut Option>, + ) { + if let Some(handle) = handle.take() + && let Err(payload) = handle.join() + { + if first_payload.is_none() { + *first_payload = Some(payload); + } else { + std::mem::forget(payload); + } + } + } + + fn join_all(&mut self) -> Option> { + let mut first_payload = None; + Self::record_join(&mut self.waiter, &mut first_payload); + if let Some(signal) = self.signal.as_mut() { + Self::record_join(&mut signal.handle, &mut first_payload); + } + first_payload + } + + fn finish(mut self) { + self.release(); + if let Some(payload) = self.join_all() { + std::panic::resume_unwind(payload); + } + } +} + +impl Drop for SharedSignalCleanup { + fn drop(&mut self) { + self.release(); + if let Some(payload) = self.join_all() { + std::mem::forget(payload); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shared_wait_and_group_signal_linearize_under_child_custody() { + let status = ExitStatus::from_raw(37 << 8); + let (wait_acquired_tx, wait_acquired_rx) = mpsc::channel(); + let (signal_acquisition_tx, signal_acquisition_rx) = mpsc::channel(); + let (completion_tx, completion_rx) = mpsc::channel(); + let state = Arc::new(SyntheticSharedState { + status: Mutex::new(None), + wait_acquired: Mutex::new(Some(wait_acquired_tx)), + signal_acquisition: Mutex::new(Some(signal_acquisition_tx)), + completion: Mutex::new(Some(completion_rx)), + group_signals: AtomicUsize::new(0), + }); + let mut command = Command::new("synthetic-child"); + command.wrap(ProcessGroup::leader()); + let child = command + .spawn_with_child({ + let state = Arc::clone(&state); + move |_| { + Ok(Box::new(SyntheticSharedChild { + state, + spawned_id: 42, + }) as Box) + } + }) + .unwrap(); + let mut lower = child + .inner() + .try_clone() + .expect("the shared custom child exposes a clone"); + let (waited_tx, waited_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("build synthetic waiter runtime"); + let result = runtime.block_on(lower.wait()); + let _ = waited_tx.send(result); + }); + let mut cleanup = SharedSignalCleanup::new(completion_tx, status, waiter); + wait_acquired_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("wait owns lower-child custody before signal starts"); + + cleanup.start_signal(child); + signal_acquisition_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("signal did not report its authoritative blocked status-lock attempt"); + assert!( + matches!(cleanup.try_signal_result(), Err(mpsc::TryRecvError::Empty)), + "signal returned after reporting blocked status acquisition" + ); + cleanup.release(); + let waited = waited_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("wait completes after explicit terminal release") + .expect("synthetic wait succeeds"); + let (mut child, signal_result) = cleanup.receive_signal_result(); + cleanup.finish(); + signal_result.unwrap(); + assert_eq!(waited, status); + assert_eq!(state.group_signals.load(Ordering::SeqCst), 0); + assert_eq!(child.wait().await.unwrap(), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); +} diff --git a/tests/readme/pty/.gitignore b/tests/readme/pty/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/tests/readme/pty/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tests/readme/pty/Cargo.lock b/tests/readme/pty/Cargo.lock new file mode 100644 index 0000000..6f9c0c9 --- /dev/null +++ b/tests/readme/pty/Cargo.lock @@ -0,0 +1,469 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "10.0.0" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "process-wrap-readme-pty" +version = "0.0.0" +dependencies = [ + "process-wrap", + "tokio", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] diff --git a/tests/readme/pty/Cargo.toml b/tests/readme/pty/Cargo.toml new file mode 100644 index 0000000..c7f6377 --- /dev/null +++ b/tests/readme/pty/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "process-wrap-readme-pty" +version = "0.0.0" +publish = false +edition = "2024" +rust-version = "1.88.0" + +[dependencies] +process-wrap = { path = "../../..", version = "10.0.0", features = ["pty"] } +tokio = { version = "1.38.2", features = ["io-util", "rt"] } diff --git a/tests/readme/pty/src/main.rs b/tests/readme/pty/src/main.rs new file mode 100644 index 0000000..41fc926 --- /dev/null +++ b/tests/readme/pty/src/main.rs @@ -0,0 +1,34 @@ +use process_wrap::tokio::{Command, Pty}; +use tokio::io::AsyncReadExt; + +async fn example() -> Result<(), Box> { + #[cfg(unix)] + let mut command = Command::with_new("sh", |command| { + command.args(["-c", "printf terminal"]); + }); + #[cfg(windows)] + let mut command = Command::with_new("cmd.exe", |command| { + command.args(["/d", "/s", "/c", "echo terminal"]); + }); + command.wrap(Pty::default()); + let mut child = command.spawn()?; + let controller = child + .take_pty_controller() + .expect("a successful PTY spawn installs one controller"); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + + let drain = tokio::spawn(async move { + let mut bytes = Vec::new(); + output.read_to_end(&mut bytes).await?; + Ok::<_, std::io::Error>(bytes) + }); + let status = child.wait().await?; + let terminal_bytes = drain.await??; + dbg!(status, terminal_bytes); + Ok(()) +} + +fn main() { + let _ = example; +} diff --git a/tests/readme/windows/.gitignore b/tests/readme/windows/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/tests/readme/windows/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tests/readme/windows/Cargo.lock b/tests/readme/windows/Cargo.lock new file mode 100644 index 0000000..b49cc44 --- /dev/null +++ b/tests/readme/windows/Cargo.lock @@ -0,0 +1,267 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "10.0.0" +dependencies = [ + "indexmap", + "nix", + "tracing", + "windows", +] + +[[package]] +name = "process-wrap-readme-windows" +version = "0.0.0" +dependencies = [ + "process-wrap", + "windows", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] diff --git a/tests/readme/windows/Cargo.toml b/tests/readme/windows/Cargo.toml new file mode 100644 index 0000000..f441a25 --- /dev/null +++ b/tests/readme/windows/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "process-wrap-readme-windows" +version = "0.0.0" +publish = false +edition = "2024" +rust-version = "1.88.0" + +[dependencies] +process-wrap = { path = "../../..", version = "10.0.0", features = ["std", "creation-flags", "job-object"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = ["Win32_System_Threading"] } diff --git a/tests/readme/windows/src/main.rs b/tests/readme/windows/src/main.rs new file mode 100644 index 0000000..08ee425 --- /dev/null +++ b/tests/readme/windows/src/main.rs @@ -0,0 +1,18 @@ +#[cfg(windows)] +fn example() -> Result<(), Box> { + use process_wrap::std::{Command, CreationFlags, JobObject}; + use windows::Win32::System::Threading::{CREATE_NO_WINDOW, DETACHED_PROCESS}; + + Command::with_new("watch", |command| { + command.arg("ls"); + }) + .wrap(CreationFlags(CREATE_NO_WINDOW | DETACHED_PROCESS)) + .wrap(JobObject) + .spawn()?; + Ok(()) +} + +fn main() { + #[cfg(windows)] + let _ = example(); +} diff --git a/tests/spawn_provider.rs b/tests/spawn_provider.rs index d23bbd9..94c9b0c 100644 --- a/tests/spawn_provider.rs +++ b/tests/spawn_provider.rs @@ -1,5 +1,115 @@ #![cfg(all(any(feature = "std", feature = "tokio1"), any(unix, windows)))] +#[path = "support/bounded_process.rs"] +mod bounded_process; + +#[cfg(feature = "tracing")] +mod lifecycle_tracing { + use std::{ + fmt, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + use tracing::{ + Event, Metadata, Subscriber, + field::{Field, Visit}, + span::{Attributes, Id, Record}, + }; + + #[derive(Debug)] + pub struct LifecyclePanic(pub Arc<()>); + + #[derive(Debug)] + pub struct PanicOnLifecycleEvent { + message: &'static str, + match_count: AtomicUsize, + panic_on_match: usize, + identity: Arc<()>, + } + + impl PanicOnLifecycleEvent { + pub fn dispatch( + message: &'static str, + panic_on_match: usize, + identity: Arc<()>, + ) -> tracing::Dispatch { + tracing::Dispatch::new(Self { + message, + match_count: AtomicUsize::new(0), + panic_on_match, + identity, + }) + } + } + + pub fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn with_dispatch(dispatch: &tracing::Dispatch, invoke: impl FnOnce() -> R) -> R { + tracing::dispatcher::with_default(dispatch, || { + tracing::callsite::rebuild_interest_cache(); + invoke() + }) + } + + #[derive(Default)] + struct MessageVisitor { + message: Option, + } + + impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + self.message = Some(format!("{value:?}")); + } + } + } + + impl Subscriber for PanicOnLifecycleEvent { + fn register_callsite( + &self, + _metadata: &'static Metadata<'static>, + ) -> tracing::subscriber::Interest { + tracing::subscriber::Interest::always() + } + + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + if visitor.message.as_deref() != Some(self.message) { + return; + } + let matched = self.match_count.fetch_add(1, Ordering::SeqCst) + 1; + if matched == self.panic_on_match { + std::panic::panic_any(LifecyclePanic(Arc::clone(&self.identity))); + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} + } +} + macro_rules! spawn_provider_tests { ( $module:ident, @@ -7,6 +117,11 @@ macro_rules! spawn_provider_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $child_wrapper_layer:path, + $child_wrapper_slots:path, + $pending_child_wrapper:path, + $prepared_child:path, + $prepared_child_ref:path, $provider_product:path, $spawn_provider:path, $runtime:expr @@ -19,16 +134,29 @@ macro_rules! spawn_provider_tests { panic::{AssertUnwindSafe, catch_unwind, panic_any}, sync::{ Arc, Mutex, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, thread::sleep, time::{Duration, Instant}, }; + #[cfg(windows)] + use std::{sync::mpsc, thread}; + + use super::bounded_process; use process_wrap::{CommandArg, SpawnTransaction}; use $child_wrapper as ChildWrapper; + #[cfg(windows)] + use $child_wrapper_layer as ChildWrapperLayer; + #[cfg(windows)] + use $child_wrapper_slots as ChildWrapperSlots; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; + #[cfg(windows)] + use $prepared_child as PreparedChild; + #[cfg(windows)] + use $prepared_child_ref as PreparedChildRef; use $provider_product as ProviderProduct; use $spawn_attempt as SpawnAttempt; use $spawn_provider as SpawnProvider; @@ -48,6 +176,14 @@ macro_rules! spawn_provider_tests { PeerWrap, Commit, Rollback, + #[cfg(windows)] + FinalizeSpawn, + #[cfg(windows)] + DisarmSpawnCleanup, + #[cfg(windows)] + DisarmNonOwner, + #[cfg(windows)] + DisarmOwner, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -56,6 +192,63 @@ macro_rules! spawn_provider_tests { Panic(&'static str), } + #[cfg(miri)] + #[derive(Debug)] + struct MiriRollbackPanicPayload; + + #[cfg(miri)] + impl Drop for MiriRollbackPanicPayload { + fn drop(&mut self) { + panic_any(()); + } + } + + #[derive(Debug)] + struct CommittedDropPayload(Arc<()>); + + #[derive(Debug)] + struct PanickingDropPayload { + #[cfg(not(miri))] + drops: Arc, + } + + impl PanickingDropPayload { + fn new(drops: &Arc) -> Self { + #[cfg(miri)] + { + let _ = drops; + Self {} + } + #[cfg(not(miri))] + { + Self { + drops: Arc::clone(drops), + } + } + } + } + + impl Drop for PanickingDropPayload { + fn drop(&mut self) { + #[cfg(not(miri))] + self.drops.fetch_add(1, Ordering::SeqCst); + panic_any("a secondary panic payload was dropped"); + } + } + + #[derive(Debug)] + enum RollbackBehavior { + Error, + Panic(PanickingDropPayload), + } + + #[derive(Debug)] + enum TransactionDropBehavior { + Record, + PanicCommitted(CommittedDropPayload), + PanicSecondary(PanickingDropPayload), + } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Event { Extend(&'static str, &'static str), @@ -68,13 +261,48 @@ macro_rules! spawn_provider_tests { Wrap(&'static str), Commit, Rollback, + ChildDrop, + #[cfg(windows)] + PreparedDrop(&'static str), + TransactionDrop, + #[cfg(windows)] + FinalizeSpawn(&'static str), + #[cfg(windows)] + DisarmSpawnCleanup(&'static str), + #[cfg(windows)] + DisarmNonOwner(&'static str), + #[cfg(windows)] + DisarmOwner(&'static str), + #[cfg(windows)] + OwnerDropped(&'static str, bool), + } + + #[cfg(windows)] + #[derive(Clone, Copy, Debug)] + struct FinalizationLayerSpec { + name: &'static str, + owns_job_cleanup: bool, } #[derive(Debug, Default)] struct Shared { events: Mutex>, failures: Mutex>, + rollback_behavior: Mutex>, + transaction_drop_behavior: Mutex>, + child_drop_payload: Mutex>, + commit_primary_failure: Mutex>, + #[cfg(windows)] + owner_primary_failure: Mutex>, + transaction_debug_calls: AtomicUsize, + panic_in_transaction_debug: AtomicBool, make_attempt_native_only: AtomicBool, + #[cfg(windows)] + use_resume_child: AtomicBool, + #[cfg(windows)] + resume_calls: AtomicUsize, + #[cfg(windows)] + finalization_layers: Mutex>, } impl Shared { @@ -90,6 +318,58 @@ macro_rules! spawn_provider_tests { self.events.lock().unwrap().clear(); } + fn set_rollback_behavior(&self, behavior: RollbackBehavior) { + *self.rollback_behavior.lock().unwrap() = Some(behavior); + } + + fn take_rollback_behavior(&self) -> Option { + self.rollback_behavior.lock().unwrap().take() + } + + fn set_transaction_drop_behavior(&self, behavior: TransactionDropBehavior) { + *self.transaction_drop_behavior.lock().unwrap() = Some(behavior); + } + + fn take_transaction_drop_behavior(&self) -> Option { + self.transaction_drop_behavior.lock().unwrap().take() + } + + fn set_child_drop_payload(&self, payload: PanickingDropPayload) { + *self.child_drop_payload.lock().unwrap() = Some(payload); + } + + fn take_child_drop_payload(&self) -> Option { + self.child_drop_payload.lock().unwrap().take() + } + + fn set_commit_primary_failure(&self, failure: PrimaryFailure) { + *self.commit_primary_failure.lock().unwrap() = Some(failure); + } + + fn take_commit_primary_failure(&self) -> Option { + self.commit_primary_failure.lock().unwrap().take() + } + + #[cfg(windows)] + fn set_owner_primary_failure(&self, failure: PrimaryFailure) { + *self.owner_primary_failure.lock().unwrap() = Some(failure); + } + + #[cfg(windows)] + fn take_owner_primary_failure(&self) -> Option { + self.owner_primary_failure.lock().unwrap().take() + } + + #[cfg(windows)] + fn set_finalization_layers(&self, layers: Vec) { + *self.finalization_layers.lock().unwrap() = layers; + } + + #[cfg(windows)] + fn finalization_layers(&self) -> Vec { + self.finalization_layers.lock().unwrap().clone() + } + fn fail_once(&self, point: Point, failure: Failure) { self.failures.lock().unwrap().push((point, failure)); } @@ -105,12 +385,33 @@ macro_rules! spawn_provider_tests { match failure { Some(Failure::Error(kind, message)) => Err(io::Error::new(kind, message)), - Some(Failure::Panic(message)) => panic_any(message), + Some(Failure::Panic(message)) => { + #[cfg(miri)] + if point == Point::Rollback { + let _ = message; + panic_any(MiriRollbackPanicPayload); + } + panic_any(message); + } None => Ok(()), } } } + fn successful_exit_status() -> std::process::ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(0) + } + } + #[derive(Debug)] struct CustomChild; @@ -126,736 +427,4639 @@ macro_rules! spawn_provider_tests { fn into_inner(self: Box) -> Box { self } - } - #[derive(Debug)] - struct Transaction(Arc); + fn try_clone(&self) -> Option> { + Some(Box::new(Self)) + } - impl SpawnTransaction for Transaction { - fn commit(&mut self) -> io::Result<()> { - self.0.event(Event::Commit); - self.0.fail(Point::Commit) + fn start_kill(&mut self) -> io::Result<()> { + Ok(()) } - fn rollback(&mut self) -> io::Result<()> { - self.0.event(Event::Rollback); - self.0.fail(Point::Rollback) + fn try_wait(&mut self) -> io::Result> { + Ok(Some(successful_exit_status())) } } + #[cfg(windows)] #[derive(Debug)] - struct Provider { - name: &'static str, - shared: Arc, - } + struct ResumeChild(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()); + #[cfg(windows)] + impl ChildWrapper for ResumeChild { + fn inner(&self) -> &dyn ChildWrapper { + self } - } - impl SpawnProvider for Provider { - fn check_available(&self) -> io::Result<()> { - self.shared.event(Event::Available(self.name)); - self.shared.fail(Point::Available) + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self } - 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 into_inner(self: Box) -> Box { + self } - 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 resume_after_job_assignment(&mut self) -> Option> { + self.0.resume_calls.fetch_add(1, Ordering::SeqCst); + Some(Ok(())) } - 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))), - )) + fn start_kill(&mut self) -> io::Result<()> { + Ok(()) + } + + fn try_wait(&mut self) -> io::Result> { + Ok(Some(successful_exit_status())) } } #[derive(Debug)] - struct ProviderWrapper { - name: &'static str, - provider: Provider, + struct PanickingChild { + shared: Arc, + payload: Option, } - impl ProviderWrapper { - fn new(name: &'static str, shared: Arc) -> Self { - Self { - name, - provider: Provider { name, shared }, - } + impl ChildWrapper for PanickingChild { + fn inner(&self) -> &dyn ChildWrapper { + self } - fn shared(&self) -> &Arc { - &self.provider.shared + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self } - 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()); + fn into_inner(self: Box) -> Box { + self } } - 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; + impl Drop for PanickingChild { + fn drop(&mut self) { + self.shared.event(Event::ChildDrop); + panic_any( + self.payload + .take() + .expect("the child destructor panics only once"), + ); } + } - fn 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(); + fn assert_provider_child(child: &dyn ChildWrapper) { + let id = child.type_id(); + let expected = id == TypeId::of::() || id == TypeId::of::(); + #[cfg(windows)] + let expected = expected || id == TypeId::of::(); + assert!(expected); + } + + #[cfg(windows)] + #[derive(Debug)] + struct PanickingPrepared { + shared: Arc, + name: &'static str, + payload: Option, + } + + #[cfg(windows)] + impl Drop for PanickingPrepared { + fn drop(&mut self) { + self.shared.event(Event::PreparedDrop(self.name)); + panic_any( + self.payload + .take() + .expect("the prepared-state destructor panics only once"), + ); + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct PreparedLayer { + inner: Option>, + prepared: Option, + } + + #[cfg(windows)] + impl PreparedLayer { + fn detached() -> Self { + Self { + inner: None, + prepared: None, } - 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) + #[cfg(windows)] + impl ChildWrapperLayer for PreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) } + } - 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) + #[cfg(windows)] + impl ChildWrapper for PreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed prepared layer owns its child") } - fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { - Some(&self.provider) + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed prepared layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed prepared layer owns its child") } } + #[cfg(windows)] + macro_rules! prepared_wrapper { + ($name:ident, $label:literal) => { + #[derive(Debug)] + struct $name { + shared: Arc, + payload: Mutex>, + } + + impl CommandWrapper for $name { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(self.payload.lock().unwrap().take().map(|payload| { + Box::new(PanickingPrepared { + shared: Arc::clone(&self.shared), + name: $label, + payload: Some(payload), + }) as Box + })) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + Ok(prepared.map(|_| PendingChildWrapper::new(PreparedLayer::detached()))) + } + } + }; + } + + #[cfg(windows)] + prepared_wrapper!(FirstPrepared, "first"); + #[cfg(windows)] + prepared_wrapper!(SecondPrepared, "second"); + + #[cfg(windows)] #[derive(Debug)] - struct Peer { - shared: Arc, - expect_provider: bool, - expect_custom_child: bool, + struct PreparedGuard(Arc); + + #[cfg(windows)] + impl Drop for PreparedGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } } - 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()); - } + #[cfg(windows)] + #[derive(Debug)] + struct OrderedPrepared { + events: Arc>>, + } + + #[cfg(windows)] + impl Drop for OrderedPrepared { + fn drop(&mut self) { + self.events.lock().unwrap().push("prepared"); } } - 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(()) + #[cfg(windows)] + #[derive(Debug)] + struct OrderedPreparedLayer { + inner: Option>, + prepared: Option, + events: Arc>>, + } + + #[cfg(windows)] + impl ChildWrapperLayer for OrderedPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared) } + } - fn post_spawn( + #[cfg(windows)] + impl ChildWrapper for OrderedPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed ordered layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed ordered layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed ordered layer owns its child") + } + } + + #[cfg(windows)] + impl Drop for OrderedPreparedLayer { + fn drop(&mut self) { + self.events.lock().unwrap().push("layer"); + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct OrderedPreparedWrapper { + events: Arc>>, + } + + #[cfg(windows)] + impl CommandWrapper for OrderedPreparedWrapper { + fn prepare_child( &mut self, _attempt: &mut SpawnAttempt, - child: &mut dyn ChildWrapper, - command: &CommandWrap, - ) -> io::Result<()> { - 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) + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(Some(Box::new(OrderedPrepared { + events: Arc::clone(&self.events), + }))) } - fn wrap_child( + fn wrap_prepared_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) + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!(prepared.as_ref().is_some_and(PreparedChildRef::is::)); + Ok(Some(PendingChildWrapper::new(OrderedPreparedLayer { + inner: None, + prepared: None, + events: Arc::clone(&self.events), + }))) } } + #[cfg(windows)] #[derive(Debug)] - struct OtherProviderWrapper(Provider); + struct OptionPreparedLayer { + inner: Option>, + prepared: Option, + } - impl CommandWrapper for OtherProviderWrapper { - fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { - Some(&self.0) + #[cfg(windows)] + impl OptionPreparedLayer { + fn detached() -> Self { + Self { + inner: None, + prepared: None, + } } } - fn runtime() -> Option { - $runtime + #[cfg(windows)] + impl ChildWrapperLayer for OptionPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::>(&mut self.prepared) + } } - fn command() -> CommandWrap { - #[cfg(unix)] - let mut command = CommandWrap::with_new("sh", |command| { - command.args(["-c", "exit 0"]); - }); + #[cfg(windows)] + impl ChildWrapper for OptionPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed option-prepared layer owns its child") + } - #[cfg(windows)] - let mut command = CommandWrap::with_new("cmd.exe", |command| { - command.args(["/D", "/S", "/C", "exit /b 0"]); - }); + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed option-prepared layer owns its child") + } - command.env("PROCESS_WRAP_PROVIDER_BASE", "set"); - command + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed option-prepared layer owns its child") + } } - fn configure_provider_intent(command: &mut CommandWrap) { - command - .env_clear() - .env("PROCESS_WRAP_PROVIDER_BASE", "set"); - #[cfg(windows)] - command.raw_arg(" provider-raw-fragment"); + #[cfg(windows)] + #[derive(Debug)] + struct OptionPrepared { + drops: Arc, } - 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 + #[cfg(windows)] + impl CommandWrapper for OptionPrepared { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(Some(Box::new(Some(PreparedGuard(Arc::clone(&self.drops)))))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!( + prepared + .as_ref() + .is_some_and(PreparedChildRef::is::>), + "the wrapping callback receives only matching type metadata" + ); + Ok(Some(PendingChildWrapper::new( + OptionPreparedLayer::detached(), + ))) + } } - 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, - ] + #[cfg(windows)] + #[derive(Debug)] + struct RetainInstalledPrepared { + steal_once: bool, + escaped: Mutex>, } - 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; + #[cfg(windows)] + impl CommandWrapper for RetainInstalledPrepared { + fn wrap_prepared_child( + &mut self, + child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!(prepared.is_none()); + if self.steal_once { + self.steal_once = false; + let layer = (child as &mut dyn std::any::Any) + .downcast_mut::() + .expect("the prepared layer is immediately inside this wrapper"); + let escaped = layer.prepared.take(); + assert!(escaped.is_some()); + *self + .escaped + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = escaped; + } + Ok(None) } - events.push(Event::Pre(name)); - if point == Point::Pre { - return events; + } + + + #[cfg(windows)] + #[derive(Debug, Default)] + struct ActivePreparedReaderSetupProbe { + started: AtomicUsize, + active: AtomicUsize, + joined: AtomicUsize, + } + + #[cfg(windows)] + struct ActivePreparedReaderActivity(Arc); + + #[cfg(windows)] + impl ActivePreparedReaderActivity { + fn new(probe: Arc) -> Self { + probe.active.fetch_add(1, Ordering::SeqCst); + Self(probe) } - events.push(Event::Pre("peer")); - if point == Point::ValidateAttempt { - events.push(Event::ValidateAttempt(name)); - return events; + } + + #[cfg(windows)] + impl Drop for ActivePreparedReaderActivity { + fn drop(&mut self) { + self.0.active.fetch_sub(1, Ordering::SeqCst); } - 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 - ); + #[cfg(windows)] + struct ActivePreparedReaderSetup { + release: Option>, + worker: Option>, + probe: Arc, + } + + #[cfg(windows)] + impl ActivePreparedReaderSetup { + fn new( + release: mpsc::Sender<()>, + worker: thread::JoinHandle, + probe: Arc, + ) -> Self { + probe.started.fetch_add(1, Ordering::SeqCst); + Self { + release: Some(release), + worker: Some(worker), + probe, } } + + fn install(mut self, reader: &ActivePreparedReader) { + *reader + .release + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.release.take(); + *reader + .worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.worker.take(); + } } - fn wait_for_exit(mut child: Box) { - let deadline = Instant::now() + EXIT_TIMEOUT; - loop { - if child.try_wait().unwrap().is_some() { - return; + #[cfg(windows)] + impl Drop for ActivePreparedReaderSetup { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + if let Some(worker) = self.worker.take() { + let result = worker.join(); + self.probe.joined.fetch_add(1, Ordering::SeqCst); + if let Err(payload) = result { + std::mem::forget(payload); + } } - assert!( - Instant::now() < deadline, - "child did not exit before timeout" - ); - sleep(Duration::from_millis(10)); } } + #[cfg(windows)] + #[derive(Debug)] + struct ActivePreparedReader { + entered: mpsc::Sender<()>, + release: Mutex>>, + worker: Mutex>>, + suppress_entered: bool, + setup_timeout: Duration, + setup_probe: Arc, + } + + #[cfg(windows)] + impl ActivePreparedReader { + fn new() -> (Arc, mpsc::Receiver<()>) { + Self::with_setup_failure(false) + } + + fn with_setup_failure( + suppress_entered: bool, + ) -> (Arc, mpsc::Receiver<()>) { + let (entered, observe_entered) = mpsc::channel(); + ( + Arc::new(Self { + entered, + release: Mutex::new(None), + worker: Mutex::new(None), + suppress_entered, + setup_timeout: if suppress_entered { + Duration::from_millis(100) + } else { + EXIT_TIMEOUT + }, + setup_probe: Arc::new(ActivePreparedReaderSetupProbe::default()), + }), + observe_entered, + ) + } + + fn start(&self, token: PreparedChild) { + let (active_tx, active_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let suppress_entered = self.suppress_entered; + let setup_probe = Arc::clone(&self.setup_probe); + let setup = ActivePreparedReaderSetup::new( + release_tx, + thread::spawn(move || { + let _activity = ActivePreparedReaderActivity::new(setup_probe); + token.with::, _>(|prepared| { + assert!(prepared.is_some()); + if !suppress_entered { + active_tx.send(()).expect( + "the wrapping callback observes active prepared access", + ); + } + release_rx.recv_timeout(EXIT_TIMEOUT).is_ok() + }) == Some(true) + }), + Arc::clone(&self.setup_probe), + ); + active_rx + .recv_timeout(self.setup_timeout) + .expect("the prepared reader enters before wrapping returns"); + setup.install(self); + let _ = self.entered.send(()); + } + + fn finish(&self) -> bool { + if let Some(release) = self + .release + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + let worker = self + .worker + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + match worker { + Some(worker) => { + let result = worker.join(); + self.setup_probe.joined.fetch_add(1, Ordering::SeqCst); + match result { + Ok(completed) => completed, + Err(payload) => std::panic::resume_unwind(payload), + } + } + None => true, + } + } + + fn assert_setup_worker_joined(&self) { + assert_eq!(self.setup_probe.started.load(Ordering::SeqCst), 1); + assert_eq!( + self.setup_probe.joined.load(Ordering::SeqCst), + 1, + "the started active reader is joined before setup failure returns" + ); + assert_eq!( + self.setup_probe.active.load(Ordering::SeqCst), + 0, + "no active prepared reader survives setup failure" + ); + } + } + + #[cfg(windows)] + impl Drop for ActivePreparedReader { + fn drop(&mut self) { + if let Some(release) = self + .release + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let _ = release.send(()); + } + if let Some(worker) = self + .worker + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + let result = worker.join(); + self.setup_probe.joined.fetch_add(1, Ordering::SeqCst); + if let Err(payload) = result { + std::mem::forget(payload); + } + } + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct AccessInstalledPrepared { + start_once: bool, + reader: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for AccessInstalledPrepared { + fn wrap_prepared_child( + &mut self, + child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!(prepared.is_none()); + if self.start_once { + self.start_once = false; + let layer = (child as &mut dyn std::any::Any) + .downcast_mut::() + .expect("the prepared layer is immediately inside this wrapper"); + let token = layer + .prepared + .take() + .expect("the earlier layer exposes its installed token"); + self.reader.start(token); + } + Ok(None) + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct FailAfterPreparedAccess { + fail_once: bool, + } + + #[cfg(windows)] + impl CommandWrapper for FailAfterPreparedAccess { + fn wrap_child( + &mut self, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result> { + if std::mem::take(&mut self.fail_once) { + Err(io::Error::other("later wrapping failed")) + } else { + Ok(None) + } + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct AlternatingPreparedLayer { + inner: Option>, + first: Option, + second: Option, + slot_calls: Arc, + } + + #[cfg(windows)] + impl ChildWrapperLayer for AlternatingPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + let call = self.slot_calls.fetch_add(1, Ordering::SeqCst); + let prepared = if call == 0 { + &mut self.first + } else { + if self.second.is_none() { + self.second = self.first.take(); + } + &mut self.second + }; + ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(prepared) + } + } + + #[cfg(windows)] + impl ChildWrapper for AlternatingPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed alternating layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed alternating layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed alternating layer owns its child") + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct AlternatingPrepared { + drops: Arc, + slot_calls: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for AlternatingPrepared { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + Ok(Some(Box::new(PreparedGuard(Arc::clone(&self.drops))))) + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + assert!( + prepared + .as_ref() + .is_some_and(PreparedChildRef::is::) + ); + Ok(Some(PendingChildWrapper::new(AlternatingPreparedLayer { + inner: None, + first: None, + second: None, + slot_calls: Arc::clone(&self.slot_calls), + }))) + } + } + + #[cfg(windows)] + #[derive(Clone, Copy, Debug)] + enum MalformedPreparedCase { + CallbackError, + CallbackPanic, + MissingSlot, + ExtraSlot, + WrongType, + InstallPanic, + } + + #[cfg(windows)] + #[derive(Debug)] + struct MalformedPreparedLayer { + inner: Option>, + prepared: Option, + case: MalformedPreparedCase, + } + + #[cfg(windows)] + impl ChildWrapperLayer for MalformedPreparedLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + match self.case { + MalformedPreparedCase::MissingSlot => ChildWrapperSlots::new(&mut self.inner), + MalformedPreparedCase::ExtraSlot => ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared), + MalformedPreparedCase::WrongType => ChildWrapperSlots::new(&mut self.inner) + .with_prepared::(&mut self.prepared), + MalformedPreparedCase::InstallPanic => { + panic_any("prepared layer installation failed") + } + MalformedPreparedCase::CallbackError | MalformedPreparedCase::CallbackPanic => { + unreachable!("callback failures do not return a layer") + } + } + } + } + + #[cfg(windows)] + impl ChildWrapper for MalformedPreparedLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed malformed-test layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed malformed-test layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed malformed-test layer owns its child") + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct MalformedPrepared { + next: Option, + active: Option, + drops: Arc, + } + + #[cfg(windows)] + impl CommandWrapper for MalformedPrepared { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + self.active = self.next.take(); + match self.active { + Some(MalformedPreparedCase::ExtraSlot) | None => Ok(None), + Some(_) => Ok(Some(Box::new(PreparedGuard(Arc::clone(&self.drops))))), + } + } + + fn wrap_prepared_child( + &mut self, + _child: &mut dyn ChildWrapper, + prepared: Option>, + _command: &CommandWrap, + ) -> io::Result> { + let Some(case) = self.active.take() else { + assert!(prepared.is_none()); + return Ok(None); + }; + if matches!(case, MalformedPreparedCase::ExtraSlot) { + assert!(prepared.is_none()); + } else { + assert!( + prepared + .as_ref() + .is_some_and(PreparedChildRef::is::) + ); + } + match case { + MalformedPreparedCase::CallbackError => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "prepared wrapping callback failed", + )), + MalformedPreparedCase::CallbackPanic => { + panic_any("prepared wrapping callback failed") + } + _ => Ok(Some(PendingChildWrapper::new(MalformedPreparedLayer { + inner: None, + prepared: None, + case, + }))), + } + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct FailPrepare(Mutex>); + + #[cfg(windows)] + impl CommandWrapper for FailPrepare { + fn prepare_child( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result>> { + let failure = { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + }; + match failure { + Some(PrimaryFailure::Error(identity)) => { + Err(io::Error::other(IdentityError(identity))) + } + Some(PrimaryFailure::Panic(identity)) => panic_any(PrimaryPanic(identity)), + None => Ok(None), + } + } + } + + #[cfg(windows)] + #[derive(Debug)] + struct FinalizationChild { + inner: Option>, + shared: Arc, + name: &'static str, + owns_job_cleanup: bool, + armed: bool, + } + + #[cfg(windows)] + impl ChildWrapper for FinalizationChild { + fn inner(&self) -> &dyn ChildWrapper { + self.inner.as_deref().expect("the child layer still owns its inner child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("the child layer still owns its inner child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("the child layer still owns its inner child") + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + self.shared.event(Event::FinalizeSpawn(self.name)); + self.shared.fail(Point::FinalizeSpawn) + } + + fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + self.shared.event(Event::DisarmSpawnCleanup(self.name)); + self.shared.fail(Point::DisarmSpawnCleanup) + } + + fn owns_job_object_cleanup_layer(&self) -> bool { + self.owns_job_cleanup + } + + fn disarm_job_object_layer(&mut self) -> io::Result<()> { + if self.owns_job_cleanup { + self.shared.event(Event::DisarmOwner(self.name)); + self.shared.fail(Point::DisarmOwner)?; + if let Some(failure) = self.shared.take_owner_primary_failure() { + match failure { + PrimaryFailure::Error(identity) => { + return Err(io::Error::other(IdentityError(identity))); + } + PrimaryFailure::Panic(identity) => panic_any(PrimaryPanic(identity)), + } + } + self.armed = false; + } else { + self.shared.event(Event::DisarmNonOwner(self.name)); + self.shared.fail(Point::DisarmNonOwner)?; + } + Ok(()) + } + } + + #[cfg(windows)] + impl FinalizationChild { + fn child_slot(&mut self) -> &mut Option> { + if self.inner.is_none() { + return &mut self.inner; + } + let inner = self + .inner + .as_deref_mut() + .expect("a prebuilt finalization chain has an inner layer"); + let inner = (inner as &mut dyn std::any::Any) + .downcast_mut::() + .expect("a detached finalization chain contains only finalization layers"); + inner.child_slot() + } + } + + #[cfg(windows)] + impl ChildWrapperLayer for FinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(self.child_slot()) + } + } + + #[cfg(windows)] + impl Drop for FinalizationChild { + fn drop(&mut self) { + if self.owns_job_cleanup { + self.shared.event(Event::OwnerDropped(self.name, self.armed)); + } + } + } + + #[cfg(windows)] + fn finalization_layer_chain(shared: Arc) -> Option { + shared + .finalization_layers() + .into_iter() + .rev() + .fold(None, |inner, layer| { + Some(FinalizationChild { + inner: inner.map(|inner| Box::new(inner) as Box), + shared: Arc::clone(&shared), + name: layer.name, + owns_job_cleanup: layer.owns_job_cleanup, + armed: layer.owns_job_cleanup, + }) + }) + } + + struct Transaction { + shared: Arc, + committed: bool, + } + + impl std::fmt::Debug for Transaction { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.shared.transaction_debug_calls.fetch_add(1, Ordering::SeqCst); + if self + .shared + .panic_in_transaction_debug + .load(Ordering::SeqCst) + { + panic_any("transaction Debug must remain private"); + } + formatter.debug_struct("Transaction").finish_non_exhaustive() + } + } + + impl Transaction { + fn new(shared: Arc) -> Self { + Self { + shared, + committed: false, + } + } + } + + impl SpawnTransaction for Transaction { + fn commit(&mut self) -> io::Result<()> { + self.shared.event(Event::Commit); + if let Some(failure) = self.shared.take_commit_primary_failure() { + match failure { + PrimaryFailure::Error(identity) => { + return Err(io::Error::other(IdentityError(identity))); + } + PrimaryFailure::Panic(identity) => panic_any(PrimaryPanic(identity)), + } + } + self.shared.fail(Point::Commit)?; + self.committed = true; + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + self.shared.event(Event::Rollback); + self.shared.fail(Point::Rollback)?; + match self.shared.take_rollback_behavior() { + Some(RollbackBehavior::Error) => Err(io::Error::other("rollback failed")), + Some(RollbackBehavior::Panic(payload)) => panic_any(payload), + None => Ok(()), + } + } + } + + impl Drop for Transaction { + fn drop(&mut self) { + let Some(behavior) = self.shared.take_transaction_drop_behavior() else { + return; + }; + self.shared.event(Event::TransactionDrop); + match behavior { + TransactionDropBehavior::Record => {} + TransactionDropBehavior::PanicCommitted(payload) => { + assert!(self.committed, "the transaction must commit before deferred disposal"); + panic_any(payload); + } + TransactionDropBehavior::PanicSecondary(payload) => panic_any(payload), + } + } + } + + #[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)?; + let child: Box = + if let Some(payload) = self.shared.take_child_drop_payload() { + Box::new(PanickingChild { + shared: Arc::clone(&self.shared), + payload: Some(payload), + }) + } else { + #[cfg(windows)] + if self.shared.use_resume_child.swap(false, Ordering::SeqCst) { + Box::new(ResumeChild(Arc::clone(&self.shared))) + } else { + Box::new(CustomChild) + } + #[cfg(not(windows))] + Box::new(CustomChild) + }; + Ok(ProviderProduct::new( + child, + Box::new(Transaction::new(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_provider_child(child); + self.shared().event(Event::Post(self.name)); + self.shared().fail(Point::Post) + } + + fn wrap_child( + &mut self, + child: &mut dyn ChildWrapper, + command: &CommandWrap, + ) -> io::Result> { + self.assert_hook_visibility(command); + assert_provider_child(child); + self.shared().event(Event::Wrap(self.name)); + self.shared().fail(Point::Wrap)?; + Ok(None) + } + + 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_provider_child(child); + } + self.shared.event(Event::Post("peer")); + self.shared.fail(Point::PeerPost) + } + + fn wrap_child( + &mut self, + child: &mut dyn ChildWrapper, + command: &CommandWrap, + ) -> io::Result> { + self.assert_hook_visibility(command); + if self.expect_custom_child { + assert_provider_child(child); + } + self.shared.event(Event::Wrap("peer")); + self.shared.fail(Point::PeerWrap)?; + #[cfg(windows)] + return Ok(finalization_layer_chain(Arc::clone(&self.shared)) + .map(PendingChildWrapper::new)); + #[cfg(not(windows))] + Ok(None) + } + } + + #[derive(Debug)] + struct IdentityError(Arc<()>); + + impl std::fmt::Display for IdentityError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("primary error") + } + } + + impl std::error::Error for IdentityError {} + + #[derive(Debug)] + struct PrimaryPanic(Arc<()>); + + #[derive(Debug)] + enum PrimaryFailure { + Error(Arc<()>), + Panic(Arc<()>), + } + + #[derive(Debug)] + struct PrimaryFailureWrapper(Mutex>); + + impl PrimaryFailureWrapper { + fn new(failure: PrimaryFailure) -> Self { + Self(Mutex::new(Some(failure))) + } + } + + impl CommandWrapper for PrimaryFailureWrapper { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result<()> { + let failure = self.0.lock().unwrap().take(); + match failure { + Some(PrimaryFailure::Error(identity)) => { + Err(io::Error::other(IdentityError(identity))) + } + Some(PrimaryFailure::Panic(identity)) => panic_any(PrimaryPanic(identity)), + None => Ok(()), + } + } + } + + #[derive(Debug)] + struct OtherProviderWrapper(Provider); + + 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 + } + + #[cfg(windows)] + fn take_retained_prepared(command: &CommandWrap) -> PreparedChild { + command + .get_wrap::() + .expect("the retaining wrapper remains registered") + .escaped + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("the retaining wrapper captured the installed token") + } + + #[cfg(windows)] + struct PreparedAccessCleanup { + releases: Vec>, + workers: Vec>, + } + + #[cfg(windows)] + impl PreparedAccessCleanup { + fn new() -> Self { + Self { + releases: Vec::new(), + workers: Vec::new(), + } + } + + fn release_all(&self) { + for release in &self.releases { + let _ = release.send(()); + } + } + + fn finish(mut self) { + self.release_all(); + let mut first_panic = None; + for worker in std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + if first_panic.is_none() { + first_panic = Some(payload); + } else { + std::mem::forget(payload); + } + } + } + if let Some(payload) = first_panic { + std::panic::resume_unwind(payload); + } + } + } + + #[cfg(windows)] + impl Drop for PreparedAccessCleanup { + fn drop(&mut self) { + self.release_all(); + for worker in std::mem::take(&mut self.workers) { + if let Err(payload) = worker.join() { + std::mem::forget(payload); + } + } + } + } + + #[cfg(windows)] + fn assert_active_prepared_access_blocks_destruction( + token: PreparedChild, + drops: Arc, + destroy: impl FnOnce() + Send + 'static, + ) { + let mut cleanup = PreparedAccessCleanup::new(); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (retry_tx, retry_rx) = mpsc::channel(); + let (inspection_tx, inspection_rx) = mpsc::channel(); + cleanup.releases.push(release_tx.clone()); + cleanup.releases.push(retry_tx.clone()); + cleanup.workers.push(thread::spawn(move || { + let completed = token + .with::, _>(|prepared| { + assert!(prepared.is_some()); + entered_tx + .send(()) + .expect("the test receives the active-access rendezvous"); + release_rx.recv_timeout(EXIT_TIMEOUT).is_ok() + }) + == Some(true); + let retry_requested = retry_rx.recv_timeout(EXIT_TIMEOUT).is_ok(); + let accessible_after_destruction = retry_requested + && token + .with::, _>(|prepared| prepared.is_some()) + .is_some(); + let _ = inspection_tx.send((completed, accessible_after_destruction)); + })); + + entered_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("prepared inspection entered after upgrading and locking state"); + let (started_tx, started_rx) = mpsc::channel(); + let (destroyed_tx, destroyed_rx) = mpsc::channel(); + cleanup.workers.push(thread::spawn(move || { + started_tx + .send(()) + .expect("the test observes destruction starting"); + destroy(); + let _ = destroyed_tx.send(()); + })); + started_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("destruction thread started"); + assert_eq!( + destroyed_rx.recv_timeout(Duration::from_millis(250)), + Err(mpsc::RecvTimeoutError::Timeout), + "authoritative destruction returned during immutable prepared access" + ); + assert_eq!( + drops.load(Ordering::SeqCst), + 0, + "prepared state dropped while immutably borrowed" + ); + + release_tx + .send(()) + .expect("release the finite prepared inspection"); + destroyed_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("destruction completes after prepared inspection release"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + retry_tx + .send(()) + .expect("request a post-destruction token access"); + let (completed, accessible_after_destruction) = inspection_rx + .recv_timeout(EXIT_TIMEOUT) + .expect("prepared inspection worker completed"); + assert!(completed, "the finite prepared inspection completed"); + assert!( + !accessible_after_destruction, + "a retained token reacquired prepared state after owner destruction" + ); + cleanup.finish(); + } + + fn provider_command_with_primary_failure( + shared: Arc, + failure: PrimaryFailure, + ) -> CommandWrap { + let mut command = provider_command(shared, "provider"); + command.wrap(PrimaryFailureWrapper::new(failure)); + command + } + + fn assert_primary_failure_preserved( + outcome: std::thread::Result>>, + identity: &Arc<()>, + was_panic: bool, + ) { + if was_panic { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => panic!("the primary panic must be resumed"), + }; + let payload = match payload.downcast::() { + Ok(payload) => payload, + Err(secondary) => { + std::mem::forget(secondary); + panic!("cleanup replaced the primary panic payload"); + } + }; + assert!(Arc::ptr_eq(&payload.0, identity)); + } else { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + panic!("cleanup replaced the primary error with a panic"); + } + }; + let error = result.expect_err("the primary error must be returned"); + let payload = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("cleanup must preserve the primary io::Error payload"); + assert!(Arc::ptr_eq(&payload.0, identity)); + } + } + + fn successful_events(name: &'static str) -> Vec { + vec![ + Event::Available(name), + 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, + ] + } + + #[cfg(windows)] + fn finalization_layer( + name: &'static str, + owns_job_cleanup: bool, + ) -> FinalizationLayerSpec { + FinalizationLayerSpec { + name, + owns_job_cleanup, + } + } + + #[cfg(windows)] + fn lifecycle_before_commit(name: &'static str) -> Vec { + let mut events = successful_events(name); + assert_eq!(events.pop(), Some(Event::Commit)); + events + } + + #[cfg(windows)] + fn successful_finalization_events( + name: &'static str, + layers: &[FinalizationLayerSpec], + ) -> Vec { + let mut events = lifecycle_before_commit(name); + events.extend(layers.iter().map(|layer| Event::FinalizeSpawn(layer.name))); + events.extend( + layers + .iter() + .map(|layer| Event::DisarmSpawnCleanup(layer.name)), + ); + events.extend( + layers + .iter() + .filter(|layer| !layer.owns_job_cleanup) + .map(|layer| Event::DisarmNonOwner(layer.name)), + ); + events.push(Event::Commit); + if let Some(owner) = layers.iter().find(|layer| layer.owns_job_cleanup) { + events.push(Event::DisarmOwner(owner.name)); + } + events + } + + fn expected_before(point: Point, name: &'static str) -> Vec { + let mut events = vec![Event::Available(name)]; + if point == Point::Available { + 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 bounded_test_process( + test_name: &str, + environment: (&str, &str), + timeout: Duration, + ) -> (std::process::Output, bool) { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", test_name, "--nocapture"]) + .env(environment.0, environment.1); + bounded_process::run(command, timeout, None) + .expect("run isolated lifecycle regression") + } + + 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] + #[cfg_attr(miri, ignore = "requires a native child process")] + 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.inner().type_id(), TypeId::of::()); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[cfg(windows)] + #[test] + fn committed_sidecar_delegates_post_transfer_exact_resume() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.use_resume_child.store(true, Ordering::SeqCst); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let mut child = command.spawn().expect("spawn provider child"); + assert_eq!(shared.resume_calls.load(Ordering::SeqCst), 0); + child + .resume_after_job_assignment() + .expect("the sidecar delegates an exact resume capability") + .expect("the delegated exact resume succeeds"); + assert_eq!(shared.resume_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn committed_transaction_residue_moves_with_the_child_and_supported_clones() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicCommitted( + CommittedDropPayload(Arc::clone(&identity)), + )); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let mut child = match outcome { + Ok(Ok(child)) => child, + Ok(Err(error)) => panic!("committed provider spawn failed: {error}"), + Err(payload) => { + let payload = payload + .downcast::() + .expect("the committed transaction supplied the panic payload"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert!(!shared.events().contains(&Event::Rollback)); + panic!("committed transaction residue was destroyed before child transfer"); + } + }; + + assert_eq!(shared.events(), successful_events("provider")); + assert_eq!(child.inner().type_id(), TypeId::of::()); + child.start_kill().expect("terminate returned child"); + let first = child + .try_wait() + .expect("reap returned child") + .expect("returned child is complete"); + assert_eq!(child.try_wait().expect("repeat child status"), Some(first)); + + let clone = child + .try_clone() + .expect("the child layer supports cloning"); + drop(child); + assert!(!shared.events().contains(&Event::TransactionDrop)); + let payload = catch_unwind(AssertUnwindSafe(|| drop(clone))) + .expect_err("the last child owner destroys the transaction residue"); + let payload = payload + .downcast::() + .expect("the exact deferred transaction payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(shared.events().last(), Some(&Event::TransactionDrop)); + assert!(!shared.events().contains(&Event::Rollback)); + + shared.clear_events(); + let mut child = command.spawn().expect("the command remains reusable"); + child.start_kill().expect("terminate reused child"); + assert!(child.try_wait().expect("reap reused child").is_some()); + drop(child); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn committed_sidecar_debug_never_formats_transaction_residue() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared + .panic_in_transaction_debug + .store(true, Ordering::SeqCst); + let mut command = provider_command(Arc::clone(&shared), "provider"); + let child = command.spawn().expect("spawn provider child"); + + let formatted = catch_unwind(AssertUnwindSafe(|| format!("{child:?}"))) + .expect("formatting a provider child must not inspect its transaction residue"); + assert!(formatted.contains("CustomChild")); + assert_eq!(shared.transaction_debug_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn consuming_the_provider_sidecar_disposes_ordinary_residue() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.set_transaction_drop_behavior(TransactionDropBehavior::Record); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let child = command.spawn().expect("spawn provider child"); + assert!(!shared.events().contains(&Event::TransactionDrop)); + let child = child.into_inner(); + assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(shared.events().last(), Some(&Event::TransactionDrop)); + } + + #[test] + fn provider_conflicts_precede_callbacks_and_allocation() { + let runtime = runtime(); + 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")); + } + } + } + + #[cfg(feature = "tracing")] + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn lifecycle_event_panics_roll_back_exactly_once_and_allow_reuse() { + let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TRACE_IDENTITY"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for event in ["post_spawn", "wrap_child"] { + let child_value = format!("{module}:{event}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::lifecycle_event_panics_roll_back_exactly_once_and_allow_reuse" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "lifecycle tracing identity exceeded its deadline"); + assert!( + output.status.success(), + "{event} tracing identity was not preserved:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let event = if selected + .as_deref() + .expect("the subprocess selects a lifecycle event") + .ends_with(":post_spawn") + { + "post_spawn" + } else { + "wrap_child" + }; + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = match outcome { + Err(payload) => payload, + Ok(result) => { + panic!("the {event} lifecycle event must resume its panic: {result:?}") + } + }; + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!( + shared + .events() + .iter() + .filter(|candidate| **candidate == Event::Rollback) + .count(), + 1, + "the armed transaction rolls back exactly once" + ); + + shared.clear_events(); + let child = super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the one-shot subscriber leaves the command reusable"); + drop(child); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[cfg(feature = "tracing")] + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn lifecycle_event_panics_quarantine_child_and_transaction_destructors() { + let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TRACE_CLEANUP_PANICS"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for event in ["post_spawn", "wrap_child"] { + let child_value = format!("{module}:{event}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::lifecycle_event_panics_quarantine_child_and_transaction_destructors" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "lifecycle tracing cleanup exceeded its deadline"); + assert!( + output.status.success(), + "{event} tracing cleanup did not preserve containment:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let event = if selected + .as_deref() + .expect("the subprocess selects a lifecycle event") + .ends_with(":post_spawn") + { + "post_spawn" + } else { + "wrap_child" + }; + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let child_payload_drops = Arc::new(AtomicUsize::new(0)); + let transaction_payload_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload::new(&child_payload_drops)); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( + PanickingDropPayload::new(&transaction_payload_drops), + )); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = outcome.expect_err("the lifecycle event must resume its panic"); + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(child_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(transaction_payload_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().contains(&Event::Rollback)); + assert!(shared.events().contains(&Event::TransactionDrop)); + assert!(shared.events().contains(&Event::ChildDrop)); + + shared.clear_events(); + drop( + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the command remains reusable"), + ); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[cfg(all(windows, feature = "tracing"))] + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn prepare_event_panic_quarantines_prepared_destructor_and_allows_reuse() { + let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_PREPARE_TRACE_PANIC"; + let module = stringify!($module); + if std::env::var_os(CHILD_ENV).as_deref() != Some(OsStr::new(module)) { + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::prepare_event_panic_quarantines_prepared_destructor_and_allows_reuse" + ), + (CHILD_ENV, module), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "prepare tracing cleanup exceeded its deadline"); + assert!( + output.status.success(), + "prepare tracing cleanup did not preserve containment:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let prepared_payload_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + "prepare_child", + 4, + Arc::clone(&identity), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(FirstPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload::new( + &prepared_payload_drops, + ))), + }) + .wrap(FailPrepare(Mutex::new(None))); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = outcome.expect_err("the prepare event must resume its panic"); + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(prepared_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!( + shared + .events() + .iter() + .filter(|candidate| **candidate == Event::Rollback) + .count(), + 1 + ); + + shared.clear_events(); + drop( + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the command remains reusable"), + ); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn rollback_failures_preserve_the_original_failure() { + let runtime = runtime(); + 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 rollback_errors_preserve_exact_primary_errors_and_panics() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for was_panic in [false, true] { + let shared = Arc::new(Shared::default()); + shared.set_rollback_behavior(RollbackBehavior::Error); + let identity = Arc::new(()); + let failure = if was_panic { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let mut command = + provider_command_with_primary_failure(Arc::clone(&shared), failure); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(shared.events().last(), Some(&Event::Rollback)); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + fn rollback_panic_payloads_are_quarantined_without_replacing_the_primary() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for was_panic in [false, true] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_rollback_behavior(RollbackBehavior::Panic( + PanickingDropPayload::new(&secondary_drops), + )); + let identity = Arc::new(()); + let failure = if was_panic { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let mut command = + provider_command_with_primary_failure(Arc::clone(&shared), failure); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert_eq!(shared.events().last(), Some(&Event::Rollback)); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + fn transaction_drop_panic_payloads_are_quarantined_after_rollback() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for was_panic in [false, true] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_transaction_drop_behavior( + TransactionDropBehavior::PanicSecondary(PanickingDropPayload::new( + &secondary_drops, + )), + ); + let identity = Arc::new(()); + let failure = if was_panic { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let mut command = + provider_command_with_primary_failure(Arc::clone(&shared), failure); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::TransactionDrop])); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn bounded_subprocess_reaps_the_timeout_path() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_TIMEOUT_HELPER"; + let child_value = stringify!($module); + if std::env::var_os(CHILD_ENV).as_deref() == Some(OsStr::new(child_value)) { + sleep(Duration::from_secs(30)); + return; + } + + let (output, timed_out) = bounded_test_process( + concat!(stringify!($module), "::bounded_subprocess_reaps_the_timeout_path"), + (CHILD_ENV, child_value), + Duration::ZERO, + ); + assert!(timed_out, "the zero-deadline seam must take the timeout branch"); + assert!(!output.status.success(), "the timed-out helper must be terminated"); + } + + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn rollback_and_transaction_unwinds_are_separated() { + const CHILD_ENV: &str = "PROCESS_WRAP_SEPARATE_PROVIDER_UNWINDS"; + let child_value = stringify!($module); + if std::env::var_os(CHILD_ENV).as_deref() != Some(OsStr::new(child_value)) { + let (output, timed_out) = bounded_test_process( + concat!(stringify!($module), "::rollback_and_transaction_unwinds_are_separated"), + (CHILD_ENV, child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "isolated lifecycle regression exceeded its deadline"); + assert!( + output.status.success(), + "rollback and transaction disposal overlapped:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let rollback_payload_drops = Arc::new(AtomicUsize::new(0)); + let transaction_payload_drops = Arc::new(AtomicUsize::new(0)); + shared.set_rollback_behavior(RollbackBehavior::Panic( + PanickingDropPayload::new(&rollback_payload_drops), + )); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( + PanickingDropPayload::new(&transaction_payload_drops), + )); + let identity = Arc::new(()); + let mut command = provider_command_with_primary_failure( + Arc::clone(&shared), + PrimaryFailure::Panic(Arc::clone(&identity)), + ); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, true); + assert_eq!(rollback_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(transaction_payload_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::TransactionDrop])); + } + + #[test] + fn child_destructor_panics_do_not_replace_post_spawn_or_commit_errors() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for fail_commit in [false, true] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload::new(&secondary_drops)); + let identity = Arc::new(()); + let mut command = if fail_commit { + shared.set_commit_primary_failure(PrimaryFailure::Error(Arc::clone(&identity))); + provider_command(Arc::clone(&shared), "provider") + } else { + provider_command_with_primary_failure( + Arc::clone(&shared), + PrimaryFailure::Error(Arc::clone(&identity)), + ) + }; + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, false); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::ChildDrop])); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + } + + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn child_destructor_panics_do_not_replace_post_spawn_or_commit_panics() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_CHILD_PANIC"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for phase in ["post", "commit"] { + let child_value = format!("{module}:{phase}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::child_destructor_panics_do_not_replace_post_spawn_or_commit_panics" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "isolated child cleanup exceeded its deadline"); + assert!( + output.status.success(), + "{phase} panic was not preserved across child cleanup:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let fail_commit = selected + .as_deref() + .expect("the subprocess selected a phase") + .ends_with(":commit"); + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload::new(&secondary_drops)); + let identity = Arc::new(()); + let mut command = if fail_commit { + shared.set_commit_primary_failure(PrimaryFailure::Panic(Arc::clone(&identity))); + provider_command(Arc::clone(&shared), "provider") + } else { + provider_command_with_primary_failure( + Arc::clone(&shared), + PrimaryFailure::Panic(Arc::clone(&identity)), + ) + }; + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, true); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[Event::Rollback, Event::ChildDrop])); + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn commit_failure_rolls_back_and_preserves_its_error() { + let runtime = runtime(); + 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")); + } + + #[cfg(windows)] + #[test] + fn option_prepared_state_stays_owned_until_the_returned_child_is_dropped() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command.wrap(OptionPrepared { + drops: Arc::clone(&drops), + }); + + let child = command.spawn().expect("spawn with prepared state"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + + let child = command.spawn().expect("reuse command with prepared state"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + + #[cfg(windows)] + #[test] + fn returned_child_drops_layers_before_stable_prepared_storage() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let events = Arc::new(Mutex::new(Vec::new())); + let mut command = if provider { + provider_command(shared, "provider") + } else { + command() + }; + command.wrap(OrderedPreparedWrapper { + events: Arc::clone(&events), + }); + + let child = command.spawn().expect("spawn with ordered prepared state"); + assert!(events.lock().unwrap().is_empty()); + drop(child); + assert_eq!(*events.lock().unwrap(), ["layer", "prepared"]); + } + } + + #[cfg(windows)] + #[test] + fn native_prepared_sidecar_preserves_traversal_and_consuming_extraction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let events = Arc::new(Mutex::new(Vec::new())); + let mut command = command(); + command.wrap(OrderedPreparedWrapper { + events: Arc::clone(&events), + }); + + let child = command.spawn().expect("spawn with a native prepared sidecar"); + assert_ne!( + child.type_id(), + TypeId::of::(), + "the private owner sidecar has its own top-level Any identity" + ); + assert_eq!( + child.inner().type_id(), + TypeId::of::(), + "the immediate application layer remains traversable" + ); + assert!(child.try_inner_child().is_some()); + + let inner = child.into_inner(); + assert!(inner.try_inner_child().is_some()); + assert_eq!(*events.lock().unwrap(), ["layer", "prepared"]); + drop(inner); + } + + #[cfg(windows)] + #[test] + fn retained_installed_token_cannot_extend_prepared_state_liveness() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("a retained installed token is non-owning"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + if provider { + assert_eq!(shared.events(), successful_events("provider")); + shared.clear_events(); + } + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + if provider { + assert_eq!(shared.events(), successful_events("provider")); + } + } + } + + #[cfg(windows)] + #[test] + fn active_reader_setup_failure_releases_and_joins_started_worker() { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let (reader, _entered) = ActivePreparedReader::with_setup_failure(true); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(AccessInstalledPrepared { + start_once: true, + reader: Arc::clone(&reader), + }); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let _payload = outcome.expect_err("the suppressed entered event must fail setup"); + reader.assert_setup_worker_joined(); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[cfg(windows)] + #[test] + fn active_reader_during_later_wrapping_is_not_an_escaped_owner() { + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let (reader, entered) = ActivePreparedReader::new(); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(AccessInstalledPrepared { + start_once: true, + reader: Arc::clone(&reader), + }); + let (spawned, observe_spawn) = mpsc::channel(); + let spawn_worker = thread::spawn(move || { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let result = command.spawn(); + let _ = spawned.send((command, result)); + }); + + entered + .recv_timeout(EXIT_TIMEOUT) + .expect("the later wrapper starts an admitted prepared reader"); + let first = observe_spawn.recv_timeout(Duration::from_secs(2)); + let returned_success_while_held = matches!(&first, Ok((_, Ok(_)))); + let reader_completed = reader.finish(); + let outcome = match first { + Ok(outcome) => outcome, + Err(mpsc::RecvTimeoutError::Timeout) => observe_spawn + .recv_timeout(EXIT_TIMEOUT) + .expect("spawn returns after emergency reader release"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = spawn_worker.join(); + panic!("the spawn worker disconnected before reporting its result") + } + }; + spawn_worker.join().expect("the spawn worker completes"); + assert!(reader_completed, "the admitted prepared callback completes"); + assert!( + returned_success_while_held, + "public spawn did not return successfully before the active reader was released" + ); + let (mut command, result) = outcome; + let child = result.expect( + "a sanctioned prepared reader may remain active across completed wrapping", + ); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn later_failure_waits_for_active_reader_and_preserves_primary_error() { + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let (reader, entered) = ActivePreparedReader::new(); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(AccessInstalledPrepared { + start_once: true, + reader: Arc::clone(&reader), + }) + .wrap(FailAfterPreparedAccess { fail_once: true }); + let (finished, observe_finish) = mpsc::channel(); + let spawn_worker = thread::spawn(move || { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let result = command.spawn(); + let _ = finished.send((command, result)); + }); + + entered + .recv_timeout(EXIT_TIMEOUT) + .expect("the later wrapper starts an admitted prepared reader"); + let early = observe_finish.recv_timeout(Duration::from_millis(100)); + let remained_pending = matches!(&early, Err(mpsc::RecvTimeoutError::Timeout)); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(reader.finish(), "the admitted prepared callback completes"); + assert!( + remained_pending, + "failure cleanup returned while prepared inspection remained active" + ); + let (mut command, result) = match early { + Ok(outcome) => outcome, + Err(mpsc::RecvTimeoutError::Timeout) => observe_finish + .recv_timeout(EXIT_TIMEOUT) + .expect("failure cleanup completes after reader release"), + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the spawn worker disconnected before reporting its result") + } + }; + spawn_worker.join().expect("the spawn worker completes"); + let error = result.expect_err("the later wrapping callback fails"); + assert_eq!(error.to_string(), "later wrapping failed"); + assert_eq!(drops.load(Ordering::SeqCst), 1); + if provider { + assert_eq!( + shared + .events() + .iter() + .filter(|event| **event == Event::Rollback) + .count(), + 1 + ); + shared.clear_events(); + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn active_prepared_access_blocks_returned_child_destruction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("spawn a child with externally inspected prepared state"); + let token = take_retained_prepared(&command); + assert_active_prepared_access_blocks_destruction( + token, + Arc::clone(&drops), + move || drop(child), + ); + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn active_prepared_access_blocks_consuming_extraction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("spawn a child for consuming extraction"); + let token = take_retained_prepared(&command); + assert_active_prepared_access_blocks_destruction( + token, + Arc::clone(&drops), + move || { + let child = child.into_inner(); + if provider { + drop(child.into_inner()); + } else { + drop(child); + } + }, + ); + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + + #[cfg(windows)] + #[test] + fn poisoned_prepared_access_preserves_exact_cleanup_and_extraction() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + for extract in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command + .wrap(OptionPrepared { + drops: Arc::clone(&drops), + }) + .wrap(RetainInstalledPrepared { + steal_once: true, + escaped: Mutex::new(None), + }); + + let child = command + .spawn() + .expect("spawn a child whose prepared mutex will be poisoned"); + let token = take_retained_prepared(&command); + let payload = catch_unwind(AssertUnwindSafe(|| { + let _ = token.with::, ()>(|_| { + panic_any("poison prepared access") + }); + })) + .expect_err("the prepared accessor poisons its private mutex"); + std::mem::forget(payload); + + if extract { + let child = child.into_inner(); + if provider { + drop(child.into_inner()); + } else { + drop(child); + } + } else { + drop(child); + } + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!( + token + .with::, _>(|prepared| prepared.is_some()) + .is_none(), + "poison recovery must not leave the prepared value accessible" + ); + + let child = command.spawn().expect("the command remains reusable"); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + } + } + + #[cfg(windows)] + #[test] + fn prepared_slot_accessors_are_not_reinvoked_after_installation() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for provider in [false, true] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let slot_calls = Arc::new(AtomicUsize::new(0)); + let mut command = if provider { + provider_command(Arc::clone(&shared), "provider") + } else { + command() + }; + command.wrap(AlternatingPrepared { + drops: Arc::clone(&drops), + slot_calls: Arc::clone(&slot_calls), + }); + + let child = command.spawn().expect("spawn with an alternating slot layer"); + assert_eq!( + slot_calls.load(Ordering::SeqCst), + 1, + "the slot accessor is used only for initial installation" + ); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + } + + #[cfg(windows)] + #[test] + fn malformed_prepared_layers_fail_atomically_and_allow_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for (case, expected, was_panic, expected_drops) in [ + ( + MalformedPreparedCase::CallbackError, + "prepared wrapping callback failed", + false, + 1, + ), + ( + MalformedPreparedCase::CallbackPanic, + "prepared wrapping callback failed", + true, + 1, + ), + ( + MalformedPreparedCase::MissingSlot, + "prepared child state requires an empty matching layer slot", + false, + 1, + ), + ( + MalformedPreparedCase::ExtraSlot, + "a prepared-state slot requires matching prepared child state", + false, + 0, + ), + ( + MalformedPreparedCase::WrongType, + "prepared child state does not match the layer slot type", + false, + 1, + ), + ( + MalformedPreparedCase::InstallPanic, + "prepared layer installation failed", + true, + 1, + ), + ] { + let shared = Arc::new(Shared::default()); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command.wrap(MalformedPrepared { + next: Some(case), + active: None, + drops: Arc::clone(&drops), + }); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + if was_panic { + let payload = outcome.expect_err("prepared installation must panic"); + assert_eq!( + *payload + .downcast::<&'static str>() + .expect("the exact installation panic is preserved"), + expected + ); + } else { + let error = outcome + .expect("prepared installation must return its error") + .expect_err("malformed prepared ownership must fail"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), expected); + } + assert_eq!(drops.load(Ordering::SeqCst), expected_drops); + let events = shared.events(); + assert!(!events.contains(&Event::Commit)); + assert_eq!( + events.iter().filter(|event| **event == Event::Rollback).count(), + 1 + ); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + assert_eq!(drops.load(Ordering::SeqCst), expected_drops); + } + } + + #[cfg(windows)] + #[test] + fn prepared_states_are_disposed_independently_after_the_primary_is_owned() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_PREPARED_PANIC"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for failure in ["error", "panic"] { + let child_value = format!("{module}:{failure}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::prepared_states_are_disposed_independently_after_the_primary_is_owned" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "prepared-state cleanup exceeded its deadline"); + assert!( + output.status.success(), + "prepared-state cleanup did not preserve the {failure}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let first_drops = Arc::new(AtomicUsize::new(0)); + let second_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let failure = if selected + .as_deref() + .expect("the subprocess selected a failure") + .ends_with(":panic") + { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let was_panic = matches!(&failure, PrimaryFailure::Panic(_)); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command + .wrap(FirstPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload::new(&first_drops))), + }) + .wrap(SecondPrepared { + shared: Arc::clone(&shared), + payload: Mutex::new(Some(PanickingDropPayload::new(&second_drops))), + }) + .wrap(FailPrepare(Mutex::new(Some(failure)))); + + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + assert_eq!(first_drops.load(Ordering::SeqCst), 0); + assert_eq!(second_drops.load(Ordering::SeqCst), 0); + assert!(shared.events().ends_with(&[ + Event::Rollback, + Event::PreparedDrop("first"), + Event::PreparedDrop("second"), + ])); + + shared.clear_events(); + drop(command.spawn().expect("the command remains reusable")); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[cfg(windows)] #[test] - #[cfg_attr(miri, ignore = "requires a native child process")] - fn native_fallback_runs_the_complete_lifecycle() { + fn precommit_child_hook_failures_roll_back_preserve_failure_and_allow_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for (point, failure_event) in [ + (Point::FinalizeSpawn, Event::FinalizeSpawn("layer")), + ( + Point::DisarmSpawnCleanup, + Event::DisarmSpawnCleanup("layer"), + ), + (Point::DisarmNonOwner, Event::DisarmNonOwner("layer")), + ] { + for failure in [ + Failure::Error(io::ErrorKind::Other, "finalization failed"), + Failure::Panic("finalization failed"), + ] { + let shared = Arc::new(Shared::default()); + let layers = vec![finalization_layer("layer", false)]; + shared.set_finalization_layers(layers.clone()); + shared.fail_once(point, failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "finalization failed"); + let events = shared.events(); + assert_eq!(events.last(), Some(&Event::Rollback)); + assert!(events.contains(&failure_event)); + assert!(!events.contains(&Event::Commit)); + assert!( + events + .windows(2) + .any(|events| events == [failure_event, Event::Rollback]), + "the failing hook must be followed by provider rollback: {events:?}" + ); + + shared.clear_events(); + let child = command.spawn().unwrap(); + drop(child); + assert_eq!( + shared.events(), + successful_finalization_events("provider", &layers) + ); + } + } + } + + #[cfg(windows)] + #[test] + fn provider_commit_separates_nonowners_from_the_sole_final_owner() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for layers in [ + vec![ + finalization_layer("outer", false), + finalization_layer("inner", false), + ], + vec![ + finalization_layer("outer", false), + finalization_layer("owner", true), + finalization_layer("inner", false), + ], + ] { + let shared = Arc::new(Shared::default()); + shared.set_finalization_layers(layers.clone()); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let child = command.spawn().unwrap(); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + if let Some(owner) = layers.iter().find(|layer| layer.owns_job_cleanup) { + expected.push(Event::OwnerDropped(owner.name, false)); + } + assert_eq!(shared.events(), expected); + } + } + + #[cfg(windows)] + #[test] + fn multiple_final_owners_fail_and_roll_back_before_commit() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); let shared = Arc::new(Shared::default()); - let mut command = command(); - command.wrap(Peer { + let layers = vec![ + finalization_layer("outer-owner", true), + finalization_layer("nonowner", false), + finalization_layer("inner-owner", true), + ]; + shared.set_finalization_layers(layers); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let error = command.spawn().expect_err("multiple owners must fail"); + assert_eq!( + error.to_string(), + "multiple child layers own JobObject cleanup" + ); + let events = shared.events(); + assert!(!events.contains(&Event::Commit)); + assert!(events.contains(&Event::Rollback)); + assert!(events.contains(&Event::DisarmNonOwner("nonowner"))); + assert!(events.ends_with(&[ + Event::Rollback, + Event::OwnerDropped("outer-owner", true), + Event::OwnerDropped("inner-owner", true), + ])); + } + + #[cfg(windows)] + #[test] + fn final_owner_failures_leave_the_owner_armed_and_preserve_the_failure() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for failure in [ + Failure::Error(io::ErrorKind::Other, "owner disarm failed"), + Failure::Panic("owner disarm failed"), + ] { + let shared = Arc::new(Shared::default()); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + shared.fail_once(Point::DisarmOwner, failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "owner disarm failed"); + let events = shared.events(); + assert!(events.contains(&Event::Commit)); + assert!(events.contains(&Event::DisarmOwner("owner"))); + assert_eq!(events.last(), Some(&Event::OwnerDropped("owner", true))); + assert!(!events.contains(&Event::Rollback)); + + shared.clear_events(); + let child = command.spawn().unwrap(); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + expected.push(Event::OwnerDropped("owner", false)); + assert_eq!(shared.events(), expected); + } + } + + #[cfg(windows)] + #[test] + fn final_owner_failures_preserve_the_owner_failure_over_residue_disposal() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for failure in [ + Failure::Error(io::ErrorKind::Other, "owner disarm failed"), + Failure::Panic("owner disarm failed"), + ] { + let shared = Arc::new(Shared::default()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + shared.fail_once(Point::DisarmOwner, failure); + shared.set_transaction_drop_behavior( + TransactionDropBehavior::PanicSecondary(PanickingDropPayload::new( + &secondary_drops, + )), + ); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure(&mut command, failure, "owner disarm failed"); + let events = shared.events(); + assert!(events.contains(&Event::Commit)); + assert!(events.contains(&Event::DisarmOwner("owner"))); + assert!(events.ends_with(&[ + Event::TransactionDrop, + Event::OwnerDropped("owner", true), + ])); + assert!(!events.contains(&Event::Rollback)); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + + shared.clear_events(); + let child = command.spawn().expect("the command remains reusable"); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + expected.push(Event::OwnerDropped("owner", false)); + assert_eq!(shared.events(), expected); + } + } + + #[cfg(windows)] + #[test] + fn final_owner_failure_survives_child_prepared_and_residue_panics() { + const CHILD_ENV: &str = "PROCESS_WRAP_PROVIDER_FINAL_OWNER_CLEANUP_PANICS"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected.as_deref().is_none_or(|value| !value.starts_with(module)) { + for failure in ["error", "panic"] { + let child_value = format!("{module}:{failure}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::final_owner_failure_survives_child_prepared_and_residue_panics" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!(!timed_out, "final-owner cleanup exceeded its deadline"); + assert!( + output.status.success(), + "cleanup replaced the final-owner {failure}:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let child_payload_drops = Arc::new(AtomicUsize::new(0)); + let prepared_payload_drops = Arc::new(AtomicUsize::new(0)); + let residue_payload_drops = Arc::new(AtomicUsize::new(0)); + shared.set_child_drop_payload(PanickingDropPayload::new(&child_payload_drops)); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicSecondary( + PanickingDropPayload::new(&residue_payload_drops), + )); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + let identity = Arc::new(()); + let failure = if selected + .as_deref() + .expect("the subprocess selected a failure") + .ends_with(":panic") + { + PrimaryFailure::Panic(Arc::clone(&identity)) + } else { + PrimaryFailure::Error(Arc::clone(&identity)) + }; + let was_panic = matches!(&failure, PrimaryFailure::Panic(_)); + shared.set_owner_primary_failure(failure); + let mut command = provider_command(Arc::clone(&shared), "provider"); + command.wrap(FirstPrepared { shared: Arc::clone(&shared), - expect_provider: false, - expect_custom_child: false, + payload: Mutex::new(Some(PanickingDropPayload::new(&prepared_payload_drops))), }); - wait_for_exit(command.spawn().unwrap()); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + assert_primary_failure_preserved(outcome, &identity, was_panic); + let events = shared.events(); + assert_eq!(events.iter().filter(|event| **event == Event::Commit).count(), 1); + assert!(!events.contains(&Event::Rollback)); + assert!(events.ends_with(&[ + Event::Commit, + Event::DisarmOwner("owner"), + Event::TransactionDrop, + Event::OwnerDropped("owner", true), + Event::ChildDrop, + Event::PreparedDrop("first"), + ])); + assert_eq!(child_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(prepared_payload_drops.load(Ordering::SeqCst), 0); + assert_eq!(residue_payload_drops.load(Ordering::SeqCst), 0); + + shared.clear_events(); + let child = command.spawn().expect("the command remains reusable"); + drop(child); + let mut expected = successful_finalization_events("provider", &layers); + expected.push(Event::OwnerDropped("owner", false)); + assert_eq!(shared.events(), expected); + } + + #[cfg(windows)] + #[test] + fn final_owner_disarms_before_committed_residue_transfer() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + let identity = Arc::new(()); + let layers = vec![finalization_layer("owner", true)]; + shared.set_finalization_layers(layers.clone()); + shared.set_transaction_drop_behavior(TransactionDropBehavior::PanicCommitted( + CommittedDropPayload(Arc::clone(&identity)), + )); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + let child = command.spawn().expect("spawn provider child"); assert_eq!( shared.events(), - vec![Event::Pre("peer"), Event::Post("peer"), Event::Wrap("peer")] + successful_finalization_events("provider", &layers) + ); + let payload = catch_unwind(AssertUnwindSafe(|| drop(child))) + .expect_err("child disposal destroys the committed residue"); + let payload = payload + .downcast::() + .expect("the exact deferred transaction payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!( + shared.events().as_slice(), + &[ + successful_finalization_events("provider", &layers).as_slice(), + &[Event::OwnerDropped("owner", false), Event::TransactionDrop], + ] + .concat() ); } #[test] - fn provider_runs_the_exact_lifecycle_for_a_custom_child() { + fn commit_panic_rolls_back_preserves_payload_and_allows_reuse() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let shared = Arc::new(Shared::default()); + shared.fail_once(Point::Commit, Failure::Panic("commit failed")); + let mut command = provider_command(Arc::clone(&shared), "provider"); + + assert_failure( + &mut command, + Failure::Panic("commit failed"), + "commit failed", + ); + let mut expected = successful_events("provider"); + expected.push(Event::Rollback); + assert_eq!(shared.events(), expected); + + shared.clear_events(); + let _child = command.spawn().unwrap(); + assert_eq!(shared.events(), successful_events("provider")); + } + + #[test] + fn successful_provider_can_be_reused() { let runtime = runtime(); 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")); + 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(feature = "std")] +macro_rules! std_shared_process_methods { + () => { + fn id(&self) -> u32 { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .id() + } + + fn wait(&mut self) -> std::io::Result { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .wait() + } + }; +} + +#[cfg(feature = "tokio1")] +macro_rules! tokio_shared_process_methods { + () => { + fn id(&self) -> Option { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .id() + } + + fn wait( + &mut self, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + let child = std::sync::Arc::clone(&self.child); + Box::pin(std::future::poll_fn(move |context| { + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut wait = child.wait(); + std::future::Future::poll(wait.as_mut(), context) + })) + } + }; +} + +#[cfg(feature = "std")] +macro_rules! std_wait_for_child { + ($runtime:expr, $child:expr) => {{ + let _ = &$runtime; + $child.wait() + }}; +} + +#[cfg(feature = "tokio1")] +macro_rules! tokio_wait_for_child { + ($runtime:expr, $child:expr) => { + $runtime + .as_ref() + .expect("the Tokio frontend has a runtime") + .block_on($child.wait()) + }; +} + +#[cfg(feature = "std")] +macro_rules! std_kill_child { + ($runtime:expr, $child:expr) => {{ + let _ = &$runtime; + $child.kill() + }}; +} + +#[cfg(feature = "tokio1")] +macro_rules! tokio_kill_child { + ($runtime:expr, $child:expr) => { + $runtime + .as_ref() + .expect("the Tokio frontend has a runtime") + .block_on(async { Box::into_pin($child.kill()).await }) + }; +} + +macro_rules! provider_capability_tests { + ( + $module:ident, + $command_wrap:path, + $spawn_attempt:path, + $command_wrapper:path, + $child_wrapper:path, + $child_wrapper_layer:path, + $child_wrapper_slots:path, + $pending_child_wrapper:path, + $provider_product:path, + $spawn_provider:path, + $native_command:path, + $native_child:path, + $wait_for_child:ident, + $kill_child:ident, + $runtime:expr + ) => { + mod $module { + use std::{ + any::TypeId, + io, + process::Stdio, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use process_wrap::SpawnTransaction; + use $child_wrapper as ChildWrapper; + use $child_wrapper_layer as ChildWrapperLayer; + use $child_wrapper_slots as ChildWrapperSlots; + use $command_wrap as CommandWrap; + use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; + use $provider_product as ProviderProduct; + use $spawn_attempt as SpawnAttempt; + use $spawn_provider as SpawnProvider; + + #[derive(Debug)] + struct CompletedTransaction { + drops: Arc, + committed: bool, + } + + impl SpawnTransaction for CompletedTransaction { + fn commit(&mut self) -> io::Result<()> { + self.committed = true; + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl Drop for CompletedTransaction { + fn drop(&mut self) { + assert!( + self.committed, + "the completed-child transaction must commit" + ); + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[derive(Debug)] + struct Provider { + drops: Arc, + wait_for_completion: bool, + } + + impl SpawnProvider for Provider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result { + let mut command = <$native_command>::new(std::env::current_exe()?); + let child_test = if self.wait_for_completion { + concat!(stringify!($module), "::completed_child_process") + } else { + concat!(stringify!($module), "::live_child_process") + }; + command + .args(["--exact", child_test, "--nocapture"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if !self.wait_for_completion { + command.env("PROCESS_WRAP_LIVE_CAPABILITY_CHILD", "1"); + } + let mut child = command.spawn()?; + if self.wait_for_completion { + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "capability child did not exit", + )); + } + sleep(Duration::from_millis(5)); + } + } + Ok(ProviderProduct::new( + Box::new(child), + Box::new(CompletedTransaction { + drops: Arc::clone(&self.drops), + committed: false, + }), + )) + } + } + + #[derive(Debug)] + struct ProviderWrapper(Provider); + + impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } + } + + #[derive(Debug)] + struct OuterLayer(Option>); + + impl ChildWrapperLayer for OuterLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } + + impl ChildWrapper for OuterLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.0 + .as_deref() + .expect("an installed outer layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.0 + .as_deref_mut() + .expect("an installed outer layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.0 + .take() + .expect("an installed outer layer owns its child") + } + + #[cfg(windows)] + fn process_handle(&self) -> Option> { + self.inner().process_handle() + } + } + + #[derive(Debug)] + struct OuterWrapper; + + impl CommandWrapper for OuterWrapper { + fn wrap_child( + &mut self, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result> { + Ok(Some(PendingChildWrapper::new(OuterLayer(None)))) + } + } + + fn runtime() -> Option { + $runtime } - #[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), - })); + fn command_with_completion( + drops: Arc, + wait_for_completion: bool, + ) -> CommandWrap { + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(ProviderWrapper(Provider { + drops, + wait_for_completion, + })) + .wrap(OuterWrapper); + command + } - 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()); + fn command(drops: Arc) -> CommandWrap { + command_with_completion(drops, true) } #[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, - }); + fn completed_child_process() {} - 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 live_child_process() { + if std::env::var_os("PROCESS_WRAP_LIVE_CAPABILITY_CHILD").is_some() { + sleep(Duration::from_secs(30)); + } } #[test] - fn availability_error_precedes_native_only_rejection() { + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_sidecar_delegates_the_complete_native_child_contract() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - let shared = Arc::new(Shared::default()); - shared.fail_once( - Point::Available, - Failure::Error(io::ErrorKind::Unsupported, "provider unavailable"), + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = command(Arc::clone(&drops)); + + let mut child = command.spawn().expect("spawn completed provider child"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert_eq!(child.inner().type_id(), TypeId::of::()); + assert_eq!( + child.inner().inner().type_id(), + TypeId::of::<$native_child>() ); - let mut command = provider_command(Arc::clone(&shared), "provider"); - let _ = command.native_mut(); + assert!(child.stdin().is_some()); + assert!(child.stdout().is_some()); + assert!(child.stderr().is_some()); + let native_id = child + .try_inner_child() + .expect("traverse to immutable native child") + .id(); + assert_eq!(child.id(), native_id); + // SAFETY: the process is complete and the forwarding layer owns no supervision state. + assert!(unsafe { child.try_inner_child_mut() }.is_some()); + let first = $wait_for_child!(runtime, child).expect("first repeated wait"); + let second = $wait_for_child!(runtime, child).expect("second repeated wait"); + assert_eq!(first, second); + assert_eq!(child.try_wait().expect("repeat try_wait"), Some(first)); + assert!(child.try_clone().is_none()); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); - 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")]); + let child = command.spawn().expect("spawn child for native extraction"); + // SAFETY: the process is complete, the transaction has no cleanup resources after commit, + // and the forwarding layer owns no supervision state. + let child = unsafe { child.try_into_inner_child() }; + let mut child = child.expect("extract native child"); + assert_eq!(drops.load(Ordering::SeqCst), 2); + let first = $wait_for_child!(runtime, child).expect("wait extracted native child"); + let second = $wait_for_child!(runtime, child).expect("repeat extracted child wait"); + assert_eq!(first, second); } #[test] - fn provider_rejects_native_only_base_after_availability() { + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_sidecar_delegates_direct_kill_and_repeated_waits() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - let shared = Arc::new(Shared::default()); - let mut command = provider_command(Arc::clone(&shared), "provider"); - let _ = command.native_mut(); + let drops = Arc::new(AtomicUsize::new(0)); + let mut command = command_with_completion(Arc::clone(&drops), false); - let error = command - .spawn() - .expect_err("native-only state must be rejected"); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + let mut child = command.spawn().expect("spawn live provider child"); + $kill_child!(runtime, child).expect("kill and reap live provider child"); + let first = $wait_for_child!(runtime, child).expect("repeat killed child wait"); + let second = $wait_for_child!(runtime, child).expect("second killed child wait"); + assert_eq!(first, second); assert_eq!( - error.to_string(), - "a spawn provider cannot use a native-only command" + child.try_wait().expect("repeat killed child try_wait"), + Some(first) ); - assert_eq!(shared.events(), vec![Event::Available("provider")]); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); } + #[cfg(windows)] #[test] - fn immutable_validation_precedes_hooks_and_is_reusable() { + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_sidecar_delegates_a_live_process_handle() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - let 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 drops = Arc::new(AtomicUsize::new(0)); + let mut command = command_with_completion(Arc::clone(&drops), false); - let error = command.spawn().expect_err("command validation must fail"); - assert_eq!(error.to_string(), "invalid command"); + let mut child = command.spawn().expect("spawn live provider child"); + assert_eq!(drops.load(Ordering::SeqCst), 0); + assert!(child.try_process_handle().is_some()); + child.start_kill().expect("terminate live provider child"); + let first = $wait_for_child!(runtime, child).expect("reap live provider child"); + let second = $wait_for_child!(runtime, child).expect("repeat live child wait"); + assert_eq!(first, second); assert_eq!( - shared.events(), - vec![ - Event::Available("provider"), - Event::ValidateCommand("provider") - ] + child.try_wait().expect("repeat live child try_wait"), + Some(first) ); + drop(child); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + } + }; +} - shared.clear_events(); - let _child = command.spawn().unwrap(); - assert_eq!(shared.events(), successful_events("provider")); +macro_rules! real_provider_tests { + ( + $module:ident, + $command_wrap:path, + $spawn_attempt:path, + $command_wrapper:path, + $child_wrapper:path, + $provider_product:path, + $spawn_provider:path, + $native_command:path, + $shared_process_methods:ident, + $wait_for_child:ident, + $runtime:expr + ) => { + mod $module { + use std::{ + fs, io, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + thread::sleep, + time::{Duration, Instant}, + }; + + use process_wrap::SpawnTransaction; + use $child_wrapper as ChildWrapper; + use $command_wrap as CommandWrap; + use $command_wrapper as CommandWrapper; + use $provider_product as ProviderProduct; + use $spawn_attempt as SpawnAttempt; + use $spawn_provider as SpawnProvider; + + const EXIT_TIMEOUT: Duration = Duration::from_secs(5); + const MARKER_ENV: &str = "PROCESS_WRAP_PROVIDER_MARKER_DIR"; + + #[derive(Clone, Debug)] + struct MarkerPaths { + directory: PathBuf, + ready: PathBuf, + go: PathBuf, + marker: PathBuf, } - #[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"); + impl MarkerPaths { + fn new(directory: &Path) -> Self { + Self { + directory: directory.to_owned(), + ready: directory.join("ready"), + go: directory.join("go"), + marker: directory.join("marker"), + } + } + } - 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") - ] - ); + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Event { + Commit, + Rollback, + TransactionDrop, + } - shared.clear_events(); - let _child = command.spawn().unwrap(); - assert_eq!(shared.events(), successful_events("provider")); + #[derive(Debug)] + struct DeferredPayload(Arc<()>); + + #[derive(Debug)] + struct PanickingDropPayload(Arc); + + impl Drop for PanickingDropPayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a secondary process-transaction payload was dropped"); + } + } + + #[derive(Debug)] + enum DropBehavior { + PanicCommitted(DeferredPayload), + PanicSecondary(PanickingDropPayload), + } + + #[derive(Debug, Default)] + struct Shared { + events: Mutex>, + paths: Mutex>, + last_child: Mutex>>>>, + drop_behavior: Mutex>, + wait_for_ready_before_return: std::sync::atomic::AtomicBool, + } + + impl Shared { + fn event(&self, event: Event) { + self.events.lock().unwrap().push(event); + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + fn set_paths(&self, paths: MarkerPaths) { + *self.paths.lock().unwrap() = Some(paths); + } + + fn paths(&self) -> MarkerPaths { + self.paths + .lock() + .unwrap() + .clone() + .expect("the process fixture has marker paths") + } + + fn set_drop_behavior(&self, behavior: DropBehavior) { + *self.drop_behavior.lock().unwrap() = Some(behavior); + } + + fn take_drop_behavior(&self) -> Option { + self.drop_behavior.lock().unwrap().take() + } + + fn child(&self) -> Arc>> { + Arc::clone( + self.last_child + .lock() + .unwrap() + .as_ref() + .expect("the provider published its process child"), + ) + } + + fn clear_child(&self) { + self.last_child.lock().unwrap().take(); + } + } + + #[derive(Debug)] + struct SharedProcessChild { + child: Arc>>, + } + + impl ChildWrapper for SharedProcessChild { + fn inner(&self) -> &dyn ChildWrapper { + self + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self + } + + fn into_inner(self: Box) -> Box { + self + } + + fn start_kill(&mut self) -> io::Result<()> { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .start_kill() + } + + fn try_wait(&mut self) -> io::Result> { + self.child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + } + + $shared_process_methods!(); + } + + #[derive(Debug)] + struct Transaction { + cleanup: Option>>>, + shared: Arc, + committed: bool, + drop_behavior: Option, + } + + impl Transaction { + fn terminate_and_reap(&mut self) -> io::Result<()> { + let Some(child) = self.cleanup.take() else { + return Ok(()); + }; + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match child.start_kill() { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::InvalidInput => {} + Err(error) => return Err(error), + } + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if child.try_wait()?.is_some() { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "provider rollback did not reap its child", + )); + } + sleep(Duration::from_millis(5)); + } + } + } + + impl SpawnTransaction for Transaction { + fn commit(&mut self) -> io::Result<()> { + self.shared.event(Event::Commit); + self.cleanup.take(); + self.committed = true; + Ok(()) + } + + fn rollback(&mut self) -> io::Result<()> { + self.shared.event(Event::Rollback); + self.terminate_and_reap() + } + } + + impl Drop for Transaction { + fn drop(&mut self) { + let Some(behavior) = self.drop_behavior.take() else { + return; + }; + self.shared.event(Event::TransactionDrop); + match behavior { + DropBehavior::PanicCommitted(payload) => { + assert!(self.committed, "the process transaction must be committed"); + panic_any(payload); + } + DropBehavior::PanicSecondary(payload) => panic_any(payload), + } + } + } + + #[derive(Debug)] + struct Provider(Arc); + + impl SpawnProvider for Provider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> io::Result { + let paths = self.0.paths(); + let mut command = <$native_command>::new(std::env::current_exe()?); + command + .args([ + "--exact", + concat!(stringify!($module), "::delayed_marker_process"), + "--nocapture", + ]) + .env(MARKER_ENV, &paths.directory); + let child = command.spawn()?; + let child = Arc::new(Mutex::new(Box::new(child) as Box)); + *self.0.last_child.lock().unwrap() = Some(Arc::clone(&child)); + if self + .0 + .wait_for_ready_before_return + .swap(false, Ordering::SeqCst) + { + wait_for_path(&paths.ready); + } + Ok(ProviderProduct::new( + Box::new(SharedProcessChild { + child: Arc::clone(&child), + }), + Box::new(Transaction { + cleanup: Some(child), + shared: Arc::clone(&self.0), + committed: false, + drop_behavior: self.0.take_drop_behavior(), + }), + )) + } + } + + #[derive(Debug)] + struct ProviderWrapper(Provider); + + impl CommandWrapper for ProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } } - #[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"); + #[derive(Debug)] + struct PrimaryError(Arc<()>); - 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") - ] - ); + impl std::fmt::Display for PrimaryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("post-spawn failure") + } } - #[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"); + impl std::error::Error for PrimaryError {} - 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()); + #[derive(Debug)] + struct FailPostSpawn(Mutex>>); - let _child = command.spawn().unwrap(); - assert_eq!(shared.events(), successful_events("provider")); + impl CommandWrapper for FailPostSpawn { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result<()> { + self.0.lock().unwrap().take().map_or(Ok(()), |identity| { + Err(io::Error::other(PrimaryError(identity))) + }) + } } - #[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"); + fn runtime() -> Option { + $runtime + } - 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()); + fn command(shared: Arc) -> CommandWrap { + let mut command = CommandWrap::new("provider-owned-program"); + command.wrap(ProviderWrapper(Provider(shared))); + command + } - shared.clear_events(); - let _child = command.spawn().unwrap(); - assert_eq!(shared.events(), successful_events("provider")); + fn wait_for_path(path: &Path) { + let deadline = Instant::now() + EXIT_TIMEOUT; + while !path.exists() { + assert!( + Instant::now() < deadline, + "path was not created: {}", + path.display() + ); + sleep(Duration::from_millis(5)); + } + } + + fn reap_external(shared: &Shared) { + let child = shared.child(); + let mut child = child + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if child.try_wait().unwrap().is_some() { + return; } + assert!(Instant::now() < deadline, "external child was not reaped"); + sleep(Duration::from_millis(5)); } } #[test] - fn 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")); + fn delayed_marker_process() { + let Some(directory) = std::env::var_os(MARKER_ENV) else { + return; + }; + let paths = MarkerPaths::new(Path::new(&directory)); + fs::write(&paths.ready, b"ready").expect("publish child readiness"); + let deadline = Instant::now() + EXIT_TIMEOUT; + while !paths.go.exists() { + if Instant::now() >= deadline { + return; } + sleep(Duration::from_millis(5)); } + fs::write(paths.marker, b"survived").expect("write delayed marker"); } #[test] - fn rollback_failures_preserve_the_original_failure() { + #[cfg_attr(miri, ignore = "requires native child processes")] + fn committed_residue_destruction_occurs_after_a_controllable_child_is_returned() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - 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)); + let directory = tempfile::tempdir().unwrap(); + let paths = MarkerPaths::new(directory.path()); + let shared = Arc::new(Shared::default()); + shared.set_paths(paths.clone()); + let identity = Arc::new(()); + shared.set_drop_behavior(DropBehavior::PanicCommitted(DeferredPayload( + Arc::clone(&identity), + ))); + let mut command = command(Arc::clone(&shared)); - shared.clear_events(); - let _child = command.spawn().unwrap(); - assert_eq!(shared.events(), successful_events("provider")); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let mut child = match outcome { + Ok(Ok(child)) => child, + Ok(Err(error)) => panic!("provider spawn failed: {error}"), + Err(payload) => { + let payload = payload + .downcast::() + .expect("the committed transaction supplied the panic payload"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + wait_for_path(&paths.ready); + fs::write(&paths.go, b"go").unwrap(); + wait_for_path(&paths.marker); + reap_external(&shared); + shared.clear_child(); + panic!("spawn lost its child while the delayed marker process survived"); } - } + }; + + wait_for_path(&paths.ready); + child.start_kill().expect("start child termination"); + let first = $wait_for_child!(runtime, child).expect("await child termination"); + let second = $wait_for_child!(runtime, child).expect("repeat child wait"); + assert_eq!(first, second); + assert_eq!( + child.try_wait().expect("repeat child try_wait"), + Some(first) + ); + fs::write(&paths.go, b"go").unwrap(); + assert!( + !paths.marker.exists(), + "terminated child wrote its delayed marker" + ); + + let payload = catch_unwind(AssertUnwindSafe(|| drop(child))) + .expect_err("child disposal destroys committed transaction residue"); + let payload = payload + .downcast::() + .expect("the exact deferred transaction payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(shared.events(), vec![Event::Commit, Event::TransactionDrop]); + assert!(!shared.events().contains(&Event::Rollback)); + assert!( + shared + .child() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + .unwrap() + .is_some() + ); + shared.clear_child(); + + let reused_directory = tempfile::tempdir().unwrap(); + let reused_paths = MarkerPaths::new(reused_directory.path()); + shared.set_paths(reused_paths.clone()); + let mut child = command.spawn().expect("the command remains reusable"); + wait_for_path(&reused_paths.ready); + child.start_kill().expect("start reused child termination"); + let first = + $wait_for_child!(runtime, child).expect("await reused child termination"); + let second = $wait_for_child!(runtime, child).expect("repeat reused child wait"); + assert_eq!(first, second); + fs::write(&reused_paths.go, b"go").unwrap(); + assert!(!reused_paths.marker.exists()); + drop(child); + shared.clear_child(); + assert_eq!( + shared.events(), + vec![Event::Commit, Event::TransactionDrop, Event::Commit] + ); } + #[cfg(feature = "tracing")] #[test] - fn commit_failure_rolls_back_and_preserves_its_error() { + #[cfg_attr(miri, ignore = "requires native child processes")] + fn lifecycle_event_panic_rolls_back_and_reaps_a_live_child() { + let _tracing_guard = super::lifecycle_tracing::serial(); + const CHILD_ENV: &str = "PROCESS_WRAP_REAL_PROVIDER_TRACE_IDENTITY"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected + .as_deref() + .is_none_or(|value| !value.starts_with(module)) + { + for event in ["post_spawn", "wrap_child"] { + let child_value = format!("{module}:{event}"); + let mut process = + std::process::Command::new(std::env::current_exe().unwrap()); + process + .args([ + "--exact", + concat!( + stringify!($module), + "::lifecycle_event_panic_rolls_back_and_reaps_a_live_child" + ), + "--nocapture", + ]) + .env(CHILD_ENV, &child_value); + let (output, timed_out) = + super::bounded_process::run(process, EXIT_TIMEOUT, None) + .expect("run isolated real-process lifecycle regression"); + assert!( + !timed_out, + "real-process tracing cleanup exceeded its deadline" + ); + assert!( + output.status.success(), + "{event} real-process tracing cleanup failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let event = if selected + .as_deref() + .expect("the subprocess selects a lifecycle event") + .ends_with(":post_spawn") + { + "post_spawn" + } else { + "wrap_child" + }; let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); - 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 directory = tempfile::tempdir().unwrap(); + let paths = MarkerPaths::new(directory.path()); + let shared = Arc::new(Shared::default()); + shared.set_paths(paths.clone()); + shared + .wait_for_ready_before_return + .store(true, Ordering::SeqCst); + let identity = Arc::new(()); + let dispatch = super::lifecycle_tracing::PanicOnLifecycleEvent::dispatch( + event, + 1, + Arc::clone(&identity), + ); + let mut command = command(Arc::clone(&shared)); - let 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); + let outcome = catch_unwind(AssertUnwindSafe(|| { + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + })); + let payload = match outcome { + Err(payload) => payload, + Ok(result) => { + panic!("the {event} lifecycle event must resume its panic: {result:?}") + } + }; + let payload = payload + .downcast::() + .expect("the exact typed subscriber payload is preserved"); + assert!(Arc::ptr_eq(&payload.0, &identity)); + assert_eq!(shared.events(), [Event::Rollback]); + assert!( + shared + .child() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + .unwrap() + .is_some(), + "rollback reaps the live provider child before resuming the panic" + ); + fs::write(&paths.go, b"go").unwrap(); + sleep(Duration::from_millis(900)); + assert!( + !paths.marker.exists(), + "the rolled-back child wrote its delayed marker" + ); + shared.clear_child(); - shared.clear_events(); - let _child = command.spawn().unwrap(); - assert_eq!(shared.events(), successful_events("provider")); + let reused_directory = tempfile::tempdir().unwrap(); + let reused_paths = MarkerPaths::new(reused_directory.path()); + shared.set_paths(reused_paths.clone()); + shared + .wait_for_ready_before_return + .store(true, Ordering::SeqCst); + shared.events.lock().unwrap().clear(); + let mut child = + super::lifecycle_tracing::with_dispatch(&dispatch, || command.spawn()) + .expect("the command remains reusable"); + child + .start_kill() + .expect("terminate the reused provider child"); + $wait_for_child!(runtime, child).expect("reap the reused provider child"); + drop(child); + shared.clear_child(); + assert_eq!(shared.events(), [Event::Commit]); + } } #[test] - fn successful_provider_can_be_reused() { + #[cfg_attr(miri, ignore = "requires native child processes")] + fn rollback_reaps_the_child_before_transaction_disposal_panics() { let runtime = runtime(); let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let directory = tempfile::tempdir().unwrap(); + let paths = MarkerPaths::new(directory.path()); let shared = Arc::new(Shared::default()); - let mut command = provider_command(Arc::clone(&shared), "provider"); + shared.set_paths(paths.clone()); + let secondary_drops = Arc::new(AtomicUsize::new(0)); + shared.set_drop_behavior(DropBehavior::PanicSecondary(PanickingDropPayload( + Arc::clone(&secondary_drops), + ))); + let identity = Arc::new(()); + let mut command = command(Arc::clone(&shared)); + command.wrap(FailPostSpawn(Mutex::new(Some(Arc::clone(&identity))))); - 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); + let outcome = catch_unwind(AssertUnwindSafe(|| command.spawn())); + let error = outcome + .expect("transaction disposal must not replace the primary error") + .expect_err("the post-spawn hook must fail"); + let error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("the exact primary error payload is preserved"); + assert!(Arc::ptr_eq(&error.0, &identity)); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + assert_eq!( + shared.events(), + vec![Event::Rollback, Event::TransactionDrop] + ); + assert!( + shared + .child() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .try_wait() + .unwrap() + .is_some() + ); + fs::write(&paths.go, b"go").unwrap(); + assert!( + !paths.marker.exists(), + "rolled-back child wrote its delayed marker" + ); + shared.clear_child(); + + let reused_directory = tempfile::tempdir().unwrap(); + let reused_paths = MarkerPaths::new(reused_directory.path()); + shared.set_paths(reused_paths.clone()); + let mut child = command.spawn().expect("the command remains reusable"); + wait_for_path(&reused_paths.ready); + child.start_kill().expect("start reused child termination"); + $wait_for_child!(runtime, child).expect("await reused child termination"); + fs::write(&reused_paths.go, b"go").unwrap(); + assert!(!reused_paths.marker.exists()); + drop(child); + shared.clear_child(); } } }; @@ -925,6 +5129,11 @@ spawn_provider_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::ChildWrapperLayer, + process_wrap::std::ChildWrapperSlots, + process_wrap::std::PendingChildWrapper, + process_wrap::std::PreparedChild, + process_wrap::std::PreparedChildRef, process_wrap::std::ProviderProduct, process_wrap::std::SpawnProvider, None @@ -937,8 +5146,91 @@ spawn_provider_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ChildWrapperLayer, + process_wrap::tokio::ChildWrapperSlots, + process_wrap::tokio::PendingChildWrapper, + process_wrap::tokio::PreparedChild, + process_wrap::tokio::PreparedChildRef, + process_wrap::tokio::ProviderProduct, + process_wrap::tokio::SpawnProvider, + Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + ) +); + +#[cfg(feature = "std")] +provider_capability_tests!( + std_provider_capabilities, + process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, + process_wrap::std::CommandWrapper, + process_wrap::std::ChildWrapper, + process_wrap::std::ChildWrapperLayer, + process_wrap::std::ChildWrapperSlots, + process_wrap::std::PendingChildWrapper, + process_wrap::std::ProviderProduct, + process_wrap::std::SpawnProvider, + std::process::Command, + std::process::Child, + std_wait_for_child, + std_kill_child, + None +); + +#[cfg(feature = "tokio1")] +provider_capability_tests!( + tokio_provider_capabilities, + process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, + process_wrap::tokio::CommandWrapper, + process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ChildWrapperLayer, + process_wrap::tokio::ChildWrapperSlots, + process_wrap::tokio::PendingChildWrapper, + process_wrap::tokio::ProviderProduct, + process_wrap::tokio::SpawnProvider, + tokio::process::Command, + tokio::process::Child, + tokio_wait_for_child, + tokio_kill_child, + Some( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + ) +); + +#[cfg(feature = "std")] +real_provider_tests!( + std_process_frontend, + process_wrap::std::CommandWrap, + process_wrap::std::SpawnAttempt, + process_wrap::std::CommandWrapper, + process_wrap::std::ChildWrapper, + process_wrap::std::ProviderProduct, + process_wrap::std::SpawnProvider, + std::process::Command, + std_shared_process_methods, + std_wait_for_child, + None +); + +#[cfg(feature = "tokio1")] +real_provider_tests!( + tokio_process_frontend, + process_wrap::tokio::CommandWrap, + process_wrap::tokio::SpawnAttempt, + process_wrap::tokio::CommandWrapper, + process_wrap::tokio::ChildWrapper, process_wrap::tokio::ProviderProduct, process_wrap::tokio::SpawnProvider, + tokio::process::Command, + tokio_shared_process_methods, + tokio_wait_for_child, Some( tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/tests/spawn_with_child.rs b/tests/spawn_with_child.rs index 4b0cbc6..706d58b 100644 --- a/tests/spawn_with_child.rs +++ b/tests/spawn_with_child.rs @@ -1,5 +1,8 @@ #![cfg(all(any(feature = "std", feature = "tokio1"), any(unix, windows)))] +#[path = "support/bounded_process.rs"] +mod bounded_process; + macro_rules! spawn_with_child_tests { ( $module:ident, @@ -7,21 +10,31 @@ macro_rules! spawn_with_child_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $child_wrapper_layer:path, + $child_wrapper_slots:path, + $pending_child_wrapper:path, $runtime:expr ) => { mod $module { use std::{ any::TypeId, io, - panic::{AssertUnwindSafe, catch_unwind}, - sync::{Arc, Mutex}, + panic::{AssertUnwindSafe, catch_unwind, panic_any}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, thread::sleep, time::{Duration, Instant}, }; + use super::bounded_process; use $child_wrapper as ChildWrapper; + use $child_wrapper_layer as ChildWrapperLayer; + use $child_wrapper_slots as ChildWrapperSlots; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; use $spawn_attempt as SpawnAttempt; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); @@ -59,11 +72,11 @@ macro_rules! spawn_with_child_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.0.lock().unwrap().push(Event::Wrap("first")); - Ok(Box::new(FirstChild(child))) + Ok(Some(PendingChildWrapper::new(FirstChild(None)))) } } @@ -92,55 +105,69 @@ macro_rules! spawn_with_child_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.0.lock().unwrap().push(Event::Wrap("second")); - Ok(Box::new(SecondChild(child))) + Ok(Some(PendingChildWrapper::new(SecondChild(None)))) } } #[derive(Debug)] - struct FirstChild(Box); + struct FirstChild(Option>); + + impl ChildWrapperLayer for FirstChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } impl ChildWrapper for FirstChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0.as_deref().expect("the first layer is installed") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0.as_deref_mut().expect("the first layer is installed") } - fn into_inner(self: Box) -> Box { - self.0 + fn into_inner(mut self: Box) -> Box { + self.0.take().expect("the first layer is installed") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } #[derive(Debug)] - struct SecondChild(Box); + struct SecondChild(Option>); + + impl ChildWrapperLayer for SecondChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } + } impl ChildWrapper for SecondChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0.as_deref().expect("the second layer is installed") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("the second layer is installed") } - fn into_inner(self: Box) -> Box { - self.0 + fn into_inner(mut self: Box) -> Box { + self.0.take().expect("the second layer is installed") } #[cfg(windows)] fn process_handle(&self) -> Option> { - self.0.process_handle() + self.inner().process_handle() } } @@ -180,6 +207,121 @@ macro_rules! spawn_with_child_tests { } } + #[derive(Debug)] + struct SecondaryPayload(Arc); + + impl Drop for SecondaryPayload { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + panic_any("a secondary wrapper payload was dropped"); + } + } + + #[derive(Debug)] + struct PanickingLayer { + inner: Option>, + payload: Option, + } + + impl ChildWrapperLayer for PanickingLayer { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } + } + + impl ChildWrapper for PanickingLayer { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("the panicking layer is installed") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("the panicking layer is installed") + } + + fn into_inner(mut self: Box) -> Box { + if let Some(payload) = self.payload.take() { + std::mem::forget(payload); + } + self.inner.take().expect("the panicking layer is installed") + } + } + + impl Drop for PanickingLayer { + fn drop(&mut self) { + if let Some(payload) = self.payload.take() { + panic_any(payload); + } + } + } + + #[derive(Debug)] + struct AddPanickingLayer(Mutex>); + + impl CommandWrapper for AddPanickingLayer { + fn wrap_child( + &mut self, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result> { + Ok(self.0.lock().unwrap().take().map(|payload| { + PendingChildWrapper::new(PanickingLayer { + inner: None, + payload: Some(payload), + }) + })) + } + } + + #[derive(Debug)] + struct WrapIdentityError(Arc<()>); + + impl std::fmt::Display for WrapIdentityError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("primary wrap error") + } + } + + impl std::error::Error for WrapIdentityError {} + + #[derive(Debug)] + struct WrapIdentityPanic(Arc<()>); + + #[derive(Debug)] + enum WrapFailure { + Error(Arc<()>), + Panic(Arc<()>), + } + + #[derive(Debug)] + struct FailWrap(Mutex>); + + impl CommandWrapper for FailWrap { + fn wrap_child( + &mut self, + _child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result> { + let failure = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + match failure { + Some(WrapFailure::Error(identity)) => { + Err(io::Error::other(WrapIdentityError(identity))) + } + Some(WrapFailure::Panic(identity)) => { + panic_any(WrapIdentityPanic(identity)); + } + None => Ok(None), + } + } + } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Phase { Pre, @@ -234,11 +376,11 @@ macro_rules! spawn_with_child_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.visit(Phase::Wrap)?; - Ok(child) + Ok(None) } } @@ -258,6 +400,19 @@ macro_rules! spawn_with_child_tests { }); } + fn bounded_test_process( + test_name: &str, + environment: (&str, &str), + timeout: Duration, + ) -> (std::process::Output, bool) { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", test_name, "--nocapture"]) + .env(environment.0, environment.1); + bounded_process::run(command, timeout, None) + .expect("run isolated wrapping regression") + } + fn wait_for_exit(mut child: Box) { let deadline = Instant::now() + EXIT_TIMEOUT; loop { @@ -338,6 +493,158 @@ macro_rules! spawn_with_child_tests { wait_for_exit(child); } + #[derive(Clone, Copy, Debug)] + enum Transport { + Native, + ExplicitNative, + Boxed, + } + + fn spawn_transport( + command: &mut CommandWrap, + transport: Transport, + ) -> io::Result> { + match transport { + Transport::Native => command.spawn(), + Transport::ExplicitNative => command.spawn_with(|command| command.spawn()), + Transport::Boxed => command + .spawn_with_child(|_| Ok(Box::new(CustomLeaf) as Box)), + } + } + + fn assert_wrap_error( + outcome: std::thread::Result>>, + identity: &Arc<()>, + ) { + let result = match outcome { + Ok(result) => result, + Err(secondary) => { + std::mem::forget(secondary); + panic!("child cleanup replaced the primary wrapping error"); + } + }; + let error = result.expect_err("the wrapping hook must fail"); + let identity_error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("the exact wrapping error must survive cleanup"); + assert!(Arc::ptr_eq(&identity_error.0, identity)); + } + + fn assert_wrap_panic( + outcome: std::thread::Result>>, + identity: &Arc<()>, + ) { + let payload = match outcome { + Err(payload) => payload, + Ok(_) => panic!("the wrapping hook must panic"), + }; + let payload = match payload.downcast::() { + Ok(payload) => payload, + Err(secondary) => { + std::mem::forget(secondary); + panic!("child cleanup replaced the primary wrapping panic"); + } + }; + assert!(Arc::ptr_eq(&payload.0, identity)); + } + + #[test] + fn consuming_wrap_errors_preserve_identity_and_cleanup_for_every_transport() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for transport in [ + Transport::Native, + Transport::ExplicitNative, + Transport::Boxed, + ] { + let secondary_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let mut command = command(); + let payload = SecondaryPayload(Arc::clone(&secondary_drops)); + let failure = WrapFailure::Error(Arc::clone(&identity)); + command + .wrap(AddPanickingLayer(Mutex::new(Some(payload)))) + .wrap(FailWrap(Mutex::new(Some(failure)))); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + spawn_transport(&mut command, transport) + })); + assert_wrap_error(outcome, &identity); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + + let child = spawn_transport(&mut command, transport) + .expect("the command and wrapping hooks remain reusable"); + if !matches!(transport, Transport::Boxed) { + wait_for_exit(child); + } + } + } + + #[test] + fn consuming_wrap_panics_preserve_identity_and_cleanup_for_every_transport() { + const CHILD_ENV: &str = "PROCESS_WRAP_CONSUMING_WRAP_PANIC"; + let module = stringify!($module); + let selected = std::env::var(CHILD_ENV).ok(); + if selected + .as_deref() + .is_none_or(|value| !value.starts_with(module)) + { + for transport in ["native", "explicit-native", "boxed"] { + let child_value = format!("{module}:{transport}"); + let (output, timed_out) = bounded_test_process( + concat!( + stringify!($module), + "::consuming_wrap_panics_preserve_identity_and_cleanup_for_every_transport" + ), + (CHILD_ENV, &child_value), + EXIT_TIMEOUT, + ); + assert!( + !timed_out, + "isolated wrapping cleanup exceeded its deadline" + ); + assert!( + output.status.success(), + "{transport} wrapping panic was not preserved:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + } + + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let selected = selected.expect("the subprocess selected a transport"); + let transport = if selected.ends_with(":native") { + Transport::Native + } else if selected.ends_with(":explicit-native") { + Transport::ExplicitNative + } else { + Transport::Boxed + }; + let secondary_drops = Arc::new(AtomicUsize::new(0)); + let identity = Arc::new(()); + let mut command = command(); + let payload = SecondaryPayload(Arc::clone(&secondary_drops)); + let failure = WrapFailure::Panic(Arc::clone(&identity)); + command + .wrap(AddPanickingLayer(Mutex::new(Some(payload)))) + .wrap(FailWrap(Mutex::new(Some(failure)))); + + let outcome = catch_unwind(AssertUnwindSafe(|| { + spawn_transport(&mut command, transport) + })); + assert_wrap_panic(outcome, &identity); + assert_eq!(secondary_drops.load(Ordering::SeqCst), 0); + let child = spawn_transport(&mut command, transport) + .expect("the command and wrapping hooks remain reusable"); + if !matches!(transport, Transport::Boxed) { + wait_for_exit(child); + } + } + #[test] fn ordinary_spawn_keeps_portable_state_for_post_spawn_hooks() { let runtime = runtime(); @@ -450,6 +757,9 @@ spawn_with_child_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::ChildWrapperLayer, + process_wrap::std::ChildWrapperSlots, + process_wrap::std::PendingChildWrapper, None ); @@ -460,6 +770,9 @@ spawn_with_child_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::ChildWrapperLayer, + process_wrap::tokio::ChildWrapperSlots, + process_wrap::tokio::PendingChildWrapper, Some( tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/tests/std_unix/signals.rs b/tests/std_unix/signals.rs index a6a69e7..a998e43 100644 --- a/tests/std_unix/signals.rs +++ b/tests/std_unix/signals.rs @@ -57,3 +57,40 @@ fn process_session() -> Result<()> { Ok(()) } + +#[test] +fn direct_wait_then_signal_and_start_kill_are_noops() -> Result<()> { + let mut child = CommandWrap::new("true").spawn()?; + let status = child.inner_mut().wait()?; + child.signal(Signal::SIGCONT as _)?; + child.start_kill()?; + assert_eq!(child.wait()?, status); + assert_eq!(child.try_wait()?, Some(status)); + Ok(()) +} + +#[cfg(feature = "process-group")] +#[test] +fn process_group_inner_wait_then_signals_are_noops() -> Result<()> { + let mut child = CommandWrap::new("true") + .wrap(ProcessGroup::leader()) + .spawn()?; + let status = child.inner_mut().wait()?; + child.signal(Signal::SIGCONT as _)?; + child.start_kill()?; + assert_eq!(child.wait()?, status); + assert_eq!(child.try_wait()?, Some(status)); + Ok(()) +} + +#[cfg(feature = "process-session")] +#[test] +fn process_session_inner_wait_then_signals_are_noops() -> Result<()> { + let mut child = CommandWrap::new("true").wrap(ProcessSession).spawn()?; + let status = child.inner_mut().wait()?; + child.signal(Signal::SIGCONT as _)?; + child.start_kill()?; + assert_eq!(child.wait()?, status); + assert_eq!(child.try_wait()?, Some(status)); + Ok(()) +} diff --git a/tests/std_windows/creation_flags_job_object.rs b/tests/std_windows/creation_flags_job_object.rs index 4ca145e..23a8555 100644 --- a/tests/std_windows/creation_flags_job_object.rs +++ b/tests/std_windows/creation_flags_job_object.rs @@ -1,9 +1,10 @@ use std::{ fs, io::{Error, ErrorKind}, + os::windows::process::CommandExt, panic::{AssertUnwindSafe, catch_unwind}, - path::PathBuf, - process::{Command as StdCommand, ExitStatus}, + path::{Path, PathBuf}, + process::{Command as StdCommand, ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -11,17 +12,32 @@ use std::{ time::Instant, }; +#[cfg(feature = "tracing")] +use tracing::{ + Event, Metadata, Subscriber, + field::{Field, Visit}, + span::{Attributes, Id, Record}, +}; + use windows::Win32::System::Threading::{ - CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, PROCESS_CREATION_FLAGS, + CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, + PROCESS_CREATION_FLAGS, }; use super::{ + bounded_process, 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"; +#[cfg(feature = "tracing")] +const FINAL_OWNER_TRACE_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_HELPER"; +#[cfg(feature = "tracing")] +const FINAL_OWNER_TRACE_STALL_HELPER: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_STALL_HELPER"; +#[cfg(feature = "tracing")] +const FINAL_OWNER_TRACE_READY: &str = "PROCESS_WRAP_FINAL_OWNER_TRACE_READY"; static PID_FILE_SEQUENCE: AtomicU32 = AtomicU32::new(0); #[derive(Clone, Copy)] @@ -115,11 +131,11 @@ struct FailWrapOnce { impl CommandWrapper for FailWrapOnce { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.failed { - return Ok(child); + return Ok(None); } self.failed = true; match self.failure { @@ -135,26 +151,39 @@ enum FailureHook { WrapChild, FinalizeSpawn, DisarmSpawnCleanup, + DisarmJobObject, } #[derive(Debug)] struct FailFinalizationChild { - inner: Box, + inner: Option>, failure: Failure, hook: FailureHook, } +impl ChildWrapperLayer for FailFinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for FailFinalizationChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed finalization layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed finalization layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed finalization layer owns its child") } fn finalize_spawn_layer(&mut self) -> Result<()> { @@ -178,13 +207,23 @@ impl ChildWrapper for FailFinalizationChild { Ok(()) } } + + fn disarm_job_object_layer(&mut self) -> Result<()> { + if self.hook == FailureHook::DisarmJobObject { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } } #[derive(Debug)] struct FailAfterDescendant { failure: Failure, hook: FailureHook, - unwrap_child: bool, pid_file: PathBuf, guard: Arc>>, } @@ -235,39 +274,132 @@ impl CommandWrapper for FailAfterDescendant { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.hook == FailureHook::PostSpawn { - return Ok(child); + return Ok(None); } resume_process_threads(child.id())?; self.observe_descendant()?; if matches!( self.hook, - FailureHook::FinalizeSpawn | FailureHook::DisarmSpawnCleanup + FailureHook::FinalizeSpawn + | FailureHook::DisarmSpawnCleanup + | FailureHook::DisarmJobObject ) { - return Ok(Box::new(FailFinalizationChild { - inner: child, + return Ok(Some(PendingChildWrapper::new(FailFinalizationChild { + inner: None, failure: self.failure, hook: self.hook, - })); - } - if self.unwrap_child { - drop(child.into_inner()); - } else { - drop(child); + }))); } self.fail() } } -fn descendant_pid_file() -> PathBuf { - std::env::temp_dir().join(format!( +#[cfg(feature = "tracing")] +#[derive(Debug)] +struct ObserveDescendant { + pid_file: PathBuf, + guard: Arc>>, +} + +#[cfg(feature = "tracing")] +impl CommandWrapper for ObserveDescendant { + fn wrap_child( + &mut self, + child: &mut dyn ChildWrapper, + _core: &CommandWrap, + ) -> Result> { + resume_process_threads(child.id())?; + let deadline = Instant::now() + EXIT_TIMEOUT; + let pid = loop { + if let Ok(pid) = fs::read_to_string(&self.pid_file) + .and_then(|pid| pid.trim().parse().map_err(Error::other)) + { + break pid; + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + std::thread::sleep(Duration::from_millis(10)); + }; + *self.guard.lock().unwrap() = Some(ProcessGuard::open(pid)?); + Ok(None) + } +} + +#[cfg(feature = "tracing")] +#[derive(Default)] +struct JobLimitMessageVisitor { + matches: bool, + kill_on_drop: Option, +} + +#[cfg(feature = "tracing")] +impl Visit for JobLimitMessageVisitor { + fn record_bool(&mut self, field: &Field, value: bool) { + if field.name() == "kill_on_drop" { + self.kill_on_drop = Some(value); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" + && format!("{value:?}").contains("setting SetInformationJobObject(limit)") + { + self.matches = true; + } + } +} + +#[cfg(feature = "tracing")] +#[derive(Debug, Default)] +struct PanicOnJobDisarmEvent; + +#[cfg(feature = "tracing")] +impl Subscriber for PanicOnJobDisarmEvent { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + let mut visitor = JobLimitMessageVisitor::default(); + event.record(&mut visitor); + if visitor.matches && visitor.kill_on_drop == Some(false) { + panic!("injected final-owner tracing panic"); + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} +} + +fn descendant_pid_file() -> Result { + let path = std::env::temp_dir().join(format!( "process-wrap-{}-{}.pid", std::process::id(), PID_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) - )) + )); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + Ok(path) } fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { @@ -393,6 +525,10 @@ fn lifecycle_descendant_leaf() { fn lifecycle_descendant_parent() { let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) .args(["lifecycle_descendant_leaf", "--ignored", "--nocapture"]) + .creation_flags(DETACHED_PROCESS.0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .unwrap(); fs::write( @@ -406,18 +542,19 @@ fn lifecycle_descendant_parent() { #[test] fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let cases = [ - (FailureHook::PostSpawn, false, false), - (FailureHook::WrapChild, false, false), - (FailureHook::WrapChild, true, false), - (FailureHook::WrapChild, true, true), - (FailureHook::FinalizeSpawn, false, false), - (FailureHook::FinalizeSpawn, true, false), - (FailureHook::DisarmSpawnCleanup, false, false), - (FailureHook::DisarmSpawnCleanup, true, false), + (FailureHook::PostSpawn, false), + (FailureHook::WrapChild, false), + (FailureHook::WrapChild, true), + (FailureHook::FinalizeSpawn, false), + (FailureHook::FinalizeSpawn, true), + (FailureHook::DisarmSpawnCleanup, false), + (FailureHook::DisarmSpawnCleanup, true), + (FailureHook::DisarmJobObject, false), + (FailureHook::DisarmJobObject, true), ]; for failure in [Failure::Error, Failure::Panic] { - for (hook, job_first, unwrap_child) in cases { - let pid_file = descendant_pid_file(); + for (hook, job_first) in cases { + let pid_file = descendant_pid_file()?; let guard = Arc::new(Mutex::new(None)); let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { command @@ -427,7 +564,6 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let fail = FailAfterDescendant { failure, hook, - unwrap_child, pid_file: pid_file.clone(), guard: Arc::clone(&guard), }; @@ -464,6 +600,127 @@ fn armed_job_kills_descendants_after_later_failures() -> Result<()> { Ok(()) } +#[cfg(feature = "tracing")] +fn wait_for_final_owner_trace_readiness(path: &Path) -> Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + while !path.exists() { + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "the final-owner tracing helper did not enter its stall branch", + )); + } + std::thread::sleep(Duration::from_millis(5)); + } + Ok(()) +} + +#[cfg(feature = "tracing")] +fn run_final_owner_trace_helper( + environment: (&str, &str), + timeout: Duration, + readiness: Option<&Path>, +) -> Result<(std::process::Output, bool)> { + let mut command = StdCommand::new(std::env::current_exe()?); + command + .args([ + "std_windows::creation_flags_job_object::final_owner_tracing_panic_leaves_job_cleanup_armed", + "--exact", + "--nocapture", + ]) + .env(environment.0, environment.1); + if let Some(readiness) = readiness { + command.env(FINAL_OWNER_TRACE_READY, readiness); + bounded_process::run_after_start(command, timeout, None, || { + wait_for_final_owner_trace_readiness(readiness) + }) + } else { + bounded_process::run(command, timeout, None) + } +} + +#[cfg(feature = "tracing")] +#[test] +fn final_owner_tracing_watchdog_terminates_a_stalled_helper() -> Result<()> { + let directory = tempfile::tempdir()?; + let readiness = directory.path().join("stall-ready"); + let (output, timed_out) = run_final_owner_trace_helper( + (FINAL_OWNER_TRACE_STALL_HELPER, "1"), + Duration::ZERO, + Some(&readiness), + )?; + assert!( + timed_out, + "the zero-deadline watchdog must take its timeout path" + ); + assert!( + !output.status.success(), + "the stalled helper must be terminated" + ); + Ok(()) +} + +#[cfg(feature = "tracing")] +#[test] +fn final_owner_tracing_panic_leaves_job_cleanup_armed() -> Result<()> { + if std::env::var_os(FINAL_OWNER_TRACE_STALL_HELPER).is_some() { + let readiness = PathBuf::from( + std::env::var_os(FINAL_OWNER_TRACE_READY) + .ok_or_else(|| Error::other("the stall readiness path is missing"))?, + ); + fs::write(readiness, b"ready")?; + std::thread::sleep(Duration::from_secs(30)); + return Ok(()); + } + if std::env::var_os(FINAL_OWNER_TRACE_HELPER).is_none() { + let (output, timed_out) = + run_final_owner_trace_helper((FINAL_OWNER_TRACE_HELPER, "1"), EXIT_TIMEOUT, None)?; + assert!( + !timed_out, + "the isolated tracing regression exceeded its deadline" + ); + assert!( + output.status.success(), + "the isolated tracing regression failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return Ok(()); + } + + let pid_file = descendant_pid_file()?; + let guard = Arc::new(Mutex::new(None)); + let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args(["lifecycle_descendant_parent", "--ignored", "--nocapture"]) + .env(DESCENDANT_PID_FILE, &pid_file); + }); + command + .wrap(ObserveDescendant { + pid_file: pid_file.clone(), + guard: Arc::clone(&guard), + }) + .wrap(JobObject); + + let panic = tracing::subscriber::with_default(PanicOnJobDisarmEvent, || { + catch_unwind(AssertUnwindSafe(|| command.spawn())) + }) + .expect_err("the final-owner tracing callback must panic"); + assert_eq!( + *panic.downcast::<&'static str>().unwrap(), + "injected final-owner tracing panic" + ); + + let process = guard + .lock() + .unwrap() + .take() + .expect("the observing hook opened the descendant process"); + wait_for_process_exit(process)?; + fs::remove_file(pid_file)?; + Ok(()) +} + #[test] fn suspended_native_children_are_killed_after_later_failures() -> Result<()> { for failure in [Failure::Error, Failure::Panic] { diff --git a/tests/std_windows/mod.rs b/tests/std_windows/mod.rs index 857b0dd..dbab355 100644 --- a/tests/std_windows/mod.rs +++ b/tests/std_windows/mod.rs @@ -11,6 +11,9 @@ mod prelude { pub const DIE_TIME: Duration = Duration::from_millis(1000); } +#[path = "../support/bounded_process.rs"] +mod bounded_process; + #[cfg(all(feature = "creation-flags", feature = "job-object"))] mod creation_flags_job_object; mod id_same_as_inner; @@ -23,6 +26,7 @@ mod try_wait_after_die; mod wait_after_die; mod wait_twice; mod wait_with_output; -#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[cfg(feature = "job-object")] +#[cfg_attr(not(feature = "creation-flags"), allow(dead_code))] #[path = "../support/windows_thread.rs"] mod windows_thread; diff --git a/tests/std_windows/process_handle.rs b/tests/std_windows/process_handle.rs index 096c347..961e7c1 100644 --- a/tests/std_windows/process_handle.rs +++ b/tests/std_windows/process_handle.rs @@ -1,17 +1,25 @@ use std::{ any::TypeId, + fs, os::windows::{ io::{AsHandle, AsRawHandle, BorrowedHandle, OwnedHandle}, process::ExitStatusExt, }, - process::{Command, ExitStatus}, + path::{Path, PathBuf}, + process::{Command, Command as StdCommand, ExitStatus}, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, }, + time::Instant, }; -use super::prelude::*; +use super::{prelude::*, windows_thread::ProcessGuard}; + +const JOB_WAIT_DESCENDANT_PID: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_PID"; +const JOB_WAIT_DESCENDANT_RELEASE: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_RELEASE"; +const JOB_WAIT_TIMEOUT: Duration = Duration::from_secs(5); #[link(name = "kernel32")] unsafe extern "system" { @@ -80,26 +88,79 @@ fn opaque_child() -> ( ) } +#[derive(Debug)] +struct NoopTransaction; + +impl SpawnTransaction for NoopTransaction { + fn commit(&mut self) -> Result<()> { + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct OpaqueProvider(Mutex>>); + +impl SpawnProvider for OpaqueProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let child = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("the opaque provider is used once"); + Ok(ProviderProduct::new(child, Box::new(NoopTransaction))) + } +} + +#[derive(Debug)] +struct OpaqueProviderWrapper(OpaqueProvider); + +impl CommandWrapper for OpaqueProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + #[derive(Debug)] struct TransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed transparent layer owns its child") } fn process_handle(&self) -> Option> { - self.inner.process_handle() + self.inner().process_handle() } } @@ -109,29 +170,43 @@ struct Transparent; impl CommandWrapper for Transparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(TransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(TransparentChild { + inner: None, + }))) } } #[derive(Debug)] struct LegacyTransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for LegacyTransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for LegacyTransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed legacy layer owns its child") } } @@ -246,28 +321,42 @@ struct LegacyTransparent; impl CommandWrapper for LegacyTransparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(LegacyTransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyTransparentChild { + inner: None, + }))) } } #[repr(transparent)] #[derive(Debug)] -struct LegacyInlineChild(std::process::Child); +struct LegacyInlineChild(Option>); + +impl ChildWrapperLayer for LegacyInlineChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for LegacyInlineChild { fn inner(&self) -> &dyn ChildWrapper { - &self.0 + self.0 + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - &mut self.0 + self.0 + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { - Box::new(self.0) + fn into_inner(mut self: Box) -> Box { + self.0 + .take() + .expect("an installed legacy layer owns its child") } } @@ -277,13 +366,10 @@ struct LegacyInline; impl CommandWrapper for LegacyInline { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - // SAFETY: `LegacyInline` adds no cleanup or supervision state. - let child = unsafe { child.try_into_inner_child() } - .map_err(|_| std::io::Error::other("legacy inline wrapper expected a native child"))?; - Ok(Box::new(LegacyInlineChild(child))) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyInlineChild(None)))) } } @@ -309,8 +395,13 @@ fn opaque_child_defaults_to_no_process_handle_without_traversing() { #[test] fn job_object_rejects_an_opaque_child_and_cleans_it_up() { let (child, inner_calls, killed, waited) = opaque_child(); - let core = CommandWrap::with_new("cmd.exe", |_| {}); - let error = JobObject.wrap_child(child, &core).unwrap_err(); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(OpaqueProviderWrapper(OpaqueProvider(Mutex::new(Some( + child, + ))))); + let error = command.spawn().unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); assert_eq!(inner_calls.load(Ordering::SeqCst), 1); @@ -326,7 +417,7 @@ fn transparent_child_delegates_the_native_process_handle() -> Result<()> { .expect("a native child exposes its process handle") .as_raw_handle(); let mut child: Box = Box::new(TransparentChild { - inner: Box::new(native), + inner: Some(Box::new(native)), }); let delegated_handle = child .process_handle() @@ -345,11 +436,13 @@ fn job_object_falls_back_through_a_legacy_transparent_child() -> Result<()> { command.wrap(LegacyTransparent).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait()?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -363,10 +456,18 @@ fn job_object_finds_terminal_capabilities_below_multiple_legacy_layers() -> Resu resumes: Arc::clone(&resumes), }); let child: Box = Box::new(LegacyTransparentChild { - inner: Box::new(LegacyTransparentChild { inner: terminal }), + inner: Some(Box::new(LegacyTransparentChild { + inner: Some(terminal), + })), }); - let core = CommandWrap::new("cmd.exe"); - let mut child = JobObject.wrap_child(child, &core)?; + let mut child = Some(child); + let mut command = CommandWrap::new("cmd.exe"); + command.wrap(JobObject); + let mut child = command.spawn_with_child(|_| { + Ok(child + .take() + .expect("the custom spawner is invoked exactly once")) + })?; assert_eq!(resumes.load(Ordering::SeqCst), 1); assert!(child.process_handle().is_some()); @@ -400,17 +501,43 @@ fn provider_job_assignment_precedes_commit_in_both_orders() -> Result<()> { Ok(()) } +#[test] +fn provider_sidecar_removal_preserves_job_object_consuming_extraction() -> Result<()> { + let resumes = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ProviderWrapper(ProcessProvider { + resumes: Arc::clone(&resumes), + commits: Arc::clone(&commits), + })); + + let child = command.spawn()?; + let mut job = child.into_inner(); + assert_eq!(job.as_ref().type_id(), TypeId::of::()); + job.start_kill()?; + let status = job.wait()?; + let mut child = job.into_inner(); + assert_eq!(child.wait()?, status); + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert_eq!(commits.load(Ordering::SeqCst), 1); + Ok(()) +} + #[test] fn job_object_falls_back_through_a_legacy_inline_child() -> Result<()> { let mut command = sleeping_command_wrap(); command.wrap(LegacyInline).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait()?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -423,8 +550,9 @@ fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Result<( let mut child = command.spawn()?; let outer_has_handle = child.process_handle().is_some(); - let direct_type = child.inner().type_id(); - let direct_mut_type = child.inner_mut().type_id(); + let prepared_layer_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); + let prepared_layer_mut_type = child.inner_mut().type_id(); let mut direct = child.into_inner(); let consumed_type = direct.as_ref().type_id(); let consumed_has_handle = direct.process_handle().is_some(); @@ -433,9 +561,211 @@ fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Result<( let _ = direct.wait()?; assert!(outer_has_handle); + assert_eq!(prepared_layer_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); - assert_eq!(direct_mut_type, TypeId::of::()); + assert_eq!(prepared_layer_mut_type, TypeId::of::()); assert_eq!(consumed_type, TypeId::of::()); assert!(consumed_has_handle); Ok(()) } + +#[derive(Debug)] +struct ReleaseOnDrop(PathBuf); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = fs::File::create(&self.0); + } +} + +#[derive(Debug)] +struct WaiterGuard { + release_file: PathBuf, + thread: Option>, +} + +impl WaiterGuard { + fn join(&mut self) -> Result<()> { + if let Some(thread) = self.thread.take() { + thread + .join() + .map_err(|_| std::io::Error::other("JobObject wait thread panicked"))?; + } + Ok(()) + } +} + +impl Drop for WaiterGuard { + fn drop(&mut self) { + let _ = fs::File::create(&self.release_file); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn wait_for_pid(path: &Path) -> Result { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if let Ok(pid) = fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(std::io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + sleep(Duration::from_millis(10)); + } +} + +fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "process did not exit before the test deadline", + )); + } + sleep(Duration::from_millis(10)); + } +} + +fn descendant_job_command(pid_file: &Path, release_file: &Path) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args([ + "--exact", + "std_windows::process_handle::job_wait_direct_child_helper", + "--ignored", + "--nocapture", + ]) + .env(JOB_WAIT_DESCENDANT_PID, pid_file) + .env(JOB_WAIT_DESCENDANT_RELEASE, release_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_descendant_helper() -> Result<()> { + let release = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ); + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + while !release.exists() { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant release was not signaled", + )); + } + sleep(Duration::from_millis(10)); + } + Ok(()) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_direct_child_helper() -> Result<()> { + let pid_file = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_PID) + .ok_or_else(|| std::io::Error::other("descendant PID path is missing"))?, + ); + let descendant = StdCommand::new(std::env::current_exe()?) + .args([ + "--exact", + "std_windows::process_handle::job_wait_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env( + JOB_WAIT_DESCENDANT_RELEASE, + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + fs::write(pid_file, descendant.id().to_string())?; + drop(descendant); + Ok(()) +} + +#[test] +fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release_file = directory.path().join("release"); + let _release_on_drop = ReleaseOnDrop(release_file.clone()); + let mut command = descendant_job_command(&pid_file, &release_file)?; + command.wrap(JobObject); + let mut child = command.spawn()?; + let direct_process = ProcessGuard::open(child.id())?; + let descendant_process = ProcessGuard::open(wait_for_pid(&pid_file)?)?; + wait_for_process_exit(direct_process)?; + + assert_eq!( + child.try_wait()?, + None, + "direct-child exit must remain pending while the job has a live descendant" + ); + + let (drain_entered, drain_entry) = mpsc::channel(); + let (completed, completion) = mpsc::channel(); + let waiter_release = release_file.clone(); + let thread = std::thread::spawn(move || { + let pending = child.try_wait(); + let entered = match &pending { + Ok(None) => Ok(()), + Ok(Some(_)) => Err(std::io::Error::other( + "blocking wait setup observed a drained job before release", + )), + Err(error) => Err(std::io::Error::new(error.kind(), error.to_string())), + }; + let _ = drain_entered.send(entered); + let result = match pending { + Ok(None) => child.wait(), + Ok(Some(status)) => Ok(status), + Err(error) => Err(error), + }; + let _ = completed.send((result, child)); + }); + let mut waiter = WaiterGuard { + release_file: waiter_release, + thread: Some(thread), + }; + drain_entry + .recv_timeout(JOB_WAIT_TIMEOUT) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("waiter did not enter job drain after a false query: {error}"), + ) + })??; + fs::File::create(&release_file)?; + let (status, mut child) = completion.recv_timeout(JOB_WAIT_TIMEOUT).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("JobObject wait did not finish after descendant release: {error}"), + ) + })?; + waiter.join()?; + let status = status?; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + assert_eq!(child.wait()?, status); + wait_for_process_exit(descendant_process)?; + Ok(()) +} diff --git a/tests/support/bounded_process.rs b/tests/support/bounded_process.rs new file mode 100644 index 0000000..d34f80b --- /dev/null +++ b/tests/support/bounded_process.rs @@ -0,0 +1,361 @@ +use std::{ + io::{self, Read}, + process::{Child, ExitStatus, Output, Stdio}, + sync::mpsc, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +const REAP_TIMEOUT: Duration = Duration::from_secs(5); +const POLL_INTERVAL: Duration = Duration::from_millis(5); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ReaderStartFailure { + First, + Second, +} + +struct ChildGuard(Option); + +impl ChildGuard { + fn new(child: Child) -> Self { + Self(Some(child)) + } + + fn child_mut(&mut self) -> &mut Child { + self.0 + .as_mut() + .expect("an armed subprocess guard owns its child") + } + + fn terminate_and_reap(&mut self) -> io::Result { + let child = self.child_mut(); + let kill_error = match child.kill() { + Ok(()) => None, + Err(error) if error.kind() == io::ErrorKind::InvalidInput => None, + Err(error) => Some(error), + }; + let deadline = Instant::now() + REAP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(error) => return Err(kill_error.unwrap_or(error)), + } + if Instant::now() >= deadline { + return Err(kill_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::TimedOut, + "isolated subprocess was not reaped after termination", + ) + })); + } + thread::sleep(POLL_INTERVAL); + } + } + + fn disarm(&mut self) { + self.0.take(); + } +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if self.0.is_some() { + let _ = self.terminate_and_reap(); + } + } +} + +struct PipeReader { + stream: &'static str, + receiver: mpsc::Receiver>>, + handle: Option>, +} + +impl PipeReader { + fn finish(mut self, deadline: Instant) -> io::Result> { + let remaining = deadline.saturating_duration_since(Instant::now()); + let result = match self.receiver.recv_timeout(remaining) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("did not drain isolated {} before the deadline", self.stream), + )), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::other(format!( + "isolated {} reader stopped without a result", + self.stream + ))), + }; + + let handle = self + .handle + .as_ref() + .expect("an unfinished pipe reader owns its thread"); + while !handle.is_finished() && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + if !handle.is_finished() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("isolated {} reader did not stop", self.stream), + )); + } + if self + .handle + .take() + .expect("a finished pipe reader owns its thread") + .join() + .is_err() + { + return Err(io::Error::other(format!( + "isolated {} reader panicked", + self.stream + ))); + } + result + } +} + +fn start_reader( + mut pipe: impl Read + Send + 'static, + stream: &'static str, + failure: Option, + position: ReaderStartFailure, +) -> io::Result { + if failure == Some(position) { + let ordinal = match position { + ReaderStartFailure::First => "first", + ReaderStartFailure::Second => "second", + }; + return Err(io::Error::other(format!( + "injected {ordinal} reader setup failure" + ))); + } + + let (sender, receiver) = mpsc::channel(); + let handle = thread::Builder::new() + .name(format!("process-wrap-{stream}-reader")) + .spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.read_to_end(&mut bytes).map(|_| bytes); + let _ = sender.send(result); + }) + .map_err(|error| { + io::Error::new( + error.kind(), + format!("could not start isolated {stream} reader: {error}"), + ) + })?; + Ok(PipeReader { + stream, + receiver, + handle: Some(handle), + }) +} + +fn finish_readers( + status: ExitStatus, + stdout: PipeReader, + stderr: PipeReader, +) -> io::Result { + let deadline = Instant::now() + REAP_TIMEOUT; + let stdout = stdout.finish(deadline); + let stderr = stderr.finish(deadline); + Ok(Output { + status, + stdout: stdout?, + stderr: stderr?, + }) +} + +fn preserve_after_cleanup( + child: &mut ChildGuard, + stdout: Option, + stderr: Option, + primary: io::Error, +) -> io::Error { + let process_cleanup = child.terminate_and_reap(); + if process_cleanup.is_ok() { + child.disarm(); + } + let deadline = Instant::now() + REAP_TIMEOUT; + let stdout_cleanup = stdout.map(|reader| reader.finish(deadline)); + let stderr_cleanup = stderr.map(|reader| reader.finish(deadline)); + let cleanup_error = process_cleanup + .err() + .or_else(|| stdout_cleanup.and_then(Result::err)) + .or_else(|| stderr_cleanup.and_then(Result::err)); + match cleanup_error { + Some(cleanup) => io::Error::new( + primary.kind(), + format!("{primary}; subprocess cleanup also failed: {cleanup}"), + ), + None => primary, + } +} + +pub(crate) fn run( + command: std::process::Command, + timeout: Duration, + reader_failure: Option, +) -> io::Result<(Output, bool)> { + run_after_start(command, timeout, reader_failure, || Ok(())) +} + +pub(crate) fn run_after_start( + mut command: std::process::Command, + timeout: Duration, + reader_failure: Option, + ready: impl FnOnce() -> io::Result<()>, +) -> io::Result<(Output, bool)> { + let spawned = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let mut child = ChildGuard::new(spawned); + + let stdout = child + .child_mut() + .stdout + .take() + .ok_or_else(|| io::Error::other("isolated subprocess stdout was not captured"))?; + let stderr = child + .child_mut() + .stderr + .take() + .ok_or_else(|| io::Error::other("isolated subprocess stderr was not captured"))?; + + let stdout = match start_reader(stdout, "stdout", reader_failure, ReaderStartFailure::First) { + Ok(reader) => reader, + Err(error) => { + drop(stderr); + return Err(preserve_after_cleanup(&mut child, None, None, error)); + } + }; + let stderr = match start_reader(stderr, "stderr", reader_failure, ReaderStartFailure::Second) { + Ok(reader) => reader, + Err(error) => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + None, + error, + )); + } + }; + + if let Err(error) = ready() { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + error, + )); + } + let deadline = match Instant::now().checked_add(timeout) { + Some(deadline) => deadline, + None => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + io::Error::new( + io::ErrorKind::InvalidInput, + "subprocess timeout is too large", + ), + )); + } + }; + + loop { + let status = match child.child_mut().try_wait() { + Ok(status) => status, + Err(error) => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + error, + )); + } + }; + if let Some(status) = status { + let output = finish_readers(status, stdout, stderr)?; + child.disarm(); + return Ok((output, false)); + } + if Instant::now() >= deadline { + let status = match child.terminate_and_reap() { + Ok(status) => status, + Err(error) => { + return Err(preserve_after_cleanup( + &mut child, + Some(stdout), + Some(stderr), + error, + )); + } + }; + let output = finish_readers(status, stdout, stderr)?; + child.disarm(); + return Ok((output, true)); + } + thread::sleep(POLL_INTERVAL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg_attr(miri, ignore = "requires a native child process")] + fn reader_start_failures_terminate_reap_and_join_before_returning() { + const MARKER_ENV: &str = "PROCESS_WRAP_READER_SETUP_MARKER"; + + for failure in [ReaderStartFailure::First, ReaderStartFailure::Second] { + let directory = tempfile::tempdir().expect("create setup-failure directory"); + let marker = directory.path().join("delayed-marker"); + + #[cfg(unix)] + let mut command = { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 0.3; : > \"$PROCESS_WRAP_READER_SETUP_MARKER\""]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = std::process::Command::new("cmd.exe"); + command.args([ + "/D", + "/S", + "/C", + "ping 127.0.0.1 -n 2 >NUL & type NUL > \"%PROCESS_WRAP_READER_SETUP_MARKER%\"", + ]); + command + }; + command.env(MARKER_ENV, &marker); + + let error = run(command, REAP_TIMEOUT, Some(failure)) + .expect_err("reader setup must fail at the injected position"); + let ordinal = match failure { + ReaderStartFailure::First => "first", + ReaderStartFailure::Second => "second", + }; + assert_eq!( + error.to_string(), + format!("injected {ordinal} reader setup failure"), + "the injected error is returned only after cleanup succeeds" + ); + #[cfg(unix)] + thread::sleep(Duration::from_millis(750)); + #[cfg(windows)] + thread::sleep(Duration::from_millis(1_500)); + assert!( + !marker.exists(), + "a setup failure must terminate the child before its delayed marker" + ); + } + } +} diff --git a/tests/tokio_pty_provider.rs b/tests/tokio_pty_provider.rs index 1a6e64d..833f930 100644 --- a/tests/tokio_pty_provider.rs +++ b/tests/tokio_pty_provider.rs @@ -18,17 +18,37 @@ use std::mem::MaybeUninit; use std::{ io, panic::{AssertUnwindSafe, catch_unwind, panic_any}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, mpsc}, time::{Duration, Instant}, }; use nix::libc; use process_wrap::tokio::{ - ChildWrapper, Command, CommandWrapper, ProviderProduct, Pty, PtyController, PtySize, - SpawnAttempt, SpawnProvider, + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrapper, + PendingChildWrapper, ProviderProduct, Pty, PtyController, PtySize, SpawnAttempt, SpawnProvider, }; use tokio::{io::AsyncReadExt, time::timeout}; +#[test] +fn capability_queries_agree_on_supported_platforms() { + let checked = Pty::check_supported().is_ok(); + assert!(checked); + assert_eq!(Pty::is_supported(), checked); +} + +#[test] +fn capability_queries_ignore_invalid_configuration_on_supported_platforms() { + let invalid = Pty::new(PtySize { + rows: 0, + columns: 0, + pixel_width: 0, + pixel_height: 0, + }); + + assert!(Pty::check_supported().is_ok()); + assert!(invalid.check_available().is_ok()); +} + fn controller(child: &mut Box) -> PtyController { child .take_pty_controller() @@ -49,19 +69,31 @@ async fn output(child: &mut dyn ChildWrapper, controller: PtyController) -> io:: } #[derive(Debug)] -struct TransparentChild(Box); +struct TransparentChild(Option>); + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed transparent layer owns its child") } } @@ -71,27 +103,39 @@ struct Transparent; impl CommandWrapper for Transparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { - Ok(Box::new(TransparentChild(child))) + ) -> io::Result> { + Ok(Some(PendingChildWrapper::new(TransparentChild(None)))) } } #[derive(Debug)] -struct SecondTransparentChild(Box); +struct SecondTransparentChild(Option>); + +impl ChildWrapperLayer for SecondTransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for SecondTransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.0.as_ref() + self.0 + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.0.as_mut() + self.0 + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.0 + .take() + .expect("an installed transparent layer owns its child") } } @@ -101,10 +145,10 @@ struct SecondTransparent; impl CommandWrapper for SecondTransparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { - Ok(Box::new(SecondTransparentChild(child))) + ) -> io::Result> { + Ok(Some(PendingChildWrapper::new(SecondTransparentChild(None)))) } } @@ -225,15 +269,15 @@ struct InspectWrap { impl CommandWrapper for InspectWrap { fn wrap_child( &mut self, - mut child: Box, + child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { + ) -> io::Result> { assert!(child.take_pty_controller().is_none()); *self .called .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = true; - Ok(child) + Ok(None) } } @@ -261,6 +305,131 @@ async fn controller_is_committed_after_child_wrapping_hooks() -> io::Result<()> #[derive(Debug)] struct SpawnPostAttempt; +const SYNCHRONOUS_POLL_TIMEOUT: Duration = Duration::from_secs(5); +const SYNCHRONOUS_CLEANUP_TIMEOUT: Duration = Duration::from_secs(1); +const SYNCHRONOUS_POLL_INTERVAL: Duration = Duration::from_millis(10); + +fn poll_until_exit( + timeout: Duration, + mut try_wait: impl FnMut() -> io::Result>, +) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + if try_wait()?.is_some() { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + std::thread::sleep(SYNCHRONOUS_POLL_INTERVAL.min(deadline - now)); + } +} + +fn polling_timeout(stage: &str, pid: Option, cleanup: &str) -> io::Error { + let process = pid.map_or_else( + || "child with unknown PID".to_owned(), + |pid| format!("PID {pid}"), + ); + io::Error::new( + io::ErrorKind::TimedOut, + format!("{stage} timed out waiting for {process}; {cleanup}"), + ) +} + +fn wait_for_native_probe_exit( + probe: &mut tokio::process::Child, + timeout: Duration, + stage: &str, +) -> io::Result<()> { + if poll_until_exit(timeout, || probe.try_wait())? { + return Ok(()); + } + + let pid = probe.id(); + let kill_error = probe.start_kill().err(); + let reaped = poll_until_exit(SYNCHRONOUS_CLEANUP_TIMEOUT, || probe.try_wait())?; + let cleanup = if reaped { + "the directly owned child was reaped after a kill request".to_owned() + } else if let Some(error) = kill_error { + format!("kill failed ({error}) and the directly owned child was not reaped") + } else { + "the directly owned child did not become reapable after a kill request".to_owned() + }; + Err(polling_timeout(stage, pid, &cleanup)) +} + +fn wait_for_provider_child_exit( + child: &mut dyn ChildWrapper, + timeout: Duration, + stage: &str, +) -> io::Result<()> { + if poll_until_exit(timeout, || child.try_wait())? { + return Ok(()); + } + + let pid = child.id(); + let kill_error = child.start_kill().err(); + let reaped = poll_until_exit(SYNCHRONOUS_CLEANUP_TIMEOUT, || child.try_wait())?; + let cleanup = if reaped { + "the provider child exited after a kill request; transaction rollback remains armed" + .to_owned() + } else if let Some(error) = kill_error { + format!("best-effort kill failed ({error}); transaction rollback remains armed") + } else { + "bounded cleanup did not observe exit; transaction rollback remains armed".to_owned() + }; + Err(polling_timeout(stage, pid, &cleanup)) +} + +#[tokio::test] +async fn synchronous_native_probe_polling_times_out_and_reaps() -> io::Result<()> { + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "while :; do sleep 1; done"]); + let child = command.spawn()?; + let pid = child.id().expect("the long-lived probe has a PID"); + let (finished, finished_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let mut child = child; + let result = wait_for_native_probe_exit( + &mut child, + Duration::from_millis(50), + "controlled native probe", + ); + let reaped = child.try_wait(); + finished.send((result, reaped)).unwrap(); + }); + + let report = match finished_rx.recv_timeout(Duration::from_secs(2)) { + Ok(report) => report, + Err(mpsc::RecvTimeoutError::Timeout) => { + // SAFETY: `pid` identifies the still-live controlled child; this external release keeps the + // unbounded mutation from leaking its process or worker thread. + unsafe { libc::kill(i32::try_from(pid).unwrap(), libc::SIGKILL) }; + finished_rx + .recv_timeout(Duration::from_secs(2)) + .expect("the externally released polling worker returns") + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the polling worker disconnected without a result") + } + }; + worker.join().unwrap(); + + let error = report + .0 + .expect_err("the local polling deadline must expire for a long-lived child"); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + let message = error.to_string(); + assert!(message.contains("controlled native probe")); + assert!(message.contains(&format!("PID {pid}"))); + assert!( + report.1?.is_some(), + "the directly owned probe must be reaped before timeout returns" + ); + Ok(()) +} + impl CommandWrapper for SpawnPostAttempt { fn post_spawn( &mut self, @@ -269,12 +438,11 @@ impl CommandWrapper for SpawnPostAttempt { _command: &Command, ) -> io::Result<()> { let mut probe = attempt.native_mut().spawn()?; - loop { - if probe.try_wait()?.is_some() { - return Ok(()); - } - std::thread::yield_now(); - } + wait_for_native_probe_exit( + &mut probe, + SYNCHRONOUS_POLL_TIMEOUT, + "post_spawn native probe", + ) } } @@ -433,14 +601,14 @@ impl CommandWrapper for FailAfterSpawn { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _command: &Command, - ) -> io::Result> { + ) -> io::Result> { if matches!(self.stage, Stage::WrapChild) { - self.record(child.as_ref()); + self.record(child); self.fail() } else { - Ok(child) + Ok(None) } } } @@ -474,12 +642,12 @@ impl CommandWrapper for ReapThenFail { .pid .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = child.id(); - loop { - if child.try_wait()?.is_some() { - return Err(io::Error::other("fail after reaping PTY child")); - } - std::thread::yield_now(); - } + wait_for_provider_child_exit( + child, + SYNCHRONOUS_POLL_TIMEOUT, + "post_spawn PTY child reaping", + )?; + Err(io::Error::other("fail after reaping PTY child")) } } diff --git a/tests/tokio_pty_unix.rs b/tests/tokio_pty_unix.rs index 06be9a1..f97c651 100644 --- a/tests/tokio_pty_unix.rs +++ b/tests/tokio_pty_unix.rs @@ -17,6 +17,8 @@ use std::any::TypeId; #[cfg(feature = "reset-sigmask")] use std::os::unix::process::ExitStatusExt; +#[cfg(any(feature = "process-group", feature = "process-session"))] +use std::time::Instant; use std::{io, process::ExitStatus, time::Duration}; #[cfg(all(feature = "kill-on-drop", feature = "process-session"))] @@ -229,6 +231,16 @@ async fn shutting_down_input_does_not_hang_up_while_output_exists() -> io::Resul resize.resize(PtySize::default())?; drop(output); + assert_eq!( + resize + .resize(PtySize { + rows: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); assert_eq!( resize.resize(PtySize::default()).unwrap_err().kind(), io::ErrorKind::BrokenPipe @@ -252,6 +264,16 @@ async fn dropping_output_does_not_hang_up_while_input_exists() -> io::Result<()> assert!(child.try_wait()?.is_none()); resize.resize(PtySize::default())?; drop(input); + assert_eq!( + resize + .resize(PtySize { + columns: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); assert_eq!( resize.resize(PtySize::default()).unwrap_err().kind(), io::ErrorKind::BrokenPipe @@ -399,6 +421,58 @@ async fn cancelling_wait_preserves_child_ownership() -> io::Result<()> { #[derive(Debug)] struct ReapBeforeChildWrapping; +#[cfg(any(feature = "process-group", feature = "process-session"))] +const SYNCHRONOUS_POLL_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(any(feature = "process-group", feature = "process-session"))] +const SYNCHRONOUS_CLEANUP_TIMEOUT: Duration = Duration::from_secs(1); +#[cfg(any(feature = "process-group", feature = "process-session"))] +const SYNCHRONOUS_POLL_INTERVAL: Duration = Duration::from_millis(10); + +#[cfg(any(feature = "process-group", feature = "process-session"))] +fn poll_until_exit( + timeout: Duration, + mut try_wait: impl FnMut() -> io::Result>, +) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + if try_wait()?.is_some() { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + std::thread::sleep(SYNCHRONOUS_POLL_INTERVAL.min(deadline - now)); + } +} + +#[cfg(any(feature = "process-group", feature = "process-session"))] +fn wait_for_provider_child_exit(child: &mut dyn ChildWrapper, stage: &str) -> io::Result<()> { + if poll_until_exit(SYNCHRONOUS_POLL_TIMEOUT, || child.try_wait())? { + return Ok(()); + } + + let pid = child.id(); + let kill_error = child.start_kill().err(); + let reaped = poll_until_exit(SYNCHRONOUS_CLEANUP_TIMEOUT, || child.try_wait())?; + let process = pid.map_or_else( + || "child with unknown PID".to_owned(), + |pid| format!("PID {pid}"), + ); + let cleanup = if reaped { + "the provider child exited after a kill request; transaction rollback remains armed" + .to_owned() + } else if let Some(error) = kill_error { + format!("best-effort kill failed ({error}); transaction rollback remains armed") + } else { + "bounded cleanup did not observe exit; transaction rollback remains armed".to_owned() + }; + Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("{stage} timed out waiting for {process}; {cleanup}"), + )) +} + #[cfg(any(feature = "process-group", feature = "process-session"))] impl CommandWrapper for ReapBeforeChildWrapping { fn post_spawn( @@ -407,12 +481,10 @@ impl CommandWrapper for ReapBeforeChildWrapping { child: &mut dyn ChildWrapper, _command: &Command, ) -> io::Result<()> { - loop { - if child.try_wait()?.is_some() { - return Ok(()); - } - std::thread::yield_now(); - } + wait_for_provider_child_exit( + child, + "post_spawn PTY child reaping before wrapper installation", + ) } } @@ -467,7 +539,7 @@ async fn assert_group_signal(mut command: Command) -> io::Result<()> { "stty -echo; trap '' HUP; trap 'exit 0' TERM; sleep 30 & printf ready; wait", ]); let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(child.inner().type_id(), TypeId::of::()); assert!(child.try_wait()?.is_none()); let (input, mut output, _resize) = controller.into_parts(); @@ -536,7 +608,7 @@ async fn process_group_composes_when_registered_after_first_spawn() -> io::Resul command.wrap(ProcessGroup::leader()); let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; - assert_eq!(child.as_ref().type_id(), TypeId::of::()); + assert_eq!(child.inner().type_id(), TypeId::of::()); let (input, mut output, _resize) = controller.into_parts(); drop(input); let (status, bytes) = wait_and_drain(child.as_mut(), &mut output).await?; diff --git a/tests/tokio_pty_unsupported.rs b/tests/tokio_pty_unsupported.rs index b20847c..7137aa1 100644 --- a/tests/tokio_pty_unsupported.rs +++ b/tests/tokio_pty_unsupported.rs @@ -9,7 +9,8 @@ target_os = "macos", target_os = "netbsd", target_os = "openbsd", - target_os = "solaris" + target_os = "solaris", + windows )) ))] @@ -21,7 +22,33 @@ use std::{ }, }; -use process_wrap::tokio::{Command, CommandWrapper, Pty, PtySize, SpawnAttempt}; +use process_wrap::tokio::{Command, CommandWrapper, Pty, PtySize, SpawnAttempt, SpawnProvider}; + +#[test] +fn capability_queries_agree_on_unsupported_platforms() { + let checked = Pty::check_supported(); + assert_eq!(checked.unwrap_err().kind(), io::ErrorKind::Unsupported); + assert!(!Pty::is_supported()); +} + +#[test] +fn capability_queries_ignore_invalid_configuration_on_unsupported_platforms() { + let invalid = Pty::new(PtySize { + rows: 0, + columns: 0, + pixel_width: 0, + pixel_height: 0, + }); + + assert_eq!( + Pty::check_supported().unwrap_err().kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + invalid.check_available().unwrap_err().kind(), + io::ErrorKind::Unsupported + ); +} #[test] fn spawning_is_explicitly_unsupported() { diff --git a/tests/tokio_pty_windows.rs b/tests/tokio_pty_windows.rs new file mode 100644 index 0000000..2846609 --- /dev/null +++ b/tests/tokio_pty_windows.rs @@ -0,0 +1,1720 @@ +#![cfg(all(windows, feature = "pty"))] + +use std::{ + env, + io::{self, Read, Write}, + os::windows::{ + io::{AsRawHandle, FromRawHandle, IntoRawHandle, OwnedHandle}, + process::CommandExt, + }, + panic::{AssertUnwindSafe, catch_unwind}, + path::{Path, PathBuf}, + process::{ExitStatus, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +#[cfg(feature = "creation-flags")] +use process_wrap::tokio::CreationFlags; +#[cfg(feature = "job-object")] +use process_wrap::tokio::JobObject; +#[cfg(feature = "kill-on-drop")] +use process_wrap::tokio::KillOnDrop; +use process_wrap::tokio::{ + ChildWrapper, ChildWrapperLayer, ChildWrapperSlots, Command, CommandWrapper, + PendingChildWrapper, ProviderProduct, Pty, PtyController, PtyOutput, PtySize, SpawnAttempt, + SpawnProvider, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::timeout, +}; +use windows::Win32::{ + Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}, + System::{ + Console::{ + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, + ENABLE_VIRTUAL_TERMINAL_INPUT, GetConsoleMode, GetConsoleScreenBufferInfo, + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + }, + Threading::{ + CreateEventW, DETACHED_PROCESS, OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, + SetEvent, TerminateProcess, WaitForSingleObject, + }, + }, +}; +use windows::core::PCWSTR; + +#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[allow(dead_code)] +#[path = "support/windows_thread.rs"] +mod windows_thread; + +const HELPER_MODE: &str = "PROCESS_WRAP_CONPTY_HELPER"; +const EXPECT_CONPTY: &str = "PROCESS_WRAP_EXPECT_CONPTY"; +const TIMEOUT: Duration = Duration::from_secs(10); +const DROP_CLEANUP_TIMEOUT_MS: u32 = 1_000; + +fn conpty_is_required() -> bool { + matches!(env::var(EXPECT_CONPTY), Ok(value) if value == "1") +} + +#[derive(Debug)] +enum ConPtyDecision { + Run, + Skip(io::Error), +} + +fn require_conpty_result(capability: io::Result<()>, required: bool) -> io::Result { + match capability { + Ok(()) => Ok(ConPtyDecision::Run), + Err(error) if error.kind() == io::ErrorKind::Unsupported && required => { + Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "ConPTY is required because {EXPECT_CONPTY}=1, but it is unavailable: {error}" + ), + )) + } + Err(error) if error.kind() == io::ErrorKind::Unsupported => Ok(ConPtyDecision::Skip(error)), + Err(error) => Err(error), + } +} + +macro_rules! require_conpty { + () => { + match require_conpty_result(Pty::check_supported(), conpty_is_required()) { + Ok(ConPtyDecision::Run) => {} + Ok(ConPtyDecision::Skip(error)) => { + eprintln!("skipping ConPTY-dependent test: {error}"); + return Ok(()); + } + Err(error) => return Err(error), + } + }; +} + +fn helper(mode: &str) -> io::Result { + let mut command = Command::new(env::current_exe()?); + command + .args(["--exact", "conpty_child_helper", "--nocapture"]) + .env(HELPER_MODE, mode); + Ok(command) +} + +fn take_controller(child: &mut dyn ChildWrapper) -> PtyController { + let controller = child + .take_pty_controller() + .expect("a successful PTY spawn installs one controller"); + assert!( + child.take_pty_controller().is_none(), + "the PTY controller can only be extracted once" + ); + controller +} + +#[test] +fn capability_queries_agree_with_windows_backend() { + let checked = Pty::check_supported().is_ok(); + assert_eq!(Pty::is_supported(), checked); +} + +#[test] +fn expected_support_rejects_injected_unsupported_capability() { + let unsupported = || { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "injected missing ConPTY capability", + )) + }; + let error = require_conpty_result(unsupported(), true) + .expect_err("expected-support mode must reject an unavailable ConPTY backend"); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert!(error.to_string().contains(EXPECT_CONPTY)); + + let decision = require_conpty_result(unsupported(), false).unwrap(); + let ConPtyDecision::Skip(error) = decision else { + panic!("optional ConPTY mode must retain the unavailable-runtime skip"); + }; + assert_eq!(error.kind(), io::ErrorKind::Unsupported); +} + +#[tokio::test] +async fn direct_batch_is_rejected_and_explicit_cmd_executes_it() -> io::Result<()> { + require_conpty!(); + let directory = tempfile::tempdir()?; + let script = directory.path().join("explicit-batch.CmD"); + std::fs::write(&script, b"@echo off\r\necho PW-BATCH-OK\r\n")?; + + let mut direct = Command::new(&script); + direct.wrap(Pty::default()); + let error = direct + .spawn() + .expect_err("the Windows PTY provider rejects direct batch execution"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "Windows PTY spawning does not execute batch scripts directly" + ); + + let mut explicit = Command::with_new("cmd.exe", |command| { + command.args(["/d", "/s", "/c"]).arg(&script); + }); + explicit.wrap(Pty::default()); + let mut child = explicit.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + assert!(child.wait().await?.success()); + assert!(String::from_utf8_lossy(&bytes).contains("PW-BATCH-OK")); + Ok(()) +} + +#[test] +fn capability_queries_ignore_invalid_configuration_on_windows() { + let invalid = Pty::new(PtySize { + rows: 0, + columns: 0, + pixel_width: 0, + pixel_height: 0, + }); + let checked = Pty::check_supported().is_ok(); + + assert_eq!(invalid.check_available().is_ok(), checked); +} + +#[derive(Debug)] +struct ObservePreSpawn(Arc); + +impl CommandWrapper for ObservePreSpawn { + fn pre_spawn(&mut self, _attempt: &mut SpawnAttempt, _command: &Command) -> io::Result<()> { + self.0.store(true, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn oversized_windows_size_is_rejected_before_hooks_without_changing_capability() -> io::Result<()> { + let oversized = Pty::new(PtySize { + rows: 1, + columns: 32_768, + pixel_width: 0, + pixel_height: 0, + }); + let validation_error = oversized + .validate_command(&Command::new("must-not-run")) + .expect_err("Windows COORD overflow must be rejected during immutable validation"); + assert_eq!(validation_error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + validation_error.to_string(), + "PTY columns must not exceed 32767 on Windows" + ); + + let supported = Pty::check_supported(); + let available = oversized.check_available(); + match (&supported, &available) { + (Ok(()), Ok(())) => {} + (Err(supported), Err(available)) => { + assert_eq!(available.kind(), supported.kind()); + assert_eq!(available.to_string(), supported.to_string()); + } + _ => panic!("capability queries disagree: {supported:?} != {available:?}"), + } + + let called = Arc::new(AtomicBool::new(false)); + let mut command = Command::new("must-not-run"); + command + .wrap(oversized) + .wrap(ObservePreSpawn(Arc::clone(&called))); + let spawn_error = match command.spawn() { + Ok(_) => panic!("an oversized Windows PTY spawn must fail"), + Err(error) => error, + }; + let expected_kind = match supported { + Ok(()) => io::ErrorKind::InvalidInput, + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + io::ErrorKind::Unsupported + } + }; + assert_eq!(spawn_error.kind(), expected_kind); + assert!( + !called.load(Ordering::SeqCst), + "capability and immutable validation must both precede pre_spawn hooks" + ); + Ok(()) +} + +fn spawn_with_terminal( + command: &mut Command, + size: PtySize, +) -> io::Result<(Box, PtyController)> { + command.wrap(Pty::new(size)); + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + Ok((child, controller)) +} + +fn descendant_command(release: &Path) -> io::Result { + let mut command = std::process::Command::new(env::current_exe()?); + command + .args(["--exact", "conpty_child_helper", "--nocapture"]) + .env(HELPER_MODE, "descendant") + .env("PW_RELEASE", release) + .env_remove("PW_DESCENDANT_PID"); + Ok(command) +} + +fn spawn_descendant(release: &Path) -> io::Result { + descendant_command(release)?.spawn() +} + +fn spawn_detached_descendant(release: &Path) -> io::Result { + descendant_command(release)? + .creation_flags(DETACHED_PROCESS.0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() +} + +struct ReleaseOnDrop(PathBuf); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = std::fs::File::create(&self.0); + } +} + +#[derive(Debug)] +struct ProcessExitGuard(Option); + +// SAFETY: the normal constructor installs a uniquely owned process handle with no thread affinity. +// Moving the guard transfers sole close authority without exposing Rust memory. The drop regression +// constructs its event-backed guard only after the independently owned duplicate reaches its worker, +// so that controlled event handle does not rely on this Send implementation. +unsafe impl Send for ProcessExitGuard {} + +impl ProcessExitGuard { + fn open(pid: u32) -> io::Result { + // SAFETY: the access mask is valid for process handles, the numeric PID carries no borrowed + // memory, and a successful call returns a new handle owned by this guard. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } + .map_err(io::Error::other)?; + Ok(Self(Some(handle))) + } + + fn has_exited(&self) -> io::Result { + // SAFETY: the guard still owns this live process handle for the duration of the nonblocking wait. + let wait = unsafe { WaitForSingleObject(self.handle(), 0) }; + if wait == WAIT_FAILED { + Err(io::Error::last_os_error()) + } else { + Ok(wait == WAIT_OBJECT_0) + } + } + + fn disarm(mut self) -> io::Result<()> { + if let Some(handle) = self.0.take() { + // SAFETY: taking the handle removes the guard's only owner, so this closes it exactly once. + unsafe { CloseHandle(handle) }?; + } + Ok(()) + } + + fn handle(&self) -> HANDLE { + self.0 + .expect("only ProcessExitGuard::disarm clears the handle, and it consumes the guard") + } +} + +impl Drop for ProcessExitGuard { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + // SAFETY: handle remains live through the close below. In normal use it is the uniquely owned + // process handle from OpenProcess. The controlled regression instead supplies a live event + // handle, which TerminateProcess rejects without consuming or closing it. + unsafe { TerminateProcess(handle, 1) }.ok(); + // SAFETY: process handles and the regression's event handle are waitable. This guard has sole + // close authority for this handle value, which remains live throughout the bounded wait. + unsafe { WaitForSingleObject(handle, DROP_CLEANUP_TIMEOUT_MS) }; + // SAFETY: taking the handle removed the guard's only close owner. The wait has returned, and no + // subsequent operation uses this handle value, so this closes it exactly once. + unsafe { CloseHandle(handle) }.ok(); + } + } +} + +#[test] +fn process_exit_guard_drop_is_bounded_when_termination_fails() { + // SAFETY: null security attributes and name request a new, unnamed manual-reset event. The + // successful call returns one owned handle, transferred exactly once into controller. + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) }.unwrap(); + // SAFETY: CreateEventW returned a live, uniquely owned handle, whose ownership is transferred + // exactly once here. + let controller = unsafe { OwnedHandle::from_raw_handle(event.0) }; + // try_clone creates a separately owned handle to the same waitable event object. The controller + // remains independent so timeout recovery can signal the event and release a blocked guard drop + // before the worker joins. + let wait_handle = controller.try_clone().unwrap(); + let (started, observe_start) = mpsc::channel(); + let (completed, observe_completion) = mpsc::channel(); + let worker = thread::spawn(move || { + // This controlled regression deliberately transfers an event handle, not a process handle, + // into the guard. The event is waitable, TerminateProcess rejects it, and into_raw_handle + // transfers the duplicate's sole close authority to the guard. + let guard = ProcessExitGuard(Some(HANDLE(wait_handle.into_raw_handle()))); + started.send(()).unwrap(); + drop(guard); + completed.send(()).unwrap(); + }); + observe_start.recv().unwrap(); + + let watchdog = Duration::from_millis(u64::from(DROP_CLEANUP_TIMEOUT_MS) * 5); + let completion = observe_completion.recv_timeout(watchdog); + if completion.is_err() { + // SAFETY: controller still owns the live event handle with event-modification access. Signaling + // changes the event state without transferring or closing the controller handle. + unsafe { SetEvent(HANDLE(controller.as_raw_handle())) }.unwrap(); + } + worker.join().unwrap(); + assert!( + completion.is_ok(), + "ProcessExitGuard::drop exceeded its {watchdog:?} watchdog" + ); +} + +async fn wait_for_process_exit(guard: ProcessExitGuard) -> io::Result<()> { + let deadline = Instant::now() + TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "ConPTY process survived the failed spawn lifecycle", + )); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn wait_for_pid_file(path: &Path) -> io::Result { + let deadline = Instant::now() + TIMEOUT; + loop { + if let Ok(pid) = std::fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "ConPTY descendant did not report its process ID", + )); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +#[derive(Debug, PartialEq, Eq)] +struct MarkerSearch { + marker_start: Option, + first_candidate: usize, + candidates_examined: usize, +} + +fn scan_marker(bytes: &[u8], needle: &[u8], new_bytes_start: usize) -> MarkerSearch { + assert!( + !needle.is_empty(), + "read_through requires a non-empty marker" + ); + + let first_candidate = new_bytes_start.saturating_sub(needle.len() - 1); + let mut candidates_examined = 0; + for (offset, window) in bytes[first_candidate..].windows(needle.len()).enumerate() { + candidates_examined += 1; + if window == needle { + return MarkerSearch { + marker_start: Some(first_candidate + offset), + first_candidate, + candidates_examined, + }; + } + } + + MarkerSearch { + marker_start: None, + first_candidate, + candidates_examined, + } +} + +#[test] +fn marker_scan_finds_marker_in_first_chunk() { + assert_eq!( + scan_marker(b"prefix READY suffix", b"READY", 0), + MarkerSearch { + marker_start: Some(7), + first_candidate: 0, + candidates_examined: 8, + } + ); +} + +#[test] +fn marker_scan_detects_marker_across_chunk_boundary() { + let mut bytes = b"prefix RE".to_vec(); + let new_bytes_start = bytes.len(); + bytes.extend_from_slice(b"ADY suffix"); + + assert_eq!( + scan_marker(&bytes, b"READY", new_bytes_start), + MarkerSearch { + marker_start: Some(7), + first_candidate: 5, + candidates_examined: 3, + } + ); +} + +#[test] +fn marker_scan_skips_absent_chunks_before_finding_marker() { + let chunks: &[&[u8]] = &[b"prefix ", b"without ", b"marker ", b"REA", b"DY tail"]; + let mut bytes = Vec::new(); + let scans = chunks + .iter() + .map(|chunk| { + let new_bytes_start = bytes.len(); + bytes.extend_from_slice(chunk); + scan_marker(&bytes, b"READY", new_bytes_start) + }) + .collect::>(); + + assert_eq!(bytes, b"prefix without marker READY tail"); + assert!(scans[..4].iter().all(|scan| scan.marker_start.is_none())); + assert_eq!( + scans + .iter() + .map(|scan| scan.first_candidate) + .collect::>(), + [0, 3, 11, 18, 21] + ); + assert_eq!(scans[4].marker_start, Some(22)); + assert_eq!(scans[4].candidates_examined, 2); +} + +#[test] +fn marker_scan_does_not_rescan_a_large_examined_prefix() { + let needle = b"READY"; + let mut bytes = vec![b'x'; 1024 * 1024]; + let first_scan = scan_marker(&bytes, needle, 0); + let new_bytes_start = bytes.len(); + bytes.extend_from_slice(needle); + let second_scan = scan_marker(&bytes, needle, new_bytes_start); + + assert_eq!(first_scan.marker_start, None); + assert_eq!(first_scan.first_candidate, 0); + assert_eq!( + first_scan.candidates_examined, + new_bytes_start - needle.len() + 1 + ); + assert_eq!(second_scan.marker_start, Some(new_bytes_start)); + assert_eq!( + second_scan.first_candidate, + new_bytes_start - (needle.len() - 1) + ); + assert_eq!(second_scan.candidates_examined, needle.len()); + assert_eq!( + first_scan.candidates_examined + second_scan.candidates_examined, + new_bytes_start + 1 + ); +} + +async fn read_through(output: &mut PtyOutput, needle: &[u8]) -> io::Result> { + timeout(TIMEOUT, async { + let mut bytes = Vec::new(); + let mut buffer = [0; 1024]; + let mut new_bytes_start = 0; + loop { + if scan_marker(&bytes, needle, new_bytes_start) + .marker_start + .is_some() + { + return Ok(bytes); + } + + new_bytes_start = bytes.len(); + let read = output.read(&mut buffer).await?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "ConPTY output closed before the expected marker", + )); + } + bytes.extend_from_slice(&buffer[..read]); + } + }) + .await + .map_err(io::Error::other)? +} + +async fn wait_and_drain( + child: &mut dyn ChildWrapper, + output: &mut PtyOutput, + bytes: &mut Vec, +) -> io::Result { + let (status, _) = timeout(TIMEOUT, async { + tokio::try_join!(child.wait(), output.read_to_end(bytes)) + }) + .await + .map_err(io::Error::other)??; + Ok(status) +} + +fn terminal_handles() -> io::Result<(HANDLE, HANDLE, HANDLE)> { + // SAFETY: these calls only retrieve this process's standard handles. + unsafe { + Ok(( + GetStdHandle(STD_INPUT_HANDLE).map_err(io::Error::other)?, + GetStdHandle(STD_OUTPUT_HANDLE).map_err(io::Error::other)?, + GetStdHandle(STD_ERROR_HANDLE).map_err(io::Error::other)?, + )) + } +} + +fn is_console(handle: HANDLE) -> bool { + let mut mode = Default::default(); + // SAFETY: handle is borrowed from a live standard handle and mode is writable storage. + unsafe { GetConsoleMode(handle, &mut mode) }.is_ok() +} + +#[test] +fn conpty_child_helper() -> io::Result<()> { + let Ok(mode) = env::var(HELPER_MODE) else { + return Ok(()); + }; + let (stdin_handle, stdout_handle, stderr_handle) = terminal_handles()?; + + match mode.as_str() { + "terminal" => { + print!( + "PW-TERMINALS:{}{}{}:{}:REMOVED={}:CWD={}", + is_console(stdin_handle) as u8, + is_console(stdout_handle) as u8, + is_console(stderr_handle) as u8, + env::var("PW_VALUE").unwrap_or_default(), + env::var_os("PW_REMOVED").is_none() as u8, + env::current_dir()?.display(), + ); + eprint!("PW-STDERR"); + io::stdout().flush()?; + io::stderr().flush()?; + } + "io" => { + let mut console_mode = Default::default(); + // SAFETY: stdin_handle is the live console input handle and console_mode is writable. + unsafe { GetConsoleMode(stdin_handle, &mut console_mode) }.map_err(io::Error::other)?; + console_mode &= !(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT); + console_mode |= ENABLE_VIRTUAL_TERMINAL_INPUT; + // SAFETY: stdin_handle remains live and console_mode is a valid combination of input flags. + unsafe { SetConsoleMode(stdin_handle, console_mode) }.map_err(io::Error::other)?; + print!("PW-READY"); + io::stdout().flush()?; + let mut bytes = [0; 4]; + io::stdin().read_exact(&mut bytes)?; + let mut stdout = io::stdout(); + stdout.write_all(b"PW-BYTES:")?; + for byte in bytes { + write!(stdout, "{byte:02x}")?; + } + stdout.flush()?; + } + "size" => { + print_size(stdout_handle)?; + io::stdout().flush()?; + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + print_size(stdout_handle)?; + io::stdout().flush()?; + } + "descendant-parent" => { + let release = env::var_os("PW_RELEASE").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") + })?; + let descendant = spawn_descendant(Path::new(&release))?; + if let Some(pid_file) = env::var_os("PW_DESCENDANT_PID") { + std::fs::write(pid_file, descendant.id().to_string())?; + } + drop(descendant); + } + "descendant" => { + let release = env::var_os("PW_RELEASE").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") + })?; + print!("PW-DESCENDANT-READY"); + io::stdout().flush()?; + while !Path::new(&release).exists() { + std::thread::sleep(Duration::from_millis(10)); + } + } + "tree" => { + let release = env::var_os("PW_RELEASE").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_RELEASE is unset") + })?; + let pid_file = env::var_os("PW_DESCENDANT_PID").ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "PW_DESCENDANT_PID is unset") + })?; + let descendant = spawn_detached_descendant(Path::new(&release))?; + std::fs::write(pid_file, descendant.id().to_string())?; + drop(descendant); + print!("PW-TREE-READY"); + io::stdout().flush()?; + let mut byte = [0]; + loop { + io::stdin().read_exact(&mut byte)?; + } + } + "wait" => { + print!("PW-READY"); + io::stdout().flush()?; + let mut byte = [0]; + loop { + io::stdin().read_exact(&mut byte)?; + } + } + other => panic!("unknown ConPTY helper mode {other}"), + } + Ok(()) +} + +fn print_size(output: HANDLE) -> io::Result<()> { + let mut info = Default::default(); + // SAFETY: output is a live console output handle and info is writable output storage. + unsafe { GetConsoleScreenBufferInfo(output, &mut info) }.map_err(io::Error::other)?; + print!("PW-SIZE:{}x{}", info.dwSize.Y, info.dwSize.X); + Ok(()) +} + +#[tokio::test] +async fn expected_supported_conpty_preflight_performs_a_real_spawn() -> io::Result<()> { + if !conpty_is_required() { + eprintln!("expected-supported ConPTY preflight is disabled"); + return Ok(()); + } + match require_conpty_result(Pty::check_supported(), true)? { + ConPtyDecision::Run => {} + ConPtyDecision::Skip(_) => unreachable!("required ConPTY cannot resolve to a skip"), + } + + let mut command = helper("terminal")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(b"PW-TERMINALS:111".len()) + .any(|window| window == b"PW-TERMINALS:111") + ); + eprintln!("expected-supported ConPTY preflight executed a real PTY spawn"); + Ok(()) +} + +#[tokio::test] +async fn spawns_with_three_console_standard_handles() -> io::Result<()> { + require_conpty!(); + let mut command = helper("terminal")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + + let mut bytes = read_through(&mut output, b"PW-TERMINALS:111").await?; + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + Ok(()) +} + +#[tokio::test] +async fn exposes_terminal_handles_and_preserves_environment_cwd_and_merged_output() -> io::Result<()> +{ + require_conpty!(); + let directory = tempfile::tempdir()?; + let system_root = env::var_os("SystemRoot").expect("Windows must define SystemRoot"); + let mut command = helper("terminal")?; + command + .env("PW_DISCARDED", "before-clear") + .env_clear() + .env("SystemRoot", system_root) + .env(HELPER_MODE, "terminal") + .env("PW_VALUE", "child-value") + .env("PW_REMOVED", "discarded") + .env_remove("PW_REMOVED") + .current_dir(directory.path()); + + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + let marker = format!( + "PW-TERMINALS:111:child-value:REMOVED=1:CWD={}", + directory.path().display() + ); + assert!( + bytes + .windows(marker.len()) + .any(|window| window == marker.as_bytes()), + "{}", + String::from_utf8_lossy(&bytes) + ); + assert!( + bytes + .windows(b"PW-STDERR".len()) + .any(|window| window == b"PW-STDERR"), + "{}", + String::from_utf8_lossy(&bytes) + ); + Ok(()) +} + +#[tokio::test] +async fn passes_bidirectional_terminal_bytes() -> io::Result<()> { + require_conpty!(); + let mut command = helper("io")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (mut input, mut output, _resize) = controller.into_parts(); + let mut bytes = read_through(&mut output, b"PW-READY").await?; + + let controls = [b'A', 0x1b, b'B', b'C']; + input.write_all(&controls).await?; + input.shutdown().await?; + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + let expected = b"PW-BYTES:411b4243"; + assert!( + bytes + .windows(expected.len()) + .any(|window| window == expected), + "{bytes:?}" + ); + Ok(()) +} + +#[tokio::test] +async fn reports_initial_size_and_resize() -> io::Result<()> { + require_conpty!(); + let mut command = helper("size")?; + let initial = PtySize::new(31, 97)?; + let (mut child, controller) = spawn_with_terminal(&mut command, initial)?; + let (mut input, mut output, resize) = controller.into_parts(); + read_through(&mut output, b"PW-SIZE:31x97").await?; + resize.resize(PtySize::new(42, 113)?)?; + input.write_all(b"go\r").await?; + read_through(&mut output, b"PW-SIZE:42x113").await?; + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + Ok(()) +} + +#[tokio::test] +async fn canceled_wait_kill_and_repeated_waits_remain_valid() -> io::Result<()> { + require_conpty!(); + let mut command = helper("wait")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + assert!( + timeout(Duration::from_millis(20), child.wait()) + .await + .is_err() + ); + child.start_kill()?; + let status = timeout(TIMEOUT, child.wait()).await??; + assert_eq!(child.try_wait()?, Some(status)); + assert_eq!(child.wait().await?, status); + drop(input); + let mut trailing = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut trailing)).await??; + Ok(()) +} + +#[tokio::test] +async fn shutting_down_input_releases_its_owner_without_early_hangup() -> io::Result<()> { + require_conpty!(); + let mut command = helper("wait")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (mut input, mut output, resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + + input.shutdown().await?; + assert_eq!( + input.write_all(b"closed").await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + assert!(child.try_wait()?.is_none()); + resize.resize(PtySize::default())?; + drop(output); + assert_eq!( + resize + .resize(PtySize { + rows: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + resize.resize(PtySize::default()).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + timeout(TIMEOUT, child.wait()).await??; + Ok(()) +} + +#[tokio::test] +async fn dropping_output_keeps_the_terminal_live_while_input_exists() -> io::Result<()> { + require_conpty!(); + let mut command = helper("wait")?; + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + drop(output); + assert!(child.try_wait()?.is_none()); + resize.resize(PtySize::default())?; + drop(input); + assert_eq!( + resize + .resize(PtySize { + columns: 0, + ..PtySize::default() + }) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + resize.resize(PtySize::default()).unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + timeout(TIMEOUT, child.wait()).await??; + Ok(()) +} + +#[tokio::test] +async fn direct_child_wait_is_independent_from_descendant_output_eof() -> io::Result<()> { + require_conpty!(); + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("descendant-parent")?; + command.env("PW_RELEASE", &release); + + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = read_through(&mut output, b"PW-DESCENDANT-READY").await?; + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + assert!( + timeout(Duration::from_millis(100), output.read_to_end(&mut bytes)) + .await + .is_err(), + "ConPTY output reached EOF while a descendant remained attached" + ); + std::fs::File::create(&release)?; + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + Ok(()) +} + +#[cfg(feature = "job-object")] +#[tokio::test] +async fn job_object_waits_for_conpty_descendant_before_terminal_eof() -> io::Result<()> { + require_conpty!(); + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let pid_file = directory.path().join("descendant.pid"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("descendant-parent")?; + command + .env("PW_RELEASE", &release) + .env("PW_DESCENDANT_PID", &pid_file) + .wrap(JobObject); + + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let direct_process = ProcessExitGuard::open( + child + .id() + .expect("a newly spawned ConPTY child exposes its process ID"), + )?; + let (input, mut output, _resize) = controller.into_parts(); + let mut bytes = read_through(&mut output, b"PW-DESCENDANT-READY").await?; + let descendant_process = ProcessExitGuard::open(wait_for_pid_file(&pid_file).await?)?; + wait_for_process_exit(direct_process).await?; + assert_eq!( + child.try_wait()?, + None, + "ConPTY direct-child exit must remain pending while its job has a descendant" + ); + assert!( + timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "JobObject wait returned before the live ConPTY descendant or terminal output completed" + ); + + std::fs::File::create(&release)?; + drop(input); + let status = wait_and_drain(child.as_mut(), &mut output, &mut bytes).await?; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + wait_for_process_exit(descendant_process).await?; + Ok(()) +} + +#[tokio::test] +async fn failed_spawn_leaves_the_tracked_command_reusable() -> io::Result<()> { + require_conpty!(); + let directory = tempfile::tempdir()?; + let executable = directory.path().join("retry-helper.exe"); + let mut command = Command::new(&executable); + command + .args(["--exact", "conpty_child_helper", "--nocapture"]) + .env(HELPER_MODE, "terminal") + .wrap(Pty::default()); + let error = command + .spawn() + .expect_err("the first spawn must fail while the executable is absent"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + + std::fs::copy(env::current_exe()?, &executable)?; + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[tokio::test] +async fn accepts_ordered_raw_argument_fragments() -> io::Result<()> { + require_conpty!(); + let mut command = Command::new(env::current_exe()?); + command + .arg("--exact") + .raw_arg("\"conpty_child_helper\"") + .arg("--nocapture") + .env(HELPER_MODE, "terminal"); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(feature = "creation-flags")] +#[tokio::test] +async fn creation_flags_compose_without_a_job_object() -> io::Result<()> { + require_conpty!(); + use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; + + let mut command = helper("terminal")?; + command.wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(feature = "job-object")] +#[tokio::test] +async fn job_object_resumes_its_temporarily_suspended_primary_thread() -> io::Result<()> { + require_conpty!(); + let mut command = helper("terminal")?; + command.wrap(JobObject); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[tokio::test] +async fn job_object_composes_with_creation_flags_in_both_orders() -> io::Result<()> { + require_conpty!(); + use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP; + + for reverse in [false, true] { + for pty_first in [false, true] { + let mut command = helper("terminal")?; + if pty_first { + command.wrap(Pty::default()); + } + if reverse { + command + .wrap(JobObject) + .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)); + } else { + command + .wrap(CreationFlags(CREATE_NEW_PROCESS_GROUP)) + .wrap(JobObject); + } + if !pty_first { + command.wrap(Pty::default()); + } + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + } + } + Ok(()) +} + +#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[tokio::test] +async fn job_object_preserves_explicit_suspension() -> io::Result<()> { + require_conpty!(); + use windows::Win32::System::Threading::CREATE_SUSPENDED; + + let mut command = helper("terminal")?; + command + .wrap(CreationFlags(CREATE_SUSPENDED)) + .wrap(JobObject); + let (mut child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let (input, mut output, _resize) = controller.into_parts(); + let pid = child.id().expect("ConPTY children expose a process ID"); + assert!( + windows_thread::process_has_suspended_thread(pid)?, + "an explicitly suspended ConPTY child had no suspended thread" + ); + windows_thread::resume_process_threads(pid)?; + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} + +#[cfg(feature = "kill-on-drop")] +async fn assert_killed_on_drop(mut command: Command) -> io::Result<()> { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows::Win32::{ + Foundation::WAIT_OBJECT_0, + System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject}, + }; + + let (child, controller) = spawn_with_terminal(&mut command, PtySize::default())?; + let pid = child.id().expect("ConPTY children expose a process ID"); + let (_input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + // SAFETY: pid identifies the live child. Ownership of the non-inheritable handle is transferred. + let process = + unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }.map_err(io::Error::other)?; + // SAFETY: OpenProcess returned a unique owned handle. + let process = unsafe { OwnedHandle::from_raw_handle(process.0) }; + drop(child); + let wait = tokio::task::spawn_blocking(move || { + // SAFETY: process owns the live synchronization handle for this wait. + unsafe { WaitForSingleObject(HANDLE(process.as_raw_handle()), 10_000) } + }); + assert_eq!(wait.await.map_err(io::Error::other)?, WAIT_OBJECT_0); + Ok(()) +} + +#[cfg(feature = "kill-on-drop")] +#[tokio::test] +async fn kill_on_drop_terminates_a_conpty_child() -> io::Result<()> { + require_conpty!(); + let mut command = helper("wait")?; + command.wrap(KillOnDrop); + assert_killed_on_drop(command).await +} + +#[cfg(all(feature = "job-object", feature = "kill-on-drop"))] +#[tokio::test] +async fn job_object_composes_with_kill_on_drop_in_both_orders() -> io::Result<()> { + require_conpty!(); + for reverse in [false, true] { + for pty_first in [false, true] { + let directory = tempfile::tempdir()?; + let release = directory.path().join("release-descendant"); + let pid_file = directory.path().join("descendant-pid"); + let _release_on_drop = ReleaseOnDrop(release.clone()); + let mut command = helper("tree")?; + command + .env("PW_RELEASE", &release) + .env("PW_DESCENDANT_PID", &pid_file); + if pty_first { + command.wrap(Pty::default()); + } + if reverse { + command.wrap(JobObject).wrap(KillOnDrop); + } else { + command.wrap(KillOnDrop).wrap(JobObject); + } + if !pty_first { + command.wrap(Pty::default()); + } + + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-TREE-READY").await?; + let pid = std::fs::read_to_string(&pid_file)? + .parse::() + .map_err(io::Error::other)?; + let guard = ProcessExitGuard::open(pid)?; + drop(child); + wait_for_process_exit(guard).await?; + drop(input); + let mut bytes = Vec::new(); + timeout(TIMEOUT, output.read_to_end(&mut bytes)).await??; + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug)] +enum LifecycleFailure { + Error, + Panic, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LifecycleStage { + PostSpawn, + WrapChild, + FinalizeSpawn, + DisarmSpawnCleanup, + DisarmJobObject, +} + +fn fail_lifecycle(failure: LifecycleFailure) -> io::Result { + match failure { + LifecycleFailure::Error => Err(io::Error::other("ConPTY lifecycle failed")), + LifecycleFailure::Panic => panic!("ConPTY lifecycle failed"), + } +} + +#[derive(Debug)] +struct FailFinalizationChild { + inner: Option>, + failure: LifecycleFailure, + stage: LifecycleStage, +} + +impl ChildWrapperLayer for FailFinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + +impl ChildWrapper for FailFinalizationChild { + fn inner(&self) -> &dyn ChildWrapper { + self.inner + .as_deref() + .expect("an installed finalization layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.inner + .as_deref_mut() + .expect("an installed finalization layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.inner + .take() + .expect("an installed finalization layer owns its child") + } + + fn finalize_spawn_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::FinalizeSpawn { + fail_lifecycle(self.failure) + } else { + Ok(()) + } + } + + fn disarm_spawn_cleanup_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::DisarmSpawnCleanup { + fail_lifecycle(self.failure) + } else { + Ok(()) + } + } + + fn disarm_job_object_layer(&mut self) -> io::Result<()> { + if self.stage == LifecycleStage::DisarmJobObject { + fail_lifecycle(self.failure) + } else { + Ok(()) + } + } +} + +#[derive(Debug)] +struct FailLifecycleOnce { + failure: LifecycleFailure, + stage: LifecycleStage, + failed: bool, + process: Arc>>, +} + +impl FailLifecycleOnce { + fn capture(&self, child: &mut dyn ChildWrapper) -> io::Result<()> { + assert!(child.take_pty_controller().is_none()); + let pid = child + .id() + .expect("a newly spawned ConPTY child exposes its process ID"); + *self.process.lock().unwrap() = Some(ProcessExitGuard::open(pid)?); + Ok(()) + } +} + +impl CommandWrapper for FailLifecycleOnce { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result<()> { + if self.stage != LifecycleStage::PostSpawn || self.failed { + return Ok(()); + } + self.capture(child)?; + self.failed = true; + fail_lifecycle(self.failure) + } + + fn wrap_child( + &mut self, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result> { + if self.failed || self.stage == LifecycleStage::PostSpawn { + return Ok(None); + } + self.capture(child)?; + self.failed = true; + if self.stage == LifecycleStage::WrapChild { + fail_lifecycle(self.failure) + } else { + Ok(Some(PendingChildWrapper::new(FailFinalizationChild { + inner: None, + failure: self.failure, + stage: self.stage, + }))) + } + } +} + +#[tokio::test] +async fn failed_windows_lifecycle_reaps_the_child_and_remains_reusable() -> io::Result<()> { + require_conpty!(); + for stage in [ + LifecycleStage::PostSpawn, + LifecycleStage::WrapChild, + LifecycleStage::FinalizeSpawn, + LifecycleStage::DisarmSpawnCleanup, + LifecycleStage::DisarmJobObject, + ] { + for failure in [LifecycleFailure::Error, LifecycleFailure::Panic] { + let process = Arc::new(Mutex::new(None)); + let mut command = helper("wait")?; + command.wrap(Pty::default()).wrap(FailLifecycleOnce { + failure, + stage, + failed: false, + process: Arc::clone(&process), + }); + + let result = catch_unwind(AssertUnwindSafe(|| command.spawn())); + match failure { + LifecycleFailure::Error => assert_eq!( + result.unwrap().unwrap_err().to_string(), + "ConPTY lifecycle failed" + ), + LifecycleFailure::Panic => assert_eq!( + *result + .expect_err("the Windows lifecycle hook must panic") + .downcast::<&'static str>() + .unwrap(), + "ConPTY lifecycle failed" + ), + } + let guard = process + .lock() + .unwrap() + .take() + .expect("the failing lifecycle hook captures the child process"); + wait_for_process_exit(guard).await?; + + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-READY").await?; + child.start_kill()?; + timeout(TIMEOUT, child.wait()).await??; + drop(input); + timeout(TIMEOUT, output.read_to_end(&mut Vec::new())).await??; + } + } + Ok(()) +} + +#[derive(Debug)] +struct OtherProvider; + +impl SpawnProvider for OtherProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &Command, + ) -> io::Result { + panic!("provider conflicts are rejected before spawning") + } +} + +impl CommandWrapper for OtherProvider { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(self) + } +} + +#[test] +fn rejects_another_spawn_provider_before_callbacks() { + let mut command = Command::new("ignored"); + command.wrap(Pty::default()).wrap(OtherProvider); + let error = command.spawn().unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!(error.to_string(), "multiple spawn providers are registered"); +} + +#[derive(Debug)] +struct MakeAttemptNative; + +impl CommandWrapper for MakeAttemptNative { + fn pre_spawn(&mut self, attempt: &mut SpawnAttempt, _command: &Command) -> io::Result<()> { + let _ = attempt.native_mut(); + Ok(()) + } +} + +#[test] +fn rejects_opaque_base_and_attempt_state() -> io::Result<()> { + let supported = match Pty::check_supported() { + Ok(()) => true, + Err(error) if error.kind() == io::ErrorKind::Unsupported => false, + Err(error) => return Err(error), + }; + + let native = tokio::process::Command::new("ignored"); + let mut command = Command::from(native); + command.wrap(Pty::default()); + let error = command.spawn().unwrap_err(); + if supported { + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only command" + ); + } else { + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + let mut command = Command::new("ignored"); + command.wrap(Pty::default()).wrap(MakeAttemptNative); + let error = command.spawn().unwrap_err(); + if supported { + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "a spawn provider cannot use a native-only spawn attempt" + ); + } else { + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + Ok(()) +} + +#[test] +fn explicit_spawners_cannot_bypass_pty() { + let mut command = Command::new("ignored"); + command.wrap(Pty::default()); + let error = command + .spawn_with(|_| panic!("explicit spawner must not run")) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + + let error = command + .spawn_with_child(|_| panic!("explicit child spawner must not run")) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); +} + +#[tokio::test] +async fn duplicate_pty_registration_uses_the_later_size() -> io::Result<()> { + require_conpty!(); + let mut command = helper("size")?; + command + .wrap(Pty::new(PtySize::new(31, 97)?)) + .wrap(Pty::new(PtySize::new(42, 113)?)); + + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (mut input, mut output, _resize) = controller.into_parts(); + read_through(&mut output, b"PW-SIZE:42x113").await?; + input.write_all(b"go\r").await?; + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + Ok(()) +} + +#[tokio::test] +async fn provider_and_controller_are_reusable() -> io::Result<()> { + require_conpty!(); + let mut command = helper("terminal")?; + command.wrap(Pty::default()); + + for _ in 0..3 { + let mut child = command.spawn()?; + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + } + Ok(()) +} + +#[tokio::test] +async fn non_pty_children_have_no_controller() -> io::Result<()> { + let mut command = Command::with_new("cmd.exe", |command| { + command.args(["/D", "/S", "/C", "exit /b 0"]); + }); + let mut child = command.spawn()?; + assert!(child.take_pty_controller().is_none()); + assert!(timeout(TIMEOUT, child.wait()).await??.success()); + Ok(()) +} + +#[derive(Debug)] +struct TransparentChild(Option>); + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} + +impl ChildWrapper for TransparentChild { + fn inner(&self) -> &dyn ChildWrapper { + self.0 + .as_deref() + .expect("an installed transparent layer owns its child") + } + + fn inner_mut(&mut self) -> &mut dyn ChildWrapper { + self.0 + .as_deref_mut() + .expect("an installed transparent layer owns its child") + } + + fn into_inner(mut self: Box) -> Box { + self.0 + .take() + .expect("an installed transparent layer owns its child") + } +} + +#[derive(Debug)] +struct ObserveControllerLifecycle { + post_spawn: Arc, + wrap_child: Arc, +} + +impl CommandWrapper for ObserveControllerLifecycle { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result<()> { + assert!(child.take_pty_controller().is_none()); + self.post_spawn.store(true, Ordering::SeqCst); + Ok(()) + } + + fn wrap_child( + &mut self, + child: &mut dyn ChildWrapper, + _command: &Command, + ) -> io::Result> { + assert!(child.take_pty_controller().is_none()); + self.wrap_child.store(true, Ordering::SeqCst); + Ok(Some(PendingChildWrapper::new(TransparentChild(None)))) + } +} + +#[tokio::test] +async fn controller_commits_after_hooks_and_traverses_outer_wrappers() -> io::Result<()> { + require_conpty!(); + let post_spawn = Arc::new(AtomicBool::new(false)); + let wrap_child = Arc::new(AtomicBool::new(false)); + let mut command = helper("terminal")?; + command + .wrap(Pty::default()) + .wrap(ObserveControllerLifecycle { + post_spawn: Arc::clone(&post_spawn), + wrap_child: Arc::clone(&wrap_child), + }); + + let mut child = command.spawn()?; + assert!(post_spawn.load(Ordering::SeqCst)); + assert!(wrap_child.load(Ordering::SeqCst)); + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + Ok(()) +} + +#[derive(Debug)] +struct PortableWrapper(Arc); + +impl CommandWrapper for PortableWrapper { + fn pre_spawn(&mut self, _attempt: &mut SpawnAttempt, _command: &Command) -> io::Result<()> { + self.0.store(true, Ordering::SeqCst); + Ok(()) + } +} + +#[tokio::test] +async fn portable_custom_wrappers_compose_with_conpty() -> io::Result<()> { + require_conpty!(); + let called = Arc::new(AtomicBool::new(false)); + let mut command = helper("terminal")?; + command + .wrap(Pty::default()) + .wrap(PortableWrapper(Arc::clone(&called))); + + let mut child = command.spawn()?; + assert!(called.load(Ordering::SeqCst)); + assert!(child.stdin().is_none()); + assert!(child.stdout().is_none()); + assert!(child.stderr().is_none()); + assert!(child.try_inner_child().is_none()); + let controller = take_controller(child.as_mut()); + let (input, mut output, _resize) = controller.into_parts(); + drop(input); + let mut bytes = Vec::new(); + assert!( + wait_and_drain(child.as_mut(), &mut output, &mut bytes) + .await? + .success() + ); + assert!( + bytes + .windows(16) + .any(|window| window == b"PW-TERMINALS:111") + ); + Ok(()) +} diff --git a/tests/tokio_windows/creation_flags_job_object.rs b/tests/tokio_windows/creation_flags_job_object.rs index bfd161b..3314cba 100644 --- a/tests/tokio_windows/creation_flags_job_object.rs +++ b/tests/tokio_windows/creation_flags_job_object.rs @@ -1,9 +1,10 @@ use std::{ fs, io::{Error, ErrorKind}, + os::windows::process::CommandExt, panic::{AssertUnwindSafe, catch_unwind}, path::PathBuf, - process::{Command as StdCommand, ExitStatus}, + process::{Command as StdCommand, ExitStatus, Stdio}, sync::{ Arc, Mutex, atomic::{AtomicU32, Ordering}, @@ -12,7 +13,8 @@ use std::{ }; use windows::Win32::System::Threading::{ - CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, PROCESS_CREATION_FLAGS, + CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, + PROCESS_CREATION_FLAGS, }; use super::{ @@ -121,11 +123,11 @@ struct FailWrapOnce { impl CommandWrapper for FailWrapOnce { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.failed { - return Ok(child); + return Ok(None); } self.failed = true; match self.failure { @@ -141,26 +143,39 @@ enum FailureHook { WrapChild, FinalizeSpawn, DisarmSpawnCleanup, + DisarmJobObject, } #[derive(Debug)] struct FailFinalizationChild { - inner: Box, + inner: Option>, failure: Failure, hook: FailureHook, } +impl ChildWrapperLayer for FailFinalizationChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } +} + impl ChildWrapper for FailFinalizationChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed finalization layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed finalization layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed finalization layer owns its child") } fn finalize_spawn_layer(&mut self) -> Result<()> { @@ -184,13 +199,23 @@ impl ChildWrapper for FailFinalizationChild { Ok(()) } } + + fn disarm_job_object_layer(&mut self) -> Result<()> { + if self.hook == FailureHook::DisarmJobObject { + match self.failure { + Failure::Error => Err(Error::other("child wrapping failed")), + Failure::Panic => panic!("child wrapping failed"), + } + } else { + Ok(()) + } + } } #[derive(Debug)] struct FailAfterDescendant { failure: Failure, hook: FailureHook, - unwrap_child: bool, pid_file: PathBuf, guard: Arc>>, } @@ -247,39 +272,42 @@ impl CommandWrapper for FailAfterDescendant { fn wrap_child( &mut self, - child: Box, + child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { + ) -> Result> { if self.hook == FailureHook::PostSpawn { - return Ok(child); + return Ok(None); } - resume_process_threads(child_id(child.as_ref())?)?; + resume_process_threads(child_id(child)?)?; self.observe_descendant()?; if matches!( self.hook, - FailureHook::FinalizeSpawn | FailureHook::DisarmSpawnCleanup + FailureHook::FinalizeSpawn + | FailureHook::DisarmSpawnCleanup + | FailureHook::DisarmJobObject ) { - return Ok(Box::new(FailFinalizationChild { - inner: child, + return Ok(Some(PendingChildWrapper::new(FailFinalizationChild { + inner: None, failure: self.failure, hook: self.hook, - })); - } - if self.unwrap_child { - drop(child.into_inner()); - } else { - drop(child); + }))); } self.fail() } } -fn descendant_pid_file() -> PathBuf { - std::env::temp_dir().join(format!( +fn descendant_pid_file() -> Result { + let path = std::env::temp_dir().join(format!( "process-wrap-{}-{}.pid", std::process::id(), PID_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed) - )) + )); + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + Ok(path) } async fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { @@ -414,6 +442,10 @@ fn lifecycle_descendant_leaf() { fn lifecycle_descendant_parent() { let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) .args(["lifecycle_descendant_leaf", "--ignored", "--nocapture"]) + .creation_flags(DETACHED_PROCESS.0) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .unwrap(); fs::write( @@ -427,18 +459,19 @@ fn lifecycle_descendant_parent() { #[tokio::test] async fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let cases = [ - (FailureHook::PostSpawn, false, false), - (FailureHook::WrapChild, false, false), - (FailureHook::WrapChild, true, false), - (FailureHook::WrapChild, true, true), - (FailureHook::FinalizeSpawn, false, false), - (FailureHook::FinalizeSpawn, true, false), - (FailureHook::DisarmSpawnCleanup, false, false), - (FailureHook::DisarmSpawnCleanup, true, false), + (FailureHook::PostSpawn, false), + (FailureHook::WrapChild, false), + (FailureHook::WrapChild, true), + (FailureHook::FinalizeSpawn, false), + (FailureHook::FinalizeSpawn, true), + (FailureHook::DisarmSpawnCleanup, false), + (FailureHook::DisarmSpawnCleanup, true), + (FailureHook::DisarmJobObject, false), + (FailureHook::DisarmJobObject, true), ]; for failure in [Failure::Error, Failure::Panic] { - for (hook, job_first, unwrap_child) in cases { - let pid_file = descendant_pid_file(); + for (hook, job_first) in cases { + let pid_file = descendant_pid_file()?; let guard = Arc::new(Mutex::new(None)); let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { command @@ -448,7 +481,6 @@ async fn armed_job_kills_descendants_after_later_failures() -> Result<()> { let fail = FailAfterDescendant { failure, hook, - unwrap_child, pid_file: pid_file.clone(), guard: Arc::clone(&guard), }; diff --git a/tests/tokio_windows/job_object_kill_on_drop.rs b/tests/tokio_windows/job_object_kill_on_drop.rs index 8fab438..44391c3 100644 --- a/tests/tokio_windows/job_object_kill_on_drop.rs +++ b/tests/tokio_windows/job_object_kill_on_drop.rs @@ -1,48 +1,184 @@ use std::{ - io::{ErrorKind, Write}, - process::Command as StdCommand, - time::Instant, + fmt, fs, + io::{Error, ErrorKind, Result}, + path::{Path, PathBuf}, + process::{Child as StdChild, Command as StdCommand, Stdio}, + time::{Duration, Instant}, }; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use windows::Win32::{ - Foundation::{CloseHandle, HANDLE, WAIT_FAILED, WAIT_OBJECT_0}, + Foundation::{ + CloseHandle, HANDLE, STILL_ACTIVE, WAIT_EVENT, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, System::Threading::{ - OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, TerminateProcess, WaitForSingleObject, + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + PROCESS_TERMINATE, TerminateProcess, WaitForSingleObject, }, }; use super::prelude::*; const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const EXIT_TIMEOUT: Duration = Duration::from_secs(5); -const DESCENDANT_MARKER: &str = "PW-DESCENDANT:"; +const LEAF_SELF_DEADLINE: Duration = Duration::from_secs(30); +const DIRECT_SELF_DEADLINE: Duration = Duration::from_secs(35); +const CONTROL_DIRECTORY_ENV: &str = "PROCESS_WRAP_JOB_EXTRACTION_CONTROL_DIRECTORY"; +const DIRECT_HELPER: &str = "tokio_windows::job_object_kill_on_drop::descendant_parent"; +const LEAF_HELPER: &str = "tokio_windows::job_object_kill_on_drop::descendant_leaf"; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] enum Order { KillOnDropFirst, JobObjectFirst, } -struct ProcessGuard(Option); +impl Order { + fn label(self) -> &'static str { + match self { + Self::KillOnDropFirst => "KillOnDrop then JobObject", + Self::JobObjectFirst => "JobObject then KillOnDrop", + } + } + + fn error(self, phase: &str, error: impl fmt::Display) -> Error { + Error::other(format!("{}: {phase}: {error}", self.label())) + } + + fn failure(self, message: impl fmt::Display) -> Error { + Error::other(format!("{}: {message}", self.label())) + } + + fn wrap(self, command: &mut CommandWrap) { + match self { + Self::KillOnDropFirst => { + command.wrap(KillOnDrop).wrap(JobObject); + } + Self::JobObjectFirst => { + command.wrap(JobObject).wrap(KillOnDrop); + } + } + } +} + +#[derive(Debug)] +struct ProcessGuard { + label: &'static str, + handle: Option, +} impl ProcessGuard { - fn open(pid: u32) -> Result { + fn open(label: &'static str, pid: u32, order: Order) -> Result { // SAFETY: success returns a new process handle immediately owned by this guard. - let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } - .map_err(std::io::Error::other)?; - Ok(Self(Some(handle))) + let handle = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION, + false, + pid, + ) + } + .map_err(|error| order.error(&format!("opening {label} process {pid}"), error))?; + Ok(Self { + label, + handle: Some(handle), + }) } fn handle(&self) -> HANDLE { - self.0 - .expect("only ProcessGuard::disarm clears the handle, and it consumes the guard") + self.handle + .expect("only ProcessGuard::disarm clears the handle") + } + + fn wait_state(&self) -> Result { + // SAFETY: this guard owns the live process handle for the nonblocking wait. + let wait = unsafe { WaitForSingleObject(self.handle(), 0) }; + if wait == WAIT_FAILED { + Err(Error::last_os_error()) + } else { + Ok(wait) + } + } + + fn exit_code(&self) -> Result { + let mut code = 0; + // SAFETY: this guard owns the live process handle and `code` is writable for the call. + unsafe { GetExitCodeProcess(self.handle(), &mut code) }.map_err(Error::other)?; + Ok(code) } - fn disarm(mut self) -> Result<()> { - if let Some(handle) = self.0.take() { + fn require_live(&self, order: Order, phase: &str) -> Result<()> { + let wait = self.wait_state().map_err(|error| { + order.error(&format!("querying {} during {phase}", self.label), error) + })?; + let code = self.exit_code().map_err(|error| { + order.error( + &format!("reading {} exit code during {phase}", self.label), + error, + ) + })?; + if wait == WAIT_TIMEOUT && code == STILL_ACTIVE.0 as u32 { + Ok(()) + } else { + Err(order.failure(format!( + "{} was not live during {phase}: wait={:#x}, exit_code={code:#x}", + self.label, wait.0 + ))) + } + } + + async fn wait_for_exit(&self, order: Order, phase: &str) -> Result { + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + let wait = self.wait_state().map_err(|error| { + order.error(&format!("waiting for {} during {phase}", self.label), error) + })?; + if wait == WAIT_OBJECT_0 { + return self.exit_code().map_err(|error| { + order.error( + &format!("reading {} exit code after {phase}", self.label), + error, + ) + }); + } + if wait != WAIT_TIMEOUT { + return Err(order.failure(format!( + "unexpected wait result {:#x} for {} during {phase}", + wait.0, self.label + ))); + } + if Instant::now() >= deadline { + let code = self.exit_code().unwrap_or(u32::MAX); + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "{}: {} did not exit during {phase}; exit_code={code:#x}", + order.label(), + self.label + ), + )); + } + sleep(Duration::from_millis(10)).await; + } + } + + fn describe(&self) -> String { + let wait = self.wait_state().map_or_else( + |error| format!("error({error})"), + |wait| format!("{:#x}", wait.0), + ); + let code = self.exit_code().map_or_else( + |error| format!("error({error})"), + |code| format!("{code:#x}"), + ); + format!("{}[wait={wait}, exit_code={code}]", self.label) + } + + fn disarm(mut self, order: Order) -> Result<()> { + if let Some(handle) = self.handle.take() { // SAFETY: taking the guard's owned handle removes the `Drop` close path. - unsafe { CloseHandle(handle) }?; + unsafe { CloseHandle(handle) }.map_err(|error| { + order.error(&format!("closing {} process handle", self.label), error) + })?; } Ok(()) } @@ -50,119 +186,553 @@ impl ProcessGuard { impl Drop for ProcessGuard { fn drop(&mut self) { - if let Some(handle) = self.0.take() { - // SAFETY: the guard still owns this handle and has taken it out of the later drop path. + if let Some(handle) = self.handle.take() { + // SAFETY: the guard still owns this handle and uses its termination right only for cleanup. unsafe { TerminateProcess(handle, 1) }.ok(); - // SAFETY: the same taken handle remains live until this close. + // SAFETY: the same handle remains live; this finite wait bounds failure cleanup. + unsafe { WaitForSingleObject(handle, 1_000) }; + // SAFETY: the handle was taken from this sole owner and is closed exactly once. unsafe { CloseHandle(handle) }.ok(); } } } -async fn descendant_pid(child: &mut dyn ChildWrapper) -> Result { - let stdout = child.stdout().take().ok_or_else(|| { - std::io::Error::new( - ErrorKind::BrokenPipe, - "descendant helper stdout was not piped", +#[derive(Debug)] +struct Protocol { + directory: PathBuf, +} + +impl Protocol { + fn new(directory: &Path) -> Self { + Self { + directory: directory.to_owned(), + } + } + + fn from_environment() -> Result { + let directory = std::env::var_os(CONTROL_DIRECTORY_ENV) + .ok_or_else(|| Error::other("JobObject extraction control directory is missing"))?; + Ok(Self { + directory: directory.into(), + }) + } + + fn pid(&self) -> PathBuf { + self.directory.join("leaf.pid") + } + + fn ready(&self) -> PathBuf { + self.directory.join("leaf.ready") + } + + fn release(&self) -> PathBuf { + self.directory.join("leaf.release") + } + + fn released(&self) -> PathBuf { + self.directory.join("leaf.released") + } + + fn deadline_failure(&self) -> PathBuf { + self.directory.join("leaf.deadline") + } + + fn ping_path(&self, sequence: usize) -> PathBuf { + self.directory.join(format!("leaf.ping.{sequence}")) + } + + fn ack(&self, sequence: usize) -> PathBuf { + self.directory.join(format!("leaf.ack.{sequence}")) + } + + async fn wait_for_content( + &self, + path: &Path, + expected: &str, + timeout: Duration, + order: Order, + phase: &str, + ) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + match fs::read_to_string(path) { + Ok(content) if content == expected => return Ok(()), + Ok(content) if Instant::now() >= deadline => { + return Err(Error::new( + ErrorKind::TimedOut, + format!( + "{}: {phase} had unexpected marker content {content:?}; expected {expected:?}", + order.label() + ), + )); + } + Err(error) if error.kind() != ErrorKind::NotFound => { + return Err(order.error(&format!("reading {phase}"), error)); + } + _ if Instant::now() >= deadline => { + return Err(Error::new( + ErrorKind::TimedOut, + format!("{}: timed out waiting for {phase}", order.label()), + )); + } + _ => sleep(Duration::from_millis(10)).await, + } + } + } + + async fn wait_for_pid(&self, order: Order) -> Result { + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + match fs::read_to_string(self.pid()) { + Ok(pid) => match pid.trim().parse::() { + Ok(pid) => return Ok(pid), + Err(error) if Instant::now() >= deadline => { + return Err(order.error("parsing the leaf PID marker", error)); + } + Err(_) => {} + }, + Err(error) if error.kind() != ErrorKind::NotFound => { + return Err(order.error("reading the leaf PID marker", error)); + } + Err(_) => {} + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + format!("{}: leaf did not publish its PID", order.label()), + )); + } + sleep(Duration::from_millis(10)).await; + } + } + + async fn wait_until_ready(&self, pid: u32, order: Order) -> Result<()> { + self.wait_for_content( + &self.ready(), + &format!("ready:{pid}"), + STARTUP_TIMEOUT, + order, + "the leaf-owned ready marker", ) - })?; - let mut lines = BufReader::new(stdout).lines(); - let result = tokio::time::timeout(STARTUP_TIMEOUT, async { - let mut transcript = String::new(); - while let Some(line) = lines.next_line().await? { - if let Some(pid) = line.strip_prefix(DESCENDANT_MARKER) { - return pid.trim().parse().map_err(std::io::Error::other); + .await + } + + async fn ping(&self, sequence: usize, order: Order, phase: &str) -> Result<()> { + let request = self.ping_path(sequence); + let ack = self.ack(sequence); + if request.exists() || ack.exists() { + return Err(order.failure(format!( + "stale ping state existed before {phase}: request={}, ack={}", + request.exists(), + ack.exists() + ))); + } + let token = format!("{}:{sequence}", order.label()); + publish_file(&request, &token).map_err(|error| { + order.error(&format!("publishing ping {sequence} during {phase}"), error) + })?; + self.wait_for_content( + &ack, + &token, + RESPONSE_TIMEOUT, + order, + &format!("leaf acknowledgement {sequence} during {phase}"), + ) + .await + } + + fn publish_release(&self, order: Order) -> Result<()> { + publish_file(&self.release(), "release") + .map_err(|error| order.error("publishing the leaf release", error)) + } + + fn require_released(&self, pid: u32, order: Order) -> Result<()> { + let content = fs::read_to_string(self.released()) + .map_err(|error| order.error("reading the leaf release marker", error))?; + if content == format!("released:{pid}") { + Ok(()) + } else { + Err(order.failure(format!( + "unexpected leaf release marker {content:?} for PID {pid}" + ))) + } + } + + fn diagnostics(&self) -> String { + let mut entries = Vec::new(); + for (label, path) in [ + ("pid", self.pid()), + ("ready", self.ready()), + ("release", self.release()), + ("released", self.released()), + ("deadline", self.deadline_failure()), + ] { + entries.push(marker_diagnostic(label, &path)); + } + for sequence in 0..3 { + entries.push(marker_diagnostic( + &format!("ping.{sequence}"), + &self.ping_path(sequence), + )); + entries.push(marker_diagnostic( + &format!("ack.{sequence}"), + &self.ack(sequence), + )); + } + entries.join(", ") + } +} + +fn publish_file(path: &Path, content: &str) -> Result<()> { + let temporary = path.with_extension("publishing"); + fs::write(&temporary, content)?; + fs::rename(temporary, path) +} + +fn marker_diagnostic(label: &str, path: &Path) -> String { + match fs::read_to_string(path) { + Ok(content) => format!("{label}={content:?}"), + Err(error) if error.kind() == ErrorKind::NotFound => format!("{label}=missing"), + Err(error) => format!("{label}=error({error})"), + } +} + +struct TreeFixture { + _order: Order, + _directory: tempfile::TempDir, + protocol: Protocol, + direct: Option, + descendant: Option, +} + +impl TreeFixture { + fn new(order: Order) -> Result { + let directory = tempfile::tempdir() + .map_err(|error| order.error("creating control directory", error))?; + let protocol = Protocol::new(directory.path()); + Ok(Self { + _order: order, + _directory: directory, + protocol, + direct: None, + descendant: None, + }) + } + + fn install_direct(&mut self, pid: u32, order: Order) -> Result<()> { + self.direct = Some(ProcessGuard::open("direct", pid, order)?); + Ok(()) + } + + fn install_descendant(&mut self, pid: u32, order: Order) -> Result<()> { + self.descendant = Some(ProcessGuard::open("descendant", pid, order)?); + Ok(()) + } + + fn direct(&self, order: Order) -> Result<&ProcessGuard> { + self.direct + .as_ref() + .ok_or_else(|| order.failure("the direct-process guard was not installed")) + } + + fn descendant(&self, order: Order) -> Result<&ProcessGuard> { + self.descendant + .as_ref() + .ok_or_else(|| order.failure("the descendant-process guard was not installed")) + } + + fn disarm_direct(&mut self, order: Order) -> Result<()> { + self.direct + .take() + .ok_or_else(|| order.failure("the direct-process guard was already removed"))? + .disarm(order) + } + + fn disarm_descendant(&mut self, order: Order) -> Result<()> { + self.descendant + .take() + .ok_or_else(|| order.failure("the descendant-process guard was already removed"))? + .disarm(order) + } + + fn finish(self, result: Result<()>, order: Order) -> Result<()> { + match result { + Ok(()) => Ok(()), + Err(error) => { + let process_state = [ + self.direct.as_ref().map(ProcessGuard::describe), + self.descendant.as_ref().map(ProcessGuard::describe), + ] + .into_iter() + .flatten() + .collect::>() + .join(", "); + Err(Error::new( + error.kind(), + format!( + "{error}; {} fixture state: processes=[{process_state}], markers=[{}]", + order.label(), + self.protocol.diagnostics() + ), + )) } - transcript.push_str(&line); - transcript.push('\n'); } + } +} - let status = child.wait().await?; - let mut stderr = String::new(); - if let Some(mut pipe) = child.stderr().take() { - pipe.read_to_string(&mut stderr).await?; +impl Drop for TreeFixture { + fn drop(&mut self) { + if self.direct.is_some() || self.descendant.is_some() { + eprintln!( + "{} fixture cleanup: processes=[{}], markers=[{}]", + self._order.label(), + [ + self.direct.as_ref().map(ProcessGuard::describe), + self.descendant.as_ref().map(ProcessGuard::describe), + ] + .into_iter() + .flatten() + .collect::>() + .join(", "), + self.protocol.diagnostics() + ); } - Err(std::io::Error::other(format!( - "descendant helper exited with {status} before reporting its pid; stdout: {transcript}; stderr: {stderr}" - ))) - }) - .await; + // Terminate the descendant first so no surviving child can outlive cleanup of the direct helper. + drop(self.descendant.take()); + drop(self.direct.take()); + } +} + +struct HelperChild(Option); + +impl HelperChild { + fn child_mut(&mut self) -> &mut StdChild { + self.0 + .as_mut() + .expect("the helper child remains owned until it exits") + } - match result { - Ok(result) => result, - Err(_) => Err(std::io::Error::new( - ErrorKind::TimedOut, - format!( - "descendant helper did not report its pid; process status: {:?}", - child.try_wait()? - ), - )), + fn disarm(&mut self) { + self.0.take(); } } -async fn wait_for_process_exit(handle: HANDLE) -> Result<()> { - let deadline = Instant::now() + EXIT_TIMEOUT; +impl Drop for HelperChild { + fn drop(&mut self) { + if let Some(child) = self.0.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn direct_command(protocol: &Protocol) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args(["--exact", DIRECT_HELPER, "--ignored", "--nocapture"]) + .env(CONTROL_DIRECTORY_ENV, &protocol.directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) +} + +async fn prepare_tree(fixture: &mut TreeFixture, order: Order) -> Result> { + let mut command = direct_command(&fixture.protocol) + .map_err(|error| order.error("constructing the direct helper command", error))?; + order.wrap(&mut command); + let child = command + .spawn() + .map_err(|error| order.error("spawning the wrapped direct helper", error))?; + let direct_pid = child + .id() + .ok_or_else(|| order.failure("the direct helper did not retain its PID"))?; + fixture.install_direct(direct_pid, order)?; + let descendant_pid = fixture.protocol.wait_for_pid(order).await?; + fixture.install_descendant(descendant_pid, order)?; + fixture + .protocol + .wait_until_ready(descendant_pid, order) + .await?; + fixture + .direct(order)? + .require_live(order, "pre-extraction readiness")?; + fixture + .descendant(order)? + .require_live(order, "pre-extraction readiness")?; + fixture + .protocol + .ping(0, order, "pre-extraction readiness") + .await?; + Ok(child) +} + +async fn run_consuming_order_inner(fixture: &mut TreeFixture, order: Order) -> Result<()> { + let child = prepare_tree(fixture, order).await?; + let mut lower = child.into_inner(); + if lower + .try_wait() + .map_err(|error| order.error("querying the returned lower child", error))? + .is_some() + { + return Err(order.failure("the returned lower child exited during extraction")); + } + fixture + .direct(order)? + .require_live(order, "post-extraction verification")?; + fixture + .descendant(order)? + .require_live(order, "post-extraction verification")?; + fixture + .protocol + .ping(1, order, "post-extraction verification") + .await?; + + drop(lower); + let direct_code = fixture + .direct(order)? + .wait_for_exit(order, "lower direct-child KillOnDrop") + .await?; + fixture.disarm_direct(order)?; + fixture + .descendant(order)? + .require_live(order, "post-direct-drop verification")?; + fixture + .protocol + .ping(2, order, "post-direct-drop verification") + .await?; + + fixture.protocol.publish_release(order)?; + let descendant_code = fixture + .descendant(order)? + .wait_for_exit(order, "explicit descendant release") + .await?; + if descendant_code != 0 { + return Err(order.failure(format!( + "the explicitly released descendant exited with {descendant_code:#x}; direct exit code was {direct_code:#x}" + ))); + } + let descendant_pid = fs::read_to_string(fixture.protocol.pid()) + .map_err(|error| order.error("re-reading the descendant PID", error))? + .trim() + .parse::() + .map_err(|error| order.error("re-parsing the descendant PID", error))?; + fixture.protocol.require_released(descendant_pid, order)?; + fixture.disarm_descendant(order)?; + Ok(()) +} + +async fn run_consuming_order(order: Order) -> Result<()> { + let mut fixture = TreeFixture::new(order)?; + let result = run_consuming_order_inner(&mut fixture, order).await; + fixture.finish(result, order) +} + +async fn run_installed_drop_order_inner(fixture: &mut TreeFixture, order: Order) -> Result<()> { + let child = prepare_tree(fixture, order).await?; + drop(child); + fixture + .direct(order)? + .wait_for_exit(order, "installed JobObject drop") + .await?; + fixture + .descendant(order)? + .wait_for_exit(order, "installed JobObject drop") + .await?; + fixture.disarm_direct(order)?; + fixture.disarm_descendant(order)?; + Ok(()) +} + +async fn run_installed_drop_order(order: Order) -> Result<()> { + let mut fixture = TreeFixture::new(order)?; + let result = run_installed_drop_order_inner(&mut fixture, order).await; + fixture.finish(result, order) +} + +#[test] +#[ignore = "subprocess helper"] +fn descendant_leaf() -> Result<()> { + let protocol = Protocol::from_environment()?; + let pid = std::process::id(); + let deadline = Instant::now() + LEAF_SELF_DEADLINE; + let mut next_ping = 0; + + // The controlled wait state is fully initialized before the leaf publishes its own PID/readiness. + publish_file(&protocol.pid(), &pid.to_string())?; + publish_file(&protocol.ready(), &format!("ready:{pid}"))?; loop { - // SAFETY: the caller's `ProcessGuard` keeps this handle live for the call. - let wait = unsafe { WaitForSingleObject(handle, 0) }; - if wait == WAIT_OBJECT_0 { + if protocol.release().exists() { + publish_file(&protocol.released(), &format!("released:{pid}"))?; return Ok(()); } - if wait == WAIT_FAILED { - return Err(std::io::Error::last_os_error()); + let ping = protocol.ping_path(next_ping); + if ping.exists() { + let token = fs::read_to_string(&ping)?; + publish_file(&protocol.ack(next_ping), &token)?; + next_ping += 1; } if Instant::now() >= deadline { - return Err(std::io::Error::new( + publish_file(&protocol.deadline_failure(), "leaf self-deadline expired")?; + return Err(Error::new( ErrorKind::TimedOut, - "descendant survived dropping the job", + "JobObject extraction leaf exceeded its self-deadline", )); } - sleep(Duration::from_millis(10)).await; + std::thread::sleep(Duration::from_millis(10)); } } #[test] #[ignore = "subprocess helper"] -fn descendant_leaf() { - std::thread::sleep(Duration::from_secs(300)); +fn descendant_parent() -> Result<()> { + let protocol = Protocol::from_environment()?; + let descendant = StdCommand::new(std::env::current_exe()?) + .args(["--exact", LEAF_HELPER, "--ignored", "--nocapture"]) + .env(CONTROL_DIRECTORY_ENV, &protocol.directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let mut descendant = HelperChild(Some(descendant)); + let deadline = Instant::now() + DIRECT_SELF_DEADLINE; + loop { + if let Some(status) = descendant.child_mut().try_wait()? { + descendant.disarm(); + return if status.success() { + Ok(()) + } else { + Err(Error::other(format!( + "JobObject extraction leaf exited with {status}" + ))) + }; + } + if Instant::now() >= deadline { + return Err(Error::new( + ErrorKind::TimedOut, + "JobObject extraction direct helper exceeded its self-deadline", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } } -#[test] -#[ignore = "subprocess helper"] -fn descendant_parent() { - let mut descendant = StdCommand::new(std::env::current_exe().unwrap()) - .args(["descendant_leaf", "--ignored", "--nocapture"]) - .spawn() - .unwrap(); - println!("{DESCENDANT_MARKER}{}", descendant.id()); - std::io::stdout().flush().unwrap(); - descendant.wait().unwrap(); +#[tokio::test] +async fn consuming_job_object_after_kill_on_drop_preserves_descendant() -> Result<()> { + run_consuming_order(Order::KillOnDropFirst).await } #[tokio::test] -async fn job_detects_kill_on_drop_in_both_orders() -> Result<()> { - for order in [Order::KillOnDropFirst, Order::JobObjectFirst] { - let mut command = CommandWrap::with_new(std::env::current_exe()?, |command| { - command - .args(["descendant_parent", "--ignored", "--nocapture"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - }); - match order { - Order::KillOnDropFirst => { - command.wrap(KillOnDrop).wrap(JobObject); - } - Order::JobObjectFirst => { - command.wrap(JobObject).wrap(KillOnDrop); - } - } +async fn consuming_job_object_before_kill_on_drop_preserves_descendant() -> Result<()> { + run_consuming_order(Order::JobObjectFirst).await +} - let mut child = command.spawn()?; - let pid = descendant_pid(child.as_mut()).await?; - let guard = ProcessGuard::open(pid)?; - drop(child); - wait_for_process_exit(guard.handle()).await?; - guard.disarm()?; - } - Ok(()) +#[tokio::test] +async fn installed_job_object_after_kill_on_drop_terminates_descendant() -> Result<()> { + run_installed_drop_order(Order::KillOnDropFirst).await +} + +#[tokio::test] +async fn installed_job_object_before_kill_on_drop_terminates_descendant() -> Result<()> { + run_installed_drop_order(Order::JobObjectFirst).await } diff --git a/tests/tokio_windows/mod.rs b/tests/tokio_windows/mod.rs index adb69bd..8a33a17 100644 --- a/tests/tokio_windows/mod.rs +++ b/tests/tokio_windows/mod.rs @@ -24,6 +24,7 @@ mod try_wait_after_die; mod wait_after_die; mod wait_twice; mod wait_with_output; -#[cfg(all(feature = "creation-flags", feature = "job-object"))] +#[cfg(feature = "job-object")] +#[cfg_attr(not(feature = "creation-flags"), allow(dead_code))] #[path = "../support/windows_thread.rs"] mod windows_thread; diff --git a/tests/tokio_windows/process_handle.rs b/tests/tokio_windows/process_handle.rs index b733f9d..bb134da 100644 --- a/tests/tokio_windows/process_handle.rs +++ b/tests/tokio_windows/process_handle.rs @@ -1,16 +1,23 @@ use std::{ any::TypeId, + fs, future::Future, os::windows::io::{AsRawHandle, BorrowedHandle, OwnedHandle}, + path::{Path, PathBuf}, pin::Pin, - process::ExitStatus, + process::{Command as StdCommand, ExitStatus}, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}, }, + time::Instant, }; -use super::prelude::*; +use super::{prelude::*, windows_thread::ProcessGuard}; + +const JOB_WAIT_DESCENDANT_PID: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_PID"; +const JOB_WAIT_DESCENDANT_RELEASE: &str = "PROCESS_WRAP_JOB_WAIT_DESCENDANT_RELEASE"; +const JOB_WAIT_TIMEOUT: Duration = Duration::from_secs(5); #[link(name = "kernel32")] unsafe extern "system" { @@ -65,26 +72,79 @@ fn opaque_child() -> (Box, Arc, Arc) ) } +#[derive(Debug)] +struct NoopTransaction; + +impl SpawnTransaction for NoopTransaction { + fn commit(&mut self) -> Result<()> { + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct OpaqueProvider(Mutex>>); + +impl SpawnProvider for OpaqueProvider { + fn spawn( + &self, + _attempt: &mut SpawnAttempt, + _command: &CommandWrap, + ) -> Result { + let child = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .expect("the opaque provider is used once"); + Ok(ProviderProduct::new(child, Box::new(NoopTransaction))) + } +} + +#[derive(Debug)] +struct OpaqueProviderWrapper(OpaqueProvider); + +impl CommandWrapper for OpaqueProviderWrapper { + fn spawn_provider(&self) -> Option<&dyn SpawnProvider> { + Some(&self.0) + } +} + #[derive(Debug)] struct TransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for TransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for TransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed transparent layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed transparent layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed transparent layer owns its child") } fn process_handle(&self) -> Option> { - self.inner.process_handle() + self.inner().process_handle() } } @@ -94,29 +154,43 @@ struct Transparent; impl CommandWrapper for Transparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(TransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(TransparentChild { + inner: None, + }))) } } #[derive(Debug)] struct LegacyTransparentChild { - inner: Box, + inner: Option>, +} + +impl ChildWrapperLayer for LegacyTransparentChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.inner) + } } impl ChildWrapper for LegacyTransparentChild { fn inner(&self) -> &dyn ChildWrapper { - self.inner.as_ref() + self.inner + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - self.inner.as_mut() + self.inner + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { + fn into_inner(mut self: Box) -> Box { self.inner + .take() + .expect("an installed legacy layer owns its child") } } @@ -237,28 +311,42 @@ struct LegacyTransparent; impl CommandWrapper for LegacyTransparent { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - Ok(Box::new(LegacyTransparentChild { inner: child })) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyTransparentChild { + inner: None, + }))) } } #[repr(transparent)] #[derive(Debug)] -struct LegacyInlineChild(tokio::process::Child); +struct LegacyInlineChild(Option>); + +impl ChildWrapperLayer for LegacyInlineChild { + fn child_wrapper_slots(&mut self) -> ChildWrapperSlots<'_> { + ChildWrapperSlots::new(&mut self.0) + } +} impl ChildWrapper for LegacyInlineChild { fn inner(&self) -> &dyn ChildWrapper { - &self.0 + self.0 + .as_deref() + .expect("an installed legacy layer owns its child") } fn inner_mut(&mut self) -> &mut dyn ChildWrapper { - &mut self.0 + self.0 + .as_deref_mut() + .expect("an installed legacy layer owns its child") } - fn into_inner(self: Box) -> Box { - Box::new(self.0) + fn into_inner(mut self: Box) -> Box { + self.0 + .take() + .expect("an installed legacy layer owns its child") } } @@ -268,13 +356,10 @@ struct LegacyInline; impl CommandWrapper for LegacyInline { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, _core: &CommandWrap, - ) -> Result> { - // SAFETY: `LegacyInline` adds no cleanup or supervision state. - let child = unsafe { child.try_into_inner_child() } - .map_err(|_| std::io::Error::other("legacy inline wrapper expected a native child"))?; - Ok(Box::new(LegacyInlineChild(child))) + ) -> Result> { + Ok(Some(PendingChildWrapper::new(LegacyInlineChild(None)))) } } @@ -300,8 +385,13 @@ fn opaque_child_defaults_to_no_process_handle_without_traversing() { #[test] fn job_object_rejects_an_opaque_child_and_cleans_it_up() { let (child, inner_calls, killed) = opaque_child(); - let core = CommandWrap::with_new("cmd.exe", |_| {}); - let error = JobObject.wrap_child(child, &core).unwrap_err(); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(OpaqueProviderWrapper(OpaqueProvider(Mutex::new(Some( + child, + ))))); + let error = command.spawn().unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); assert_eq!(inner_calls.load(Ordering::SeqCst), 1); @@ -316,7 +406,7 @@ async fn transparent_child_delegates_the_native_process_handle() -> Result<()> { .expect("a native child exposes its process handle") .as_raw_handle(); let mut child: Box = Box::new(TransparentChild { - inner: Box::new(native), + inner: Some(Box::new(native)), }); let delegated_handle = child .process_handle() @@ -335,11 +425,13 @@ async fn job_object_falls_back_through_a_legacy_transparent_child() -> Result<() command.wrap(LegacyTransparent).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait().await?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -353,10 +445,18 @@ async fn job_object_finds_terminal_capabilities_below_multiple_legacy_layers() - resumes: Arc::clone(&resumes), }); let child: Box = Box::new(LegacyTransparentChild { - inner: Box::new(LegacyTransparentChild { inner: terminal }), + inner: Some(Box::new(LegacyTransparentChild { + inner: Some(terminal), + })), }); - let core = CommandWrap::new("cmd.exe"); - let mut child = JobObject.wrap_child(child, &core)?; + let mut child = Some(child); + let mut command = CommandWrap::new("cmd.exe"); + command.wrap(JobObject); + let mut child = command.spawn_with_child(|_| { + Ok(child + .take() + .expect("the custom spawner is invoked exactly once")) + })?; assert_eq!(resumes.load(Ordering::SeqCst), 1); assert!(child.process_handle().is_some()); @@ -390,17 +490,43 @@ async fn provider_job_assignment_precedes_commit_in_both_orders() -> Result<()> Ok(()) } +#[tokio::test] +async fn provider_sidecar_removal_preserves_job_object_consuming_extraction() -> Result<()> { + let resumes = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let mut command = CommandWrap::new("provider-owned-program"); + command + .wrap(JobObject) + .wrap(ProviderWrapper(ProcessProvider { + resumes: Arc::clone(&resumes), + commits: Arc::clone(&commits), + })); + + let child = command.spawn()?; + let mut job = child.into_inner(); + assert_eq!(job.as_ref().type_id(), TypeId::of::()); + job.start_kill()?; + let status = job.wait().await?; + let mut child = job.into_inner(); + assert_eq!(child.wait().await?, status); + assert_eq!(resumes.load(Ordering::SeqCst), 1); + assert_eq!(commits.load(Ordering::SeqCst), 1); + Ok(()) +} + #[tokio::test] async fn job_object_falls_back_through_a_legacy_inline_child() -> Result<()> { let mut command = sleeping_command_wrap(); command.wrap(LegacyInline).wrap(JobObject); let mut child = command.spawn()?; - let direct_type = child.inner().type_id(); + let job_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); let has_handle = child.process_handle().is_some(); child.start_kill()?; let _ = child.wait().await?; + assert_eq!(job_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); assert!(has_handle); Ok(()) @@ -413,8 +539,9 @@ async fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Re let mut child = command.spawn()?; let outer_has_handle = child.process_handle().is_some(); - let direct_type = child.inner().type_id(); - let direct_mut_type = child.inner_mut().type_id(); + let prepared_layer_type = child.inner().type_id(); + let direct_type = child.inner().inner().type_id(); + let prepared_layer_mut_type = child.inner_mut().type_id(); let mut direct = child.into_inner(); let consumed_type = direct.as_ref().type_id(); let consumed_has_handle = direct.process_handle().is_some(); @@ -423,9 +550,336 @@ async fn job_object_uses_delegated_handle_and_preserves_the_direct_child() -> Re let _ = direct.wait().await?; assert!(outer_has_handle); + assert_eq!(prepared_layer_type, TypeId::of::()); assert_eq!(direct_type, TypeId::of::()); - assert_eq!(direct_mut_type, TypeId::of::()); + assert_eq!(prepared_layer_mut_type, TypeId::of::()); assert_eq!(consumed_type, TypeId::of::()); assert!(consumed_has_handle); Ok(()) } + +#[derive(Debug)] +struct ReleaseOnDrop(PathBuf); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + let _ = fs::File::create(&self.0); + } +} + +fn wait_for_pid_blocking(path: &Path) -> Result { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if let Ok(pid) = fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(std::io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn wait_for_process_exit_blocking(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "process did not exit before the test deadline", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +async fn wait_for_pid(path: &Path) -> Result { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if let Ok(pid) = fs::read_to_string(path) + .and_then(|pid| pid.trim().parse().map_err(std::io::Error::other)) + { + return Ok(pid); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant helper did not report its process ID", + )); + } + sleep(Duration::from_millis(10)).await; + } +} + +async fn wait_for_process_exit(guard: ProcessGuard) -> Result<()> { + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + loop { + if guard.has_exited()? { + return guard.disarm(); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "process did not exit before the test deadline", + )); + } + sleep(Duration::from_millis(10)).await; + } +} + +fn descendant_job_command(pid_file: &Path, release_file: &Path) -> Result { + Ok(CommandWrap::with_new(std::env::current_exe()?, |command| { + command + .args([ + "--exact", + "tokio_windows::process_handle::job_wait_direct_child_helper", + "--ignored", + "--nocapture", + ]) + .env(JOB_WAIT_DESCENDANT_PID, pid_file) + .env(JOB_WAIT_DESCENDANT_RELEASE, release_file) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + })) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_descendant_helper() -> Result<()> { + let release = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ); + let deadline = Instant::now() + JOB_WAIT_TIMEOUT; + while !release.exists() { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "descendant release was not signaled", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } + Ok(()) +} + +#[test] +#[ignore = "subprocess helper"] +fn job_wait_direct_child_helper() -> Result<()> { + let pid_file = PathBuf::from( + std::env::var_os(JOB_WAIT_DESCENDANT_PID) + .ok_or_else(|| std::io::Error::other("descendant PID path is missing"))?, + ); + let descendant = StdCommand::new(std::env::current_exe()?) + .args([ + "--exact", + "tokio_windows::process_handle::job_wait_descendant_helper", + "--ignored", + "--nocapture", + ]) + .env( + JOB_WAIT_DESCENDANT_RELEASE, + std::env::var_os(JOB_WAIT_DESCENDANT_RELEASE) + .ok_or_else(|| std::io::Error::other("descendant release path is missing"))?, + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + fs::write(pid_file, descendant.id().to_string())?; + drop(descendant); + Ok(()) +} + +struct DescendantJob { + child: Box, + release_file: PathBuf, + release_on_drop: ReleaseOnDrop, + descendant_process: ProcessGuard, + directory: tempfile::TempDir, +} + +async fn spawn_descendant_job() -> Result { + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release_file = directory.path().join("release"); + let release_on_drop = ReleaseOnDrop(release_file.clone()); + let mut command = descendant_job_command(&pid_file, &release_file)?; + command.wrap(JobObject); + let child = command.spawn()?; + let direct_process = ProcessGuard::open( + child + .id() + .expect("a newly spawned child exposes its process ID"), + )?; + let descendant_process = ProcessGuard::open(wait_for_pid(&pid_file).await?)?; + wait_for_process_exit(direct_process).await?; + Ok(DescendantJob { + child, + release_file, + release_on_drop, + descendant_process, + directory, + }) +} + +fn spawn_descendant_job_without_timer() -> Result { + let directory = tempfile::tempdir()?; + let pid_file = directory.path().join("descendant.pid"); + let release_file = directory.path().join("release"); + let release_on_drop = ReleaseOnDrop(release_file.clone()); + let mut command = descendant_job_command(&pid_file, &release_file)?; + command.wrap(JobObject); + let child = command.spawn()?; + let direct_process = ProcessGuard::open( + child + .id() + .expect("a newly spawned child exposes its process ID"), + )?; + let descendant_process = ProcessGuard::open(wait_for_pid_blocking(&pid_file)?)?; + wait_for_process_exit_blocking(direct_process)?; + Ok(DescendantJob { + child, + release_file, + release_on_drop, + descendant_process, + directory, + }) +} + +#[test] +fn descendant_aware_wait_works_without_a_timer_driver() -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .build()?; + runtime.block_on(async { + let DescendantJob { + mut child, + release_file, + release_on_drop: _release_on_drop, + descendant_process, + directory: _directory, + } = spawn_descendant_job_without_timer()?; + assert_eq!( + child.try_wait()?, + None, + "the direct status is cached while the descendant keeps the job active" + ); + + let (blocker_started_tx, blocker_started_rx) = std::sync::mpsc::channel(); + let (blocker_release_tx, blocker_release_rx) = std::sync::mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = blocker_started_tx.send(()); + let _ = blocker_release_rx.recv(); + }); + blocker_started_rx + .recv_timeout(JOB_WAIT_TIMEOUT) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("blocking-pool guard did not start: {error}"), + ) + })?; + + let mut wait = child.wait(); + let first_poll = + std::future::poll_fn(|context| std::task::Poll::Ready(wait.as_mut().poll(context))) + .await; + assert!( + first_poll.is_pending(), + "the live descendant must leave the timer-disabled wait pending" + ); + drop(wait); + + let mut resumed_wait = child.wait(); + let resumed_poll = std::future::poll_fn(|context| { + std::task::Poll::Ready(resumed_wait.as_mut().poll(context)) + }) + .await; + assert!( + resumed_poll.is_pending(), + "a canceled wait must resume its queued cadence without a timer driver" + ); + + fs::File::create(&release_file)?; + let _ = blocker_release_tx.send(()); + let status = resumed_wait.await?; + blocker.await.map_err(std::io::Error::other)?; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + wait_for_process_exit_blocking(descendant_process)?; + Ok(()) + }) +} + +#[tokio::test] +async fn job_waits_for_descendant_after_direct_child_exits() -> Result<()> { + let DescendantJob { + mut child, + release_file, + release_on_drop: _release_on_drop, + descendant_process, + directory: _directory, + } = spawn_descendant_job().await?; + assert_eq!( + child.try_wait()?, + None, + "direct-child exit must remain pending while the job has a live descendant" + ); + assert!( + tokio::time::timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "JobObject wait returned while the descendant remained alive" + ); + fs::File::create(&release_file)?; + let status = tokio::time::timeout(JOB_WAIT_TIMEOUT, child.wait()) + .await + .map_err(std::io::Error::other)??; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + assert_eq!(child.wait().await?, status); + wait_for_process_exit(descendant_process).await?; + Ok(()) +} + +#[tokio::test] +async fn canceled_job_wait_preserves_pending_drain_state() -> Result<()> { + let DescendantJob { + mut child, + release_file, + release_on_drop: _release_on_drop, + descendant_process, + directory: _directory, + } = spawn_descendant_job().await?; + assert!( + tokio::time::timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "the first wait must remain pending before cancellation" + ); + assert_eq!(child.try_wait()?, None); + assert!( + tokio::time::timeout(Duration::from_millis(100), child.wait()) + .await + .is_err(), + "a repeated wait after cancellation must resume the pending job drain" + ); + fs::File::create(&release_file)?; + let status = tokio::time::timeout(JOB_WAIT_TIMEOUT, child.wait()) + .await + .map_err(std::io::Error::other)??; + assert!(status.success()); + assert_eq!(child.try_wait()?, Some(status)); + wait_for_process_exit(descendant_process).await?; + Ok(()) +} diff --git a/tests/unix_attempt_policy.rs b/tests/unix_attempt_policy.rs index b226d61..cad3302 100644 --- a/tests/unix_attempt_policy.rs +++ b/tests/unix_attempt_policy.rs @@ -127,6 +127,30 @@ macro_rules! unix_attempt_policy_tests { } } + #[derive(Debug)] + struct ReapBeforeWrapping; + + impl CommandWrapper for ReapBeforeWrapping { + fn post_spawn( + &mut self, + _attempt: &mut SpawnAttempt, + child: &mut dyn ChildWrapper, + _command: &CommandWrap, + ) -> io::Result<()> { + let deadline = Instant::now() + EXIT_TIMEOUT; + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the post-spawn hook could not reap the direct child", + )); + } + sleep(Duration::from_millis(5)); + } + Ok(()) + } + } + #[derive(Debug)] struct ReplaceNative; @@ -208,6 +232,92 @@ macro_rules! unix_attempt_policy_tests { } } + fn spawn_transport( + command: &mut CommandWrap, + transport: u8, + ) -> io::Result> { + match transport { + 0 => command.spawn(), + 1 => command.spawn_with(|native| native.spawn()), + 2 => command.spawn_with_child(|native| { + Ok(Box::new(native.spawn()?) as Box) + }), + _ => unreachable!("the test enumerates every transport"), + } + } + + #[test] + fn post_hook_reap_preserves_supervision_installation_identity() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for session in [false, true] { + for reaper_first in [false, true] { + for transport in 0..3 { + let mut command = command_with_exit(23); + if reaper_first { + command.wrap(ReapBeforeWrapping); + if session { + command.wrap(ProcessSession); + } else { + command.wrap(ProcessGroup::leader()); + } + } else { + if session { + command.wrap(ProcessSession); + } else { + command.wrap(ProcessGroup::leader()); + } + command.wrap(ReapBeforeWrapping); + } + + let mut child = spawn_transport(&mut command, transport).unwrap(); + let first = wait_for_exit(child.as_mut()); + assert_eq!(first.code(), Some(23)); + assert_eq!(wait_for_exit(child.as_mut()), first); + assert_eq!(child.try_wait().unwrap(), Some(first)); + } + } + } + } + + #[test] + fn direct_signal_and_start_kill_are_noops_after_reap() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + let mut command = command_with_exit(0); + let mut child = command.spawn().unwrap(); + let status = wait_for_exit(child.as_mut()); + + child.signal(Signal::SIGCONT as i32).unwrap(); + child.start_kill().unwrap(); + assert_eq!(wait_for_exit(child.as_mut()), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); + } + + #[test] + fn lower_layer_reap_makes_group_signals_noops() { + let runtime = runtime(); + let _runtime_guard = runtime.as_ref().map(tokio::runtime::Runtime::enter); + for session in [false, true] { + let mut command = command_with_exit(0); + if session { + command.wrap(ProcessSession); + } else { + command.wrap(ProcessGroup::leader()); + } + let mut child = command.spawn().unwrap(); + let status = { + let inner = child.inner_mut(); + wait_for_exit(inner) + }; + + child.signal(Signal::SIGCONT as i32).unwrap(); + child.start_kill().unwrap(); + assert_eq!(wait_for_exit(child.as_mut()), status); + assert_eq!(child.try_wait().unwrap(), Some(status)); + } + } + #[test] fn native_only_session_command_is_reusable() { let runtime = runtime(); diff --git a/tests/wrapper_hooks.rs b/tests/wrapper_hooks.rs index 09e5cca..0766ddd 100644 --- a/tests/wrapper_hooks.rs +++ b/tests/wrapper_hooks.rs @@ -8,6 +8,7 @@ macro_rules! wrapper_hook_tests { $spawn_attempt:path, $command_wrapper:path, $child_wrapper:path, + $pending_child_wrapper:path, $runtime:expr ) => { mod $module { @@ -23,6 +24,7 @@ macro_rules! wrapper_hook_tests { use $child_wrapper as ChildWrapper; use $command_wrap as CommandWrap; use $command_wrapper as CommandWrapper; + use $pending_child_wrapper as PendingChildWrapper; use $spawn_attempt as SpawnAttempt; const EXIT_TIMEOUT: Duration = Duration::from_secs(5); @@ -76,11 +78,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.observe(Phase::Wrap, core); - Ok(child) + Ok(None) } } @@ -119,11 +121,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.observe(Phase::Wrap, core); - Ok(child) + Ok(None) } } @@ -191,11 +193,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.visit(Phase::Wrap, core)?; - Ok(child) + Ok(None) } } @@ -234,11 +236,11 @@ macro_rules! wrapper_hook_tests { fn wrap_child( &mut self, - child: Box, + _child: &mut dyn ChildWrapper, core: &CommandWrap, - ) -> io::Result> { + ) -> io::Result> { self.observe(Phase::Wrap, core); - Ok(child) + Ok(None) } } @@ -427,6 +429,7 @@ wrapper_hook_tests!( process_wrap::std::SpawnAttempt, process_wrap::std::CommandWrapper, process_wrap::std::ChildWrapper, + process_wrap::std::PendingChildWrapper, None ); @@ -438,6 +441,7 @@ wrapper_hook_tests!( process_wrap::tokio::SpawnAttempt, process_wrap::tokio::CommandWrapper, process_wrap::tokio::ChildWrapper, + process_wrap::tokio::PendingChildWrapper, Some( tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/tests/wrapper_lookup.rs b/tests/wrapper_lookup.rs index 7cc6a4e..b5a6db9 100644 --- a/tests/wrapper_lookup.rs +++ b/tests/wrapper_lookup.rs @@ -1,3 +1,4 @@ +#[cfg(any(feature = "std", feature = "tokio1"))] macro_rules! wrapper_lookup_tests { ($module:ident, $command_wrap:path, $command_wrapper:path) => { mod $module {