From 20e0a114cf56d7adc5131a61cbec9e3cd7eaa38b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:00:59 +0200 Subject: [PATCH 1/3] mason: establish descendant readiness before deadline --- crates/basal-testkit/src/command.rs | 29 ++++++- crates/basal-testkit/src/process.rs | 16 ++++ crates/basal-testkit/src/process/windows.rs | 57 +++++++++++++ crates/basal-testkit/tests/windows_process.rs | 79 ++++++++++++++++++- mutations.d/testkit.toml | 17 ++++ 5 files changed, 192 insertions(+), 6 deletions(-) diff --git a/crates/basal-testkit/src/command.rs b/crates/basal-testkit/src/command.rs index 6f44f424..3a41c68f 100644 --- a/crates/basal-testkit/src/command.rs +++ b/crates/basal-testkit/src/command.rs @@ -92,9 +92,33 @@ mod windows { } pub fn output_until(&self, timeout: Duration) -> io::Result { + 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( + &self, + timeout: Duration, + readiness_timeout: Duration, + ready: F, + ) -> io::Result + where + F: FnMut() -> io::Result, + { + let command = self.plain_command()?; + super::super::process::windows::output_until_ready( + &command, + timeout, + readiness_timeout, + ready, + ) + } + + fn plain_command(&self) -> io::Result { 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 @@ -106,8 +130,7 @@ mod windows { stdin: PlainStdio::Null, stdout: PlainStdio::Piped, stderr: PlainStdio::Piped, - }; - super::super::process::windows::output_until(&command, timeout) + }) } } diff --git a/crates/basal-testkit/src/process.rs b/crates/basal-testkit/src/process.rs index 8b6bb3a3..5d53fa48 100644 --- a/crates/basal-testkit/src/process.rs +++ b/crates/basal-testkit/src/process.rs @@ -383,6 +383,22 @@ pub fn output_until(command: &mut Command, timeout: Duration) -> io::Result( + command: &mut Command, + timeout: Duration, + readiness_timeout: Duration, + ready: F, +) -> io::Result +where + F: FnMut() -> io::Result, +{ + command.output_until_ready(timeout, readiness_timeout, ready) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/basal-testkit/src/process/windows.rs b/crates/basal-testkit/src/process/windows.rs index 01ee5206..0fe60f18 100644 --- a/crates/basal-testkit/src/process/windows.rs +++ b/crates/basal-testkit/src/process/windows.rs @@ -154,6 +154,30 @@ pub(crate) fn output_until( command: &basal_launch::PlainCommand, timeout: std::time::Duration, ) -> io::Result { + output_until_inner(command, timeout, None, || Ok(true)) +} + +pub(crate) fn output_until_ready( + command: &basal_launch::PlainCommand, + timeout: std::time::Duration, + readiness_timeout: std::time::Duration, + ready: F, +) -> io::Result +where + F: FnMut() -> io::Result, +{ + output_until_inner(command, timeout, Some(readiness_timeout), ready) +} + +fn output_until_inner( + command: &basal_launch::PlainCommand, + timeout: std::time::Duration, + readiness_timeout: Option, + mut ready: F, +) -> io::Result +where + F: FnMut() -> io::Result, +{ use std::io::Read; use std::time::{Duration, Instant}; let mut child = basal_launch::spawn_plain(command).map_err(io::Error::other)?; @@ -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 { diff --git a/crates/basal-testkit/tests/windows_process.rs b/crates/basal-testkit/tests/windows_process.rs index 37e4a032..66601910 100644 --- a/crates/basal-testkit/tests/windows_process.rs +++ b/crates/basal-testkit/tests/windows_process.rs @@ -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; @@ -36,6 +36,44 @@ fn pipe_tree(dir: &Path, hold_parent: bool) -> Command { command } +fn descendant_is_live(dir: &Path) -> std::io::Result { + 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::() { + 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; @@ -69,7 +107,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(); @@ -78,7 +130,28 @@ 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)?; + 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(), "{}", diff --git a/mutations.d/testkit.toml b/mutations.d/testkit.toml index 26fa5178..a6e0e534 100644 --- a/mutations.d/testkit.toml +++ b/mutations.d/testkit.toml @@ -176,3 +176,20 @@ 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/src/process/windows.rs" +old = "output_until_inner(command, timeout, Some(readiness_timeout), ready)" +new = "output_until_inner(command, timeout, None, ready) // NON-VACUITY BREAK" +edits = [] +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 From 2420fc9ce2322d7272d9382fbf67b5bed9b8ad6d Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:16:04 +0200 Subject: [PATCH 2/3] mason: isolate deadline mutation to guarded test --- crates/basal-testkit/tests/windows_process.rs | 20 ++++++++++++++----- mutations.d/testkit.toml | 16 ++++++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/basal-testkit/tests/windows_process.rs b/crates/basal-testkit/tests/windows_process.rs index 66601910..87107658 100644 --- a/crates/basal-testkit/tests/windows_process.rs +++ b/crates/basal-testkit/tests/windows_process.rs @@ -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 @@ -138,6 +145,9 @@ fn subprocess_exit_reaps_descendants_holding_output_pipes() { Duration::from_secs(120), || { descendant_ready = descendant_is_live(&dir)?; + if descendant_ready { + std::fs::write(dir.join("release-parent"), b"")?; + } Ok(descendant_ready) }, ) diff --git a/mutations.d/testkit.toml b/mutations.d/testkit.toml index a6e0e534..ce20cd37 100644 --- a/mutations.d/testkit.toml +++ b/mutations.d/testkit.toml @@ -180,9 +180,19 @@ 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/src/process/windows.rs" -old = "output_until_inner(command, timeout, Some(readiness_timeout), ready)" -new = "output_until_inner(command, timeout, None, ready) // NON-VACUITY BREAK" +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" edits = [] test_file = "crates/basal-testkit/tests/windows_process.rs" runner = "cargo" From 33478e88cb953983039598d6bf50ec2a16716ee0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:12:29 +0200 Subject: [PATCH 3/3] mason: remove empty edits from Windows witness --- mutations.d/testkit.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/mutations.d/testkit.toml b/mutations.d/testkit.toml index ce20cd37..caa05d5e 100644 --- a/mutations.d/testkit.toml +++ b/mutations.d/testkit.toml @@ -193,7 +193,6 @@ let error = output_until_ready( ) .unwrap_err();""" new = "let error = basal_testkit::process::output_until(&mut command, Duration::from_secs(10)).unwrap_err(); // NON-VACUITY BREAK" -edits = [] test_file = "crates/basal-testkit/tests/windows_process.rs" runner = "cargo" package = "basal-testkit"