use std::io::Write;
use std::time::{Duration, Instant};
use processkit::{Command, Outcome, ProcessGroup, ProcessGroupOptions};
use crate::common::completes_within;
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
"#;
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");
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)
}
fn ensure_console() {
use windows_sys::Win32::System::Console::AllocConsole;
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();
let group = ProcessGroup::with_options(
ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(25)),
)
.expect("create group");
let (cmd, _script) = ctrl_break_powershell("Environment.Exit(42)");
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(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();
let group = ProcessGroup::with_options(
ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(1)),
)
.expect("create group");
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() {
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");
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:?})"
);
}
const PS_WINDOW_SCRIPT: &str = r#"Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.Text = 'processkit-wmclose-test'
$form.Add_FormClosing({ [Environment]::Exit(43) })
$form.Add_Shown({ [Console]::Out.WriteLine('ready'); [Console]::Out.Flush() })
[System.Windows.Forms.Application]::Run($form)
"#;
fn wm_close_powershell() -> (Command, tempfile::TempPath) {
let mut f = tempfile::Builder::new()
.suffix(".ps1")
.tempfile()
.expect("create temp script");
f.write_all(PS_WINDOW_SCRIPT.as_bytes())
.expect("write temp script");
f.flush().expect("flush temp script");
let temp_path = f.into_temp_path();
let cmd = Command::new("powershell").args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Sta",
"-File",
&temp_path.to_string_lossy(),
]);
(cmd, temp_path)
}
#[tokio::test]
#[ignore = "spawns a real GUI subprocess; automatic graceful WM_CLOSE shutdown"]
async fn windows_wm_close_lets_a_windowed_child_exit_within_the_grace() {
let group = ProcessGroup::with_options(
ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(25)),
)
.expect("create group");
let (cmd, _script) = wm_close_powershell();
let mut run = group.start(&cmd).await.expect("start");
run.wait_for_line(|l| l.contains("ready"), Duration::from_secs(30))
.await
.expect("window shown");
let start = Instant::now();
completes_within(
Duration::from_secs(30),
"wm-close graceful shutdown",
group.shutdown(),
)
.await
.expect("shutdown ok");
assert!(
start.elapsed() < Duration::from_secs(12),
"a WM_CLOSE-handling windowed 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(43),
"the child exited via its WM_CLOSE FormClosing handler (code 43), not the hard-kill fallback"
);
}
#[tokio::test]
#[ignore = "spawns a real windowless console subprocess; WM_CLOSE tier must NOT delay its prompt hard kill"]
async fn windows_windowless_child_is_hard_killed_promptly_not_after_the_grace() {
let group = ProcessGroup::with_options(
ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(20)),
)
.expect("create group");
let run = group
.start(&Command::new("ping").args(["-n", "30", "127.0.0.1"]))
.await
.expect("start");
tokio::time::sleep(Duration::from_millis(300)).await;
let start = Instant::now();
completes_within(
Duration::from_secs(10),
"windowless prompt shutdown",
group.shutdown(),
)
.await
.expect("shutdown ok");
assert!(
start.elapsed() < Duration::from_secs(5),
"a windowless child has no WM_CLOSE target, so it is hard-killed promptly — \
NOT after the 20s grace (took {:?})",
start.elapsed()
);
let outcome = completes_within(Duration::from_secs(5), "reap", run.wait())
.await
.expect("wait");
assert!(
!matches!(outcome, Outcome::Exited(0)),
"the windowless child was hard-killed by the atomic fallback (got {outcome:?})"
);
}
#[cfg(feature = "process-control")]
#[tokio::test]
#[ignore = "spawns a real windowless subprocess; T-167 stop() reports an unsupported soft signal"]
async fn windows_windowless_stop_reports_unsupported_soft_signal_and_escalates_promptly() {
use processkit::SoftSignal;
let group = ProcessGroup::with_options(
ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(20)),
)
.expect("create group");
let run = group
.start(&Command::new("ping").args(["-n", "30", "127.0.0.1"]))
.await
.expect("start");
tokio::time::sleep(Duration::from_millis(300)).await;
let start = Instant::now();
let report = completes_within(
Duration::from_secs(10),
"windowless stop",
group.stop(Duration::from_secs(20), true),
)
.await
.expect("stop ok");
assert_eq!(
report.soft_signal(),
SoftSignal::Unsupported,
"a windowless Job Object with no console-CTRL leader has no soft-signal tier"
);
assert_eq!(
report.attempted_signal(),
None,
"nothing soft was attempted"
);
assert!(
report.escalated(),
"the live tree was hard-killed (TerminateJobObject) (report: {report:?})"
);
assert!(
!report.drained_within_grace(),
"a live windowless tree does not soft-drain"
);
assert!(
report.members_before().is_some_and(|n| n >= 1),
"the ping child was alive before the kill (report: {report:?})"
);
assert!(
start.elapsed() < Duration::from_secs(5),
"the atomic branch is prompt — it does NOT wait out the 20s grace (took {:?})",
start.elapsed()
);
let outcome = completes_within(Duration::from_secs(5), "reap", run.wait())
.await
.expect("wait");
assert!(
!matches!(outcome, Outcome::Exited(0)),
"the windowless child was hard-killed by the atomic fallback (got {outcome:?})"
);
}
#[cfg(feature = "process-control")]
#[tokio::test]
#[ignore = "spawns a real GUI subprocess; T-167 stop() reports a WM_CLOSE soft signal that drained"]
async fn windows_wm_close_stop_reports_a_sent_soft_signal_that_drained() {
use processkit::{Signal, SoftSignal};
let group = ProcessGroup::with_options(
ProcessGroupOptions::default().shutdown_timeout(Duration::from_secs(25)),
)
.expect("create group");
let (cmd, _script) = wm_close_powershell();
let mut run = group.start(&cmd).await.expect("start");
run.wait_for_line(|l| l.contains("ready"), Duration::from_secs(30))
.await
.expect("window shown");
let report = completes_within(
Duration::from_secs(30),
"wm-close stop",
group.stop(Duration::from_secs(25), true),
)
.await
.expect("stop ok");
assert_eq!(
report.soft_signal(),
SoftSignal::Sent(Signal::Term),
"the WM_CLOSE soft trigger reached the windowed member"
);
assert_eq!(report.attempted_signal(), Some(Signal::Term));
assert!(
report.drained_within_grace(),
"the windowed child closed within the grace (report: {report:?})"
);
assert!(
!report.escalated(),
"an in-time WM_CLOSE drain needs no TerminateJobObject"
);
assert!(
report.elapsed() < Duration::from_secs(12),
"an early WM_CLOSE drain must not ride the 25s grace (report: {report:?})"
);
let outcome = completes_within(Duration::from_secs(5), "reap", run.wait())
.await
.expect("wait");
assert_eq!(
outcome,
Outcome::Exited(43),
"the child exited via its WM_CLOSE FormClosing handler (code 43), not the hard-kill fallback"
);
}