1use std::process::Output;
2use std::time::{Duration, Instant};
3use tokio::process::Command;
4
5pub const TIMEOUT_QUICK: Duration = Duration::from_secs(15);
6pub const TIMEOUT_MEDIUM: Duration = Duration::from_secs(30);
7pub const TIMEOUT_SLOW: Duration = Duration::from_secs(60);
8
9pub async fn run_cmd(mut cmd: Command, timeout: Duration) -> Result<Output, String> {
10 let label = format!("{:?}", cmd.as_std().get_program());
11 match tokio::time::timeout(timeout, cmd.output()).await {
12 Ok(Ok(output)) => Ok(output),
13 Ok(Err(e)) => Err(format!("{} failed: {}", label, e)),
14 Err(_) => Err(format!("{} timed out after {}s", label, timeout.as_secs())),
15 }
16}
17
18pub(crate) async fn run_owned_mutation(
32 mut cmd: Command,
33 timeout: Duration,
34) -> Result<Output, String> {
35 use tokio::sync::oneshot;
36
37 static MUTATION_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
38
39 let label = format!("{:?}", cmd.as_std().get_program());
40 cmd.kill_on_drop(true);
41
42 let mutation_guard = MUTATION_LOCK.lock().await;
46 let (cancel_tx, cancel_rx) = oneshot::channel();
47 let supervisor = tokio::spawn(async move {
48 let _mutation_guard = mutation_guard;
49 run_owned_mutation_child(cmd, timeout, cancel_rx).await
50 });
51 let mut cancel_guard = MutationCancelGuard {
52 sender: Some(cancel_tx),
53 };
54
55 let result = supervisor
56 .await
57 .map_err(|error| format!("{} mutation supervisor failed: {}", label, error))?;
58 cancel_guard.sender.take();
59 result.map_err(|error| format!("{} {}", label, error))
60}
61
62#[cfg(any(windows, target_os = "linux", test))]
66pub(crate) struct MutationPairOutput {
67 pub first: Result<Output, String>,
68 pub inverse: Result<Output, String>,
69}
70
71#[cfg(any(windows, target_os = "linux", test))]
80pub(crate) async fn run_owned_mutation_pair(
81 first: Command,
82 first_timeout: Duration,
83 dwell: Duration,
84 inverse: Command,
85 inverse_timeout: Duration,
86) -> Result<MutationPairOutput, String> {
87 use tokio::sync::oneshot;
88
89 static PAIR_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
90
91 let (cancel_tx, cancel_rx) = oneshot::channel();
92 let supervisor = tokio::spawn(async move {
93 let _pair_guard = PAIR_LOCK.lock().await;
94 run_owned_mutation_pair_child(
95 first,
96 first_timeout,
97 dwell,
98 inverse,
99 inverse_timeout,
100 cancel_rx,
101 )
102 .await
103 });
104 let mut cancel_guard = PairCancelGuard {
105 sender: Some(cancel_tx),
106 };
107
108 let result = supervisor
109 .await
110 .map_err(|error| format!("mutation-pair supervisor failed: {error}"))?;
111 cancel_guard.sender.take();
112 Ok(result)
113}
114
115#[cfg(any(windows, target_os = "linux", test))]
116struct PairCancelGuard {
117 sender: Option<tokio::sync::oneshot::Sender<()>>,
118}
119
120#[cfg(any(windows, target_os = "linux", test))]
121impl Drop for PairCancelGuard {
122 fn drop(&mut self) {
123 if let Some(sender) = self.sender.take() {
124 let _ = sender.send(());
125 }
126 }
127}
128
129#[cfg(any(windows, target_os = "linux", test))]
130async fn run_owned_mutation_pair_child(
131 first: Command,
132 first_timeout: Duration,
133 dwell: Duration,
134 inverse: Command,
135 inverse_timeout: Duration,
136 mut cancelled: tokio::sync::oneshot::Receiver<()>,
137) -> MutationPairOutput {
138 let first_result = {
139 let first_run = run_owned_mutation(first, first_timeout);
140 tokio::pin!(first_run);
141 tokio::select! {
142 biased;
143 _ = &mut cancelled => None,
144 result = &mut first_run => Some(result),
145 }
146 };
147
148 let first_succeeded = first_result
149 .as_ref()
150 .is_some_and(|result| result.as_ref().is_ok_and(|output| output.status.success()));
151 if first_succeeded && !dwell.is_zero() {
152 tokio::select! {
153 biased;
154 _ = &mut cancelled => {}
155 _ = tokio::time::sleep(dwell) => {}
156 }
157 }
158
159 let inverse = run_owned_mutation(inverse, inverse_timeout).await;
162 MutationPairOutput {
163 first: first_result.unwrap_or_else(|| Err("was cancelled before completing".to_string())),
164 inverse,
165 }
166}
167
168#[cfg(target_os = "macos")]
173pub(crate) async fn run_macos_mutation(cmd: Command, timeout: Duration) -> Result<Output, String> {
174 run_owned_mutation(cmd, timeout).await
175}
176
177struct MutationCancelGuard {
178 sender: Option<tokio::sync::oneshot::Sender<()>>,
179}
180
181impl Drop for MutationCancelGuard {
182 fn drop(&mut self) {
183 if let Some(sender) = self.sender.take() {
184 let _ = sender.send(());
187 }
188 }
189}
190
191async fn run_owned_mutation_child(
192 mut cmd: Command,
193 timeout: Duration,
194 mut cancelled: tokio::sync::oneshot::Receiver<()>,
195) -> Result<Output, String> {
196 use std::process::Stdio;
197 use tokio::io::AsyncReadExt;
198
199 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
200 let mut child = cmd
201 .spawn()
202 .map_err(|error| format!("spawn failed: {error}"))?;
203 let mut stdout = child
204 .stdout
205 .take()
206 .ok_or_else(|| "could not capture stdout".to_string())?;
207 let mut stderr = child
208 .stderr
209 .take()
210 .ok_or_else(|| "could not capture stderr".to_string())?;
211
212 enum Completion {
213 Exited(Result<(std::process::ExitStatus, Vec<u8>, Vec<u8>), String>),
214 TimedOut,
215 Cancelled,
216 }
217
218 let completion = {
219 let completed = async {
220 let mut stdout_bytes = Vec::new();
221 let mut stderr_bytes = Vec::new();
222 let (status, stdout_result, stderr_result) = tokio::join!(
223 child.wait(),
224 stdout.read_to_end(&mut stdout_bytes),
225 stderr.read_to_end(&mut stderr_bytes),
226 );
227 let status = status.map_err(|error| format!("wait failed: {error}"))?;
228 stdout_result.map_err(|error| format!("stdout read failed: {error}"))?;
229 stderr_result.map_err(|error| format!("stderr read failed: {error}"))?;
230 Ok((status, stdout_bytes, stderr_bytes))
231 };
232 tokio::pin!(completed);
233 tokio::select! {
234 biased;
235 _ = &mut cancelled => Completion::Cancelled,
236 result = &mut completed => Completion::Exited(result),
237 _ = tokio::time::sleep(timeout) => Completion::TimedOut,
238 }
239 };
240
241 match completion {
242 Completion::Exited(Ok((status, stdout, stderr))) => Ok(Output {
243 status,
244 stdout,
245 stderr,
246 }),
247 Completion::Exited(Err(error)) => {
248 terminate_and_reap_mutation(&mut child).await;
249 Err(error)
250 }
251 Completion::TimedOut => {
252 terminate_and_reap_mutation(&mut child).await;
253 Err(format!("timed out after {}s", timeout.as_secs_f64()))
254 }
255 Completion::Cancelled => {
256 terminate_and_reap_mutation(&mut child).await;
257 Err("was cancelled and safely terminated".to_string())
258 }
259 }
260}
261
262async fn terminate_and_reap_mutation(child: &mut tokio::process::Child) {
263 let _ = child.start_kill();
264 let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
265}
266
267#[derive(Debug, Clone)]
271pub struct CmdOutcome {
272 pub command: String,
274 pub args: Vec<String>,
276 pub exit_code: Option<i32>,
279 pub stdout: String,
282 pub stderr: String,
284 pub duration: Duration,
287 pub ok: bool,
290 pub error: Option<String>,
293}
294
295impl CmdOutcome {
296 pub fn summary(&self) -> String {
299 if self.ok {
300 format!("ok ({:.1}s)", self.duration.as_secs_f64())
301 } else if let Some(err) = &self.error {
302 format!("failed: {}", err)
303 } else if let Some(code) = self.exit_code {
304 format!("exit {} ({:.1}s)", code, self.duration.as_secs_f64())
305 } else {
306 "failed".to_string()
307 }
308 }
309
310 pub fn cmdline(&self) -> String {
312 let mut s = self.command.clone();
313 for a in &self.args {
314 s.push(' ');
315 if a.contains(' ') {
316 s.push('"');
317 s.push_str(a);
318 s.push('"');
319 } else {
320 s.push_str(a);
321 }
322 }
323 s
324 }
325}
326
327pub async fn run_cmd_capture(mut cmd: Command, timeout: Duration) -> CmdOutcome {
336 let std_cmd = cmd.as_std();
337 let command = std_cmd.get_program().to_string_lossy().into_owned();
338 let args: Vec<String> = std_cmd
339 .get_args()
340 .map(|s| s.to_string_lossy().into_owned())
341 .collect();
342
343 let started = Instant::now();
344 let raced = tokio::time::timeout(timeout, cmd.output()).await;
345 let duration = started.elapsed();
346
347 match raced {
348 Ok(Ok(output)) => {
349 let exit_code = output.status.code();
350 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
351 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
352 let ok = output.status.success();
353 CmdOutcome {
354 command,
355 args,
356 exit_code,
357 stdout,
358 stderr,
359 duration,
360 ok,
361 error: None,
362 }
363 }
364 Ok(Err(e)) => CmdOutcome {
365 command,
366 args,
367 exit_code: None,
368 stdout: String::new(),
369 stderr: String::new(),
370 duration,
371 ok: false,
372 error: Some(format!("spawn failed: {}", e)),
373 },
374 Err(_) => CmdOutcome {
375 command,
376 args,
377 exit_code: None,
378 stdout: String::new(),
379 stderr: String::new(),
380 duration,
381 ok: false,
382 error: Some(format!("timed out after {}s", timeout.as_secs())),
383 },
384 }
385}
386
387#[cfg(test)]
388mod portable_owned_mutation_tests {
389 use super::*;
390 use std::path::{Path, PathBuf};
391
392 const HELPER_TEST: &str =
393 "actions::fix::cmd::portable_owned_mutation_tests::mutation_child_helper";
394
395 fn test_path(label: &str, suffix: &str) -> PathBuf {
396 std::env::temp_dir().join(format!(
397 "nd300-portable-mutation-{label}-{}-{suffix}",
398 std::process::id()
399 ))
400 }
401
402 fn helper_command(
403 state_file: &Path,
404 started_file: &Path,
405 delay_ms: u64,
406 value: &str,
407 ) -> Command {
408 let mut command = Command::new(std::env::current_exe().expect("current test executable"));
409 command.args(["--exact", HELPER_TEST, "--nocapture"]);
410 command.env("ND300_MUTATION_HELPER", "1");
411 command.env("ND300_MUTATION_HELPER_STATE", state_file);
412 command.env("ND300_MUTATION_HELPER_STARTED", started_file);
413 command.env("ND300_MUTATION_HELPER_DELAY_MS", delay_ms.to_string());
414 command.env("ND300_MUTATION_HELPER_VALUE", value);
415 command
416 }
417
418 async fn wait_for_file(path: &Path) {
419 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
420 while !path.exists() && tokio::time::Instant::now() < deadline {
421 tokio::time::sleep(Duration::from_millis(10)).await;
422 }
423 assert!(path.exists(), "helper did not create {}", path.display());
424 }
425
426 async fn wait_for_contents(path: &Path, expected: &[u8]) {
427 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
428 loop {
429 if std::fs::read(path).is_ok_and(|contents| contents == expected) {
430 return;
431 }
432 assert!(
433 tokio::time::Instant::now() < deadline,
434 "{} never reached expected state {:?}",
435 path.display(),
436 String::from_utf8_lossy(expected)
437 );
438 tokio::time::sleep(Duration::from_millis(10)).await;
439 }
440 }
441
442 fn cleanup(paths: &[&Path]) {
443 for path in paths {
444 let _ = std::fs::remove_file(path);
445 }
446 }
447
448 #[test]
449 fn mutation_child_helper() {
450 if std::env::var_os("ND300_MUTATION_HELPER").is_none() {
451 return;
452 }
453 let state = PathBuf::from(
454 std::env::var_os("ND300_MUTATION_HELPER_STATE").expect("helper state path"),
455 );
456 let started = PathBuf::from(
457 std::env::var_os("ND300_MUTATION_HELPER_STARTED").expect("helper started path"),
458 );
459 let delay_ms = std::env::var("ND300_MUTATION_HELPER_DELAY_MS")
460 .expect("helper delay")
461 .parse::<u64>()
462 .expect("numeric helper delay");
463 let value = std::env::var("ND300_MUTATION_HELPER_VALUE").expect("helper value");
464 std::fs::write(started, b"started").expect("write helper started marker");
465 std::thread::sleep(Duration::from_millis(delay_ms));
466 std::fs::write(state, value).expect("write helper state");
467 }
468
469 #[tokio::test]
470 async fn owned_timeout_prevents_late_mutation_on_every_platform() {
471 let state = test_path("timeout", "state");
472 let started = test_path("timeout", "started");
473 cleanup(&[&state, &started]);
474 std::fs::write(&state, b"up").unwrap();
475
476 let error = run_owned_mutation(
477 helper_command(&state, &started, 600, "down"),
478 Duration::from_millis(50),
479 )
480 .await
481 .unwrap_err();
482 assert!(error.contains("timed out"), "unexpected error: {error}");
483 tokio::time::sleep(Duration::from_millis(800)).await;
489 assert_eq!(std::fs::read(&state).unwrap(), b"up");
490 cleanup(&[&state, &started]);
491 }
492
493 #[tokio::test]
494 async fn completed_pair_reports_both_transitions() {
495 let state = test_path("pair-complete", "state");
496 let down_started = test_path("pair-complete", "down-started");
497 let up_started = test_path("pair-complete", "up-started");
498 cleanup(&[&state, &down_started, &up_started]);
499
500 let pair = run_owned_mutation_pair(
501 helper_command(&state, &down_started, 0, "down"),
502 Duration::from_secs(5),
503 Duration::ZERO,
504 helper_command(&state, &up_started, 0, "up"),
505 Duration::from_secs(5),
506 )
507 .await
508 .unwrap();
509 assert!(pair.first.unwrap().status.success());
510 assert!(pair.inverse.unwrap().status.success());
511 assert_eq!(std::fs::read(&state).unwrap(), b"up");
512 cleanup(&[&state, &down_started, &up_started]);
513 }
514
515 #[tokio::test]
516 async fn timed_out_pair_reaps_first_before_running_inverse() {
517 let state = test_path("pair-timeout", "state");
518 let down_started = test_path("pair-timeout", "down-started");
519 let up_started = test_path("pair-timeout", "up-started");
520 cleanup(&[&state, &down_started, &up_started]);
521 std::fs::write(&state, b"initial").unwrap();
522
523 let pair = run_owned_mutation_pair(
524 helper_command(&state, &down_started, 600, "down"),
525 Duration::from_millis(50),
526 Duration::ZERO,
527 helper_command(&state, &up_started, 0, "up"),
528 Duration::from_secs(5),
529 )
530 .await
531 .unwrap();
532
533 assert!(
534 pair.first.unwrap_err().contains("timed out"),
535 "the first mutation should have timed out"
536 );
537 assert!(pair.inverse.unwrap().status.success());
538 wait_for_contents(&state, b"up").await;
539 tokio::time::sleep(Duration::from_millis(800)).await;
540 assert_eq!(
541 std::fs::read(&state).unwrap(),
542 b"up",
543 "timed-out first command mutated state after its inverse"
544 );
545 cleanup(&[&state, &down_started, &up_started]);
546 }
547
548 #[tokio::test]
549 async fn cancelled_pair_runs_inverse_and_cannot_mutate_late() {
550 let state = test_path("pair-cancel", "state");
551 let down_started = test_path("pair-cancel", "down-started");
552 let up_started = test_path("pair-cancel", "up-started");
553 cleanup(&[&state, &down_started, &up_started]);
554 std::fs::write(&state, b"initial").unwrap();
555
556 let task = tokio::spawn(run_owned_mutation_pair(
557 helper_command(&state, &down_started, 600, "down"),
558 Duration::from_secs(5),
559 Duration::from_secs(2),
560 helper_command(&state, &up_started, 0, "up"),
561 Duration::from_secs(5),
562 ));
563 wait_for_file(&down_started).await;
564 task.abort();
565 let _ = task.await;
566
567 wait_for_file(&up_started).await;
568 wait_for_contents(&state, b"up").await;
569 tokio::time::sleep(Duration::from_millis(800)).await;
570 assert_eq!(
571 std::fs::read(&state).unwrap(),
572 b"up",
573 "cancelled first command mutated state after its inverse"
574 );
575 cleanup(&[&state, &down_started, &up_started]);
576 }
577}
578
579#[cfg(all(test, unix))]
580mod owned_mutation_tests {
581 use super::*;
582 use std::path::{Path, PathBuf};
583
584 fn test_path(label: &str, suffix: &str) -> PathBuf {
585 std::env::temp_dir().join(format!(
586 "nd300-owned-mutation-{label}-{}-{suffix}",
587 std::process::id()
588 ))
589 }
590
591 fn late_down_command(pid_file: &Path, started_file: &Path, state_file: &Path) -> Command {
592 let mut command = Command::new("sh");
593 command.args([
594 "-c",
595 "printf '%s' \"$$\" > \"$1\"; printf started > \"$2\"; sleep 1; printf down > \"$3\"",
596 "nd300-owned-mutation-test",
597 &pid_file.to_string_lossy(),
598 &started_file.to_string_lossy(),
599 &state_file.to_string_lossy(),
600 ]);
601 command
602 }
603
604 fn restore_up_command(state_file: &Path) -> Command {
605 let mut command = Command::new("sh");
606 command.args([
607 "-c",
608 "printf up > \"$1\"",
609 "nd300-owned-mutation-restore",
610 &state_file.to_string_lossy(),
611 ]);
612 command
613 }
614
615 async fn wait_for_file(path: &Path) {
616 let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
617 while !path.exists() && tokio::time::Instant::now() < deadline {
618 tokio::time::sleep(Duration::from_millis(10)).await;
619 }
620 assert!(
621 path.exists(),
622 "test child did not create {}",
623 path.display()
624 );
625 }
626
627 fn child_is_gone(pid_file: &Path) -> bool {
628 let pid = std::fs::read_to_string(pid_file)
629 .ok()
630 .and_then(|value| value.parse::<libc::pid_t>().ok())
631 .expect("test child PID");
632 (unsafe { libc::kill(pid, 0) }) != 0
635 }
636
637 fn cleanup(paths: &[&Path]) {
638 for path in paths {
639 let _ = std::fs::remove_file(path);
640 }
641 }
642
643 #[tokio::test]
644 async fn timed_out_down_is_killed_reaped_before_restore_returns() {
645 let pid_file = test_path("timeout", "pid");
646 let started_file = test_path("timeout", "started");
647 let state_file = test_path("timeout", "state");
648 cleanup(&[&pid_file, &started_file, &state_file]);
649 std::fs::write(&state_file, b"up").unwrap();
650
651 let error = run_owned_mutation(
652 late_down_command(&pid_file, &started_file, &state_file),
653 Duration::from_millis(50),
654 )
655 .await
656 .unwrap_err();
657 assert!(error.contains("timed out"), "unexpected error: {error}");
658 assert!(started_file.exists());
659 assert!(
660 child_is_gone(&pid_file),
661 "timed-out mutation child was not reaped before return"
662 );
663
664 run_owned_mutation(restore_up_command(&state_file), Duration::from_secs(2))
665 .await
666 .unwrap();
667 tokio::time::sleep(Duration::from_millis(1200)).await;
668 assert_eq!(std::fs::read(&state_file).unwrap(), b"up");
669 cleanup(&[&pid_file, &started_file, &state_file]);
670 }
671
672 #[tokio::test]
673 async fn cancelled_down_supervisor_finishes_before_serialized_restore() {
674 let pid_file = test_path("cancel", "pid");
675 let started_file = test_path("cancel", "started");
676 let state_file = test_path("cancel", "state");
677 cleanup(&[&pid_file, &started_file, &state_file]);
678 std::fs::write(&state_file, b"up").unwrap();
679
680 let task = tokio::spawn(run_owned_mutation(
681 late_down_command(&pid_file, &started_file, &state_file),
682 Duration::from_secs(10),
683 ));
684 wait_for_file(&started_file).await;
685 task.abort();
686 let _ = task.await;
687
688 run_owned_mutation(restore_up_command(&state_file), Duration::from_secs(2))
691 .await
692 .unwrap();
693 assert!(
694 child_is_gone(&pid_file),
695 "cancelled mutation child was not reaped before restore"
696 );
697 tokio::time::sleep(Duration::from_millis(1200)).await;
698 assert_eq!(
699 std::fs::read(&state_file).unwrap(),
700 b"up",
701 "late cancelled mutation overwrote restored state"
702 );
703 cleanup(&[&pid_file, &started_file, &state_file]);
704 }
705}