Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions crates/basal-testkit/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,33 @@ mod windows {
}

pub fn output_until(&self, timeout: Duration) -> io::Result<Output> {
let command = self.plain_command()?;
super::super::process::windows::output_until(&command, timeout)
}

/// Starts the output deadline only after `ready` confirms the child is ready.
pub fn output_until_ready<F>(
&self,
timeout: Duration,
readiness_timeout: Duration,
ready: F,
) -> io::Result<Output>
where
F: FnMut() -> io::Result<bool>,
{
let command = self.plain_command()?;
super::super::process::windows::output_until_ready(
&command,
timeout,
readiness_timeout,
ready,
)
}

fn plain_command(&self) -> io::Result<basal_launch::PlainCommand> {
use basal_launch::{PlainCommand, PlainStdio};
let program = resolve_program(&self.program, &self.env, self.cwd.as_deref())?;
let command = PlainCommand {
Ok(PlainCommand {
program,
args: self.args.clone(),
env: self
Expand All @@ -106,8 +130,7 @@ mod windows {
stdin: PlainStdio::Null,
stdout: PlainStdio::Piped,
stderr: PlainStdio::Piped,
};
super::super::process::windows::output_until(&command, timeout)
})
}
}

Expand Down
16 changes: 16 additions & 0 deletions crates/basal-testkit/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,22 @@ pub fn output_until(command: &mut Command, timeout: Duration) -> io::Result<std:
command.output_until(timeout)
}

#[cfg(windows)]
/// Capture a subprocess after its readiness predicate succeeds.
///
/// The readiness timeout is separate; `timeout` begins only once `ready` returns true.
pub fn output_until_ready<F>(
command: &mut Command,
timeout: Duration,
readiness_timeout: Duration,
ready: F,
) -> io::Result<std::process::Output>
where
F: FnMut() -> io::Result<bool>,
{
command.output_until_ready(timeout, readiness_timeout, ready)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
57 changes: 57 additions & 0 deletions crates/basal-testkit/src/process/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,30 @@ pub(crate) fn output_until(
command: &basal_launch::PlainCommand,
timeout: std::time::Duration,
) -> io::Result<std::process::Output> {
output_until_inner(command, timeout, None, || Ok(true))
}

pub(crate) fn output_until_ready<F>(
command: &basal_launch::PlainCommand,
timeout: std::time::Duration,
readiness_timeout: std::time::Duration,
ready: F,
) -> io::Result<std::process::Output>
where
F: FnMut() -> io::Result<bool>,
{
output_until_inner(command, timeout, Some(readiness_timeout), ready)
}

fn output_until_inner<F>(
command: &basal_launch::PlainCommand,
timeout: std::time::Duration,
readiness_timeout: Option<std::time::Duration>,
mut ready: F,
) -> io::Result<std::process::Output>
where
F: FnMut() -> io::Result<bool>,
{
use std::io::Read;
use std::time::{Duration, Instant};
let mut child = basal_launch::spawn_plain(command).map_err(io::Error::other)?;
Expand All @@ -167,6 +191,39 @@ pub(crate) fn output_until(
let mut bytes = Vec::new();
stderr.read_to_end(&mut bytes).map(|_| bytes)
});

if let Some(readiness_timeout) = readiness_timeout {
let readiness_deadline = Instant::now() + readiness_timeout;
let readiness_error = loop {
match ready() {
Ok(true) if Instant::now() <= readiness_deadline => break None,
Ok(false) if Instant::now() < readiness_deadline => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(_) => {
break Some(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"readiness condition was not satisfied within {readiness_timeout:?}"
),
));
}
Err(error) => break Some(error),
}
};
if let Some(error) = readiness_error {
child.kill()?;
child.wait()?;
drop(child);
out.join()
.map_err(|_| io::Error::other("stdout reader panicked"))??;
err.join()
.map_err(|_| io::Error::other("stderr reader panicked"))??;
return Err(error);
}
}

// The output deadline starts only after the optional readiness condition holds.
let deadline = Instant::now() + timeout;
let mut timed_out = false;
let status = loop {
Expand Down
99 changes: 91 additions & 8 deletions crates/basal-testkit/tests/windows_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use basal_testkit::command::Command;
use basal_testkit::harness::scratch;
use basal_testkit::process::{assert_reaped, output_until};
use basal_testkit::process::{assert_reaped, output_until_ready};
use basal_testkit::{WorkerProcess, worker_binary};
use std::path::Path;
use std::time::Duration;
Expand All @@ -14,20 +14,27 @@ fn pipe_tree(dir: &Path, hold_parent: bool) -> Command {
.join("v1.0")
.join("powershell.exe");
let pid_file = dir.join("descendant.pid");
let release_file = dir.join("release-parent");
let quoted_path = pid_file.display().to_string().replace('\'', "''");
let quoted_release_path = release_file.display().to_string().replace('\'', "''");
// Start-Process without redirection inherits the parent's standard pipes.
// The child publishes its pid before waiting, so a missing job kill would
// leave both a live process and a pipe reader that cannot finish.
// Keep the exiting parent alive until the test confirms the child is live.
let parent_action = if hold_parent {
"Start-Sleep -Seconds 600".to_owned()
} else {
format!(
"while (!(Test-Path '{}')) {{ Start-Sleep -Milliseconds 10 }}; exit 0",
quoted_release_path
)
};
let script = format!(
"$p = Start-Process -PassThru -NoNewWindow -FilePath '{}' -ArgumentList '-NoProfile','-Command','\"[IO.File]::WriteAllText(''{}'', [string]$PID); Start-Sleep -Seconds 600\"'; while (!(Test-Path '{}')) {{ Start-Sleep -Milliseconds 10 }}; {}",
powershell.display(),
quoted_path,
quoted_path,
if hold_parent {
"Start-Sleep -Seconds 600"
} else {
"exit 0"
},
parent_action,
);
let mut command = Command::new(powershell);
command
Expand All @@ -36,6 +43,44 @@ fn pipe_tree(dir: &Path, hold_parent: bool) -> Command {
command
}

fn descendant_is_live(dir: &Path) -> std::io::Result<bool> {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::Foundation::{WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject,
};

let pid_file = dir.join("descendant.pid");
let pid_text = match std::fs::read_to_string(pid_file) {
Ok(pid) => pid,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
let pid = match pid_text.trim().parse::<u32>() {
Ok(pid) => pid,
Err(_) => return Ok(false),
};
// SYNCHRONIZE is a standard access right, not a process-specific flag.
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | 0x0010_0000, 0, pid) };
if handle.is_null() {
let error = std::io::Error::last_os_error();
return if error.raw_os_error() == Some(87) {
Ok(false)
} else {
Err(error)
};
}
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
match unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) } {
WAIT_TIMEOUT => Ok(true),
WAIT_OBJECT_0 => Ok(false),
WAIT_FAILED => Err(std::io::Error::last_os_error()),
result => Err(std::io::Error::other(format!(
"unexpected WaitForSingleObject result {result}"
))),
}
}

fn assert_descendant_dead(dir: &Path) {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
Expand Down Expand Up @@ -69,7 +114,21 @@ fn assert_descendant_dead(dir: &Path) {
fn subprocess_deadline_reaps_descendants_holding_output_pipes() {
let dir = scratch("windows-pipe-deadline");
let mut command = pipe_tree(&dir, true);
let error = output_until(&mut command, Duration::from_secs(10)).unwrap_err();
let mut descendant_ready = false;
let error = output_until_ready(
&mut command,
Duration::from_secs(10),
Duration::from_secs(120),
|| {
descendant_ready = descendant_is_live(&dir)?;
Ok(descendant_ready)
},
)
.unwrap_err();
assert!(
descendant_ready,
"descendant never became ready within 120s: {error}"
);
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
assert_descendant_dead(&dir);
std::fs::remove_dir_all(dir).unwrap();
Expand All @@ -78,7 +137,31 @@ fn subprocess_deadline_reaps_descendants_holding_output_pipes() {
#[test]
fn subprocess_exit_reaps_descendants_holding_output_pipes() {
let dir = scratch("windows-pipe-exit");
let output = output_until(&mut pipe_tree(&dir, false), Duration::from_secs(30)).unwrap();
let mut command = pipe_tree(&dir, false);
let mut descendant_ready = false;
let output = output_until_ready(
&mut command,
Duration::from_secs(30),
Duration::from_secs(120),
|| {
descendant_ready = descendant_is_live(&dir)?;
if descendant_ready {
std::fs::write(dir.join("release-parent"), b"")?;
}
Ok(descendant_ready)
},
)
.unwrap_or_else(|error| {
assert!(
descendant_ready,
"descendant never became ready within 120s: {error}"
);
panic!("subprocess exit wait failed: {error}");
});
assert!(
descendant_ready,
"descendant never became ready within 120s"
);
assert!(
output.status.success(),
"{}",
Expand Down
26 changes: 26 additions & 0 deletions mutations.d/testkit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,29 @@ only = true
platforms = ["macos"]
timeout_s = 600
build_timeout_s = 3600

[[control]]
id = "windows-subprocess-deadline-starts-after-descendant-readiness"
guards = "The subprocess deadline starts only after its descendant is provably live"
file = "crates/basal-testkit/tests/windows_process.rs"
old = """
let error = output_until_ready(
&mut command,
Duration::from_secs(10),
Duration::from_secs(120),
|| {
descendant_ready = descendant_is_live(&dir)?;
Ok(descendant_ready)
},
)
.unwrap_err();"""
new = "let error = basal_testkit::process::output_until(&mut command, Duration::from_secs(10)).unwrap_err(); // NON-VACUITY BREAK"
test_file = "crates/basal-testkit/tests/windows_process.rs"
runner = "cargo"
package = "basal-testkit"
platforms = ["windows"]
target = "--test windows_process"
expect_red = ["subprocess_deadline_reaps_descendants_holding_output_pipes"]
only = true
timeout_s = 600
build_timeout_s = 3600
Loading