processkit 2.3.1

Async child-process management for tokio: whole-tree kill-on-drop (no orphans), plus streaming, pipelines, timeouts, and supervision
Documentation
//! Windows-only: the opt-in graceful teardown via a console `CTRL_BREAK` event
//! ([`Command::windows_graceful_ctrl_break`]) — gated by the `#[cfg(windows)] mod`
//! declaration in `main.rs`. The unix graceful ladder lives in `shutdown.rs`.
//!
//! The direct child is spawned into its own console process group and, at
//! [`ProcessGroup::shutdown`], sent `GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT)`
//! before the grace window, then `TerminateJobObject`'d if it hasn't exited.
//! Three scenarios, driven against a purpose-built PowerShell child that installs
//! a real `SetConsoleCtrlHandler` (system tools like `ping` install their own
//! handlers and ignore `CTRL_BREAK`, so they can't stand in here):
//!
//! - a child whose handler **exits** on the event drains early (exit code 42);
//! - a child whose handler **ignores** it rides the grace to the hard-kill
//!   fallback;
//! - an **off-console** child (`create_no_window`) never receives the event and
//!   likewise falls back — the documented console-only boundary.
//!
//! `GenerateConsoleCtrlEvent` routes through the console the sender shares with
//! the child, so the two delivery tests first ensure this process owns one (a
//! harness launched headless has none). All are `#[ignore]`d real-subprocess
//! tests, run on the Windows CI leg via `--include-ignored`.

use std::io::Write;
use std::time::{Duration, Instant};

use processkit::{Command, Outcome, ProcessGroup, ProcessGroupOptions};

use crate::common::completes_within;

/// A PowerShell script that installs a `CTRL_BREAK` handler, prints `ready` once
/// it is armed, then idles ~30s. `HANDLER_BODY` is the C# statement the handler
/// runs on the event (e.g. `Environment.Exit(42)`, or nothing to ignore it).
const PS_HANDLER_TEMPLATE: &str = r#"Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class Ctrl {
  public delegate bool Handler(uint sig);
  [DllImport("kernel32.dll")] public static extern bool SetConsoleCtrlHandler(Handler handler, bool add);
  static Handler _h;
  public static void Arm() { _h = s => { HANDLER_BODY; return true; }; SetConsoleCtrlHandler(_h, true); }
}
'@
[Ctrl]::Arm()
Write-Output 'ready'
Start-Sleep -Seconds 30
"#;

/// Build a `Command` that runs a `CTRL_BREAK`-handling PowerShell child (opted
/// into the graceful console teardown), plus the temp script path that must be
/// kept alive for the child's lifetime. `handler_body` is the C# statement the
/// handler runs on the event.
fn ctrl_break_powershell(handler_body: &str) -> (Command, tempfile::TempPath) {
    let script = PS_HANDLER_TEMPLATE.replace("HANDLER_BODY", handler_body);
    let mut f = tempfile::Builder::new()
        .suffix(".ps1")
        .tempfile()
        .expect("create temp script");
    f.write_all(script.as_bytes()).expect("write temp script");
    f.flush().expect("flush temp script");
    // Close our handle before spawning — Windows blocks PowerShell from reading a
    // file we still hold open — while keeping it on disk until the path drops.
    let temp_path = f.into_temp_path();
    let cmd = Command::new("powershell")
        .args([
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
            &temp_path.to_string_lossy(),
        ])
        .windows_graceful_ctrl_break();
    (cmd, temp_path)
}

/// Ensure THIS process owns a console before spawning a child we intend to
/// `CTRL_BREAK`.
///
/// `GenerateConsoleCtrlEvent` routes through the console the *sender* shares with
/// the target group; a harness launched without one (a detached/service context)
/// has nothing to route through and the event silently no-ops. `AllocConsole`
/// attaches one — a harmless no-op (`ERROR_ACCESS_DENIED`) when a console already
/// exists, and it does not disturb std handles already redirected (cargo's output
/// capture). The child, spawned after, inherits the console and can receive the
/// event. This mirrors the "console-only" boundary the mechanism documents.
fn ensure_console() {
    use windows_sys::Win32::System::Console::AllocConsole;
    // SAFETY: a plain console allocation; the result is deliberately ignored (it
    // fails when a console is already present, which is fine).
    unsafe {
        let _ = AllocConsole();
    }
}

#[tokio::test]
#[ignore = "spawns a real console subprocess; opt-in CTRL_BREAK graceful shutdown"]
async fn windows_ctrl_break_lets_a_handling_child_exit_within_the_grace() {
    ensure_console();
    // A generous grace — the child must exit *early* on the CTRL_BREAK, so a
    // regression that failed to deliver it would ride the full grace and trip the
    // bound below.
    let group = ProcessGroup::with_options(
        ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(25)),
    )
    .expect("create group");

    // The handler exits with the distinctive code 42, so the reported outcome is
    // positive proof the CTRL_BREAK ran the handler — not the TerminateJobObject
    // fallback (which would exit 1).
    let (cmd, _script) = ctrl_break_powershell("Environment.Exit(42)");
    let mut run = group.start(&cmd).await.expect("start");
    // Wait until the handler is armed (Add-Type compiles first — a few seconds on
    // a cold runner) before signalling: a CTRL_BREAK landing earlier would hit the
    // default handler and terminate the child, masking the graceful path.
    run.wait_for_line(|l| l.contains("ready"), Duration::from_secs(30))
        .await
        .expect("handler armed");

    let start = Instant::now();
    completes_within(
        Duration::from_secs(30),
        "ctrl-break graceful shutdown",
        group.shutdown(),
    )
    .await
    .expect("shutdown ok");
    assert!(
        start.elapsed() < Duration::from_secs(12),
        "a CTRL_BREAK-handling child must exit early, not ride the 25s grace (took {:?})",
        start.elapsed()
    );

    let outcome = completes_within(Duration::from_secs(5), "reap", run.wait())
        .await
        .expect("wait");
    assert_eq!(
        outcome,
        Outcome::Exited(42),
        "the child exited via its CTRL_BREAK handler (code 42), not the hard-kill fallback"
    );
}

#[tokio::test]
#[ignore = "spawns a CTRL_BREAK-ignoring subprocess; escalates to TerminateJobObject"]
async fn windows_ctrl_break_escalates_to_terminate_for_an_ignoring_child() {
    ensure_console();
    // A short grace: the child receives the CTRL_BREAK but its handler ignores it
    // (returns handled without exiting), so it must ride the grace to the hard
    // kill.
    let group = ProcessGroup::with_options(
        ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(1)),
    )
    .expect("create group");

    // An empty handler body: the handler returns `true` (handled) without exiting,
    // so the child keeps running through the grace.
    let (cmd, _script) = ctrl_break_powershell("");
    let mut run = group.start(&cmd).await.expect("start");
    run.wait_for_line(|l| l.contains("ready"), Duration::from_secs(30))
        .await
        .expect("handler armed");

    let start = Instant::now();
    completes_within(
        Duration::from_secs(15),
        "ctrl-break escalation shutdown",
        group.shutdown(),
    )
    .await
    .expect("shutdown ok");
    assert!(
        start.elapsed() >= Duration::from_millis(800),
        "an ignoring child must ride the ~1s grace before TerminateJobObject (took {:?})",
        start.elapsed()
    );

    let outcome = completes_within(Duration::from_secs(5), "reap", run.wait())
        .await
        .expect("wait");
    assert!(
        !matches!(outcome, Outcome::Exited(42)),
        "the child did NOT exit via its handler — it was hard-killed by the fallback (got {outcome:?})"
    );
}

#[tokio::test]
#[ignore = "spawns a real off-console subprocess; CTRL_BREAK falls back to TerminateJobObject"]
async fn windows_ctrl_break_falls_back_to_terminate_for_an_off_console_child() {
    // `create_no_window` detaches the child from this process's console, so the
    // CTRL_BREAK cannot reach it — the documented console-only boundary. It rides
    // the grace to the TerminateJobObject fallback. A plain `ping -n 30` child is
    // fine here: no console event ever reaches it, so its own handler behavior is
    // irrelevant.
    let group = ProcessGroup::with_options(
        ProcessGroupOptions::default().shutdown_timeout(Duration::from_millis(700)),
    )
    .expect("create group");

    let run = group
        .start(
            &Command::new("ping")
                .args(["-n", "30", "127.0.0.1"])
                .windows_graceful_ctrl_break()
                .create_no_window(),
        )
        .await
        .expect("start");
    // The ≈30s child is still running after a brief settle — it will NOT exit on
    // its own within the 700ms grace.
    tokio::time::sleep(Duration::from_millis(300)).await;

    let start = Instant::now();
    completes_within(
        Duration::from_secs(10),
        "ctrl-break fallback shutdown",
        group.shutdown(),
    )
    .await
    .expect("shutdown ok");
    assert!(
        start.elapsed() >= Duration::from_millis(500),
        "an off-console child rides the grace before the TerminateJobObject fallback (took {:?})",
        start.elapsed()
    );

    let outcome = completes_within(Duration::from_secs(5), "reap", run.wait())
        .await
        .expect("wait");
    assert!(
        !matches!(outcome, Outcome::Exited(0)),
        "the off-console child was hard-killed by the fallback (got {outcome:?})"
    );
}