Skip to main content

nd_300/actions/fix/
cmd.rs

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
18/// Serialize explicitly state-changing network commands and retain ownership of
19/// their child process until it has exited or has been explicitly killed and
20/// reaped.
21///
22/// A plain `timeout(command.output())` drops the future but not necessarily the
23/// spawned process. For commands such as `networksetup ... off`, `ifconfig
24/// ... down`, or DNS setters, that lets an expired child apply its mutation
25/// *after* the restore path has run. This helper closes that race:
26///
27/// - one supervisor owns the child with `kill_on_drop(true)` as a final guard;
28/// - timeout and caller cancellation explicitly kill and reap it;
29/// - all callers serialize on one mutex, so a restore command cannot start
30///   until an interrupted mutation's supervisor has finished reaping.
31pub(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    // The guard is borrowed from a static mutex and can therefore move into
43    // the supervisor task. If this caller is cancelled, restoration blocks on
44    // the same mutex until the supervisor has killed and reaped the old child.
45    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/// Results from a paired state transition such as down/up, off/on, or
63/// release/renew. The inverse is attempted even when the first command fails,
64/// times out, or the caller is cancelled.
65#[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/// Run a destructive command and its inverse under a detached, cancellation-
72/// aware supervisor.
73///
74/// Individual commands use [`run_owned_mutation`], so a timed-out child is
75/// killed and reaped before its inverse can acquire the mutation lock. The
76/// sequence supervisor closes the second failure window: if the caller is
77/// dropped after the first command succeeds (including during `dwell`), the
78/// supervisor remains alive and runs the inverse before exiting.
79#[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    // Once cleanup begins it deliberately ignores further cancellation. Its
160    // owned runner still bounds, kills, and reaps the inverse child.
161    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/// macOS-named entrypoint used at every service/interface/DNS mutation site.
169/// Keeping this wrapper explicit makes accidental fallback to `run_cmd` easy to
170/// catch in source review while sharing the same owned runner with Windows and
171/// Linux adapter bounces.
172#[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            // A dropped receiver means the supervisor already completed; that
185            // is harmless. Otherwise this wakes its kill-and-reap branch.
186            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/// Structured record of a subprocess invocation. Carries enough information for
268/// the fix-flow report to render full forensic detail (command, args, exit
269/// code, captured streams, wall-time, and the failure mode if any).
270#[derive(Debug, Clone)]
271pub struct CmdOutcome {
272    /// Program name (e.g. `"netsh"`, `"ipconfig"`).
273    pub command: String,
274    /// Arguments passed to the program, in order.
275    pub args: Vec<String>,
276    /// Process exit code if the program ran to completion. `None` indicates a
277    /// spawn error or a timeout — distinguished by `error`.
278    pub exit_code: Option<i32>,
279    /// Captured stdout (lossy UTF-8 — best-effort, suitable for human display
280    /// and Markdown reports). Trailing whitespace is preserved as-is.
281    pub stdout: String,
282    /// Captured stderr (same encoding caveat as `stdout`).
283    pub stderr: String,
284    /// Wall-clock duration from spawn through completion / timeout / spawn
285    /// failure.
286    pub duration: Duration,
287    /// `true` when the program ran to completion AND returned exit code 0.
288    /// `false` for any non-zero exit, spawn failure, or timeout.
289    pub ok: bool,
290    /// Populated when the spawn or run failed for a reason other than a
291    /// non-zero exit code (e.g. binary not found, OS-level failure, timeout).
292    pub error: Option<String>,
293}
294
295impl CmdOutcome {
296    /// Returns a single human-readable line summarizing the outcome.
297    /// Suitable for terminal status lines.
298    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    /// Reconstruct an approximate command line for display in reports.
311    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
327/// Capturing variant of [`run_cmd`]. Consumes the supplied [`Command`],
328/// recording the program path and argv first so the resulting [`CmdOutcome`]
329/// can be rendered in fix-flow reports without losing the invocation context.
330///
331/// Returns a [`CmdOutcome`] for every code path (success, non-zero exit, spawn
332/// error, timeout) — callers don't need to handle a `Result`. Inspect
333/// [`CmdOutcome::ok`] / [`CmdOutcome::error`] / [`CmdOutcome::exit_code`] to
334/// branch on what happened.
335pub 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        // Do not require the nested test harness to reach its marker before
484        // the deliberately tiny timeout. On a saturated CI host the owned
485        // child can be spawned and safely killed before its test body is
486        // scheduled; that is a valid timeout, not a failed precondition. The
487        // handshaked cancellation test below covers a child known to be live.
488        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        // SAFETY: signal zero performs existence/permission checking only and
633        // has no pointer arguments.
634        (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        // This uses the same mutation lock. It cannot run until cancellation
689        // has killed and reaped the old `down` child.
690        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}