Skip to main content

rbnx/cmd/
boot_watchdog.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Detached `rbnx boot` watchdog.
3//
4// The foreground boot process owns the terminal and can disappear without a
5// catchable signal (terminal close, SIGKILL, IDE cancellation). This helper is
6// re-execed in a new session, watches the exact parent PID identity, and runs
7// the same persisted teardown path if that parent disappears unexpectedly.
8
9use anyhow::{Context, Result};
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::time::Duration;
13
14use super::teardown;
15
16pub fn spawn(
17    state_path: &Path,
18    boot_pid: u32,
19    boot_start_time_ticks: Option<u64>,
20    boot_id: &str,
21) -> Result<()> {
22    use std::os::unix::process::CommandExt;
23
24    let exe = std::env::current_exe().context("resolve rbnx binary for boot watchdog")?;
25    let mut command = std::process::Command::new(exe);
26    command
27        .arg("__watch-boot")
28        .arg("--state")
29        .arg(state_path)
30        .arg("--boot-pid")
31        .arg(boot_pid.to_string())
32        .arg("--boot-id")
33        .arg(boot_id)
34        .stdin(Stdio::null())
35        .stdout(Stdio::null())
36        .stderr(Stdio::null());
37    if let Some(ticks) = boot_start_time_ticks {
38        command
39            .arg("--boot-start-time-ticks")
40            .arg(ticks.to_string());
41    }
42    // Safety: pre_exec runs after fork and before exec. setsid(2) is
43    // async-signal-safe and detaches the watcher from the boot PTY/session.
44    unsafe {
45        command.pre_exec(|| {
46            nix::unistd::setsid()
47                .map(|_| ())
48                .map_err(std::io::Error::other)
49        });
50    }
51    command.spawn().context("spawn detached boot watchdog")?;
52    Ok(())
53}
54
55pub async fn execute(
56    state_path: PathBuf,
57    boot_pid: u32,
58    boot_start_time_ticks: Option<u64>,
59    boot_id: String,
60) -> Result<()> {
61    loop {
62        if !state_path.exists() {
63            return Ok(());
64        }
65        let alive = watched_boot_is_alive(boot_pid, boot_start_time_ticks, &boot_id);
66        if !alive {
67            break;
68        }
69        tokio::time::sleep(Duration::from_millis(250)).await;
70    }
71
72    let state = teardown::read_state(&state_path)?;
73    if !matches_watched_boot(&state, boot_pid, boot_start_time_ticks, &boot_id) {
74        return Ok(());
75    }
76    let state_boot_id = (!state.boot_id.is_empty()).then_some(state.boot_id.as_str());
77    let complete = teardown::teardown(
78        Some(&state.atlas_endpoint),
79        &state.components,
80        state_boot_id,
81    )
82    .await;
83    if !complete {
84        return Ok(());
85    }
86
87    // A new boot may have replaced state.json while cleanup was running. Only
88    // remove the file if it still belongs to the parent we watched.
89    if teardown::read_state(&state_path)
90        .ok()
91        .is_some_and(|current| {
92            matches_watched_boot(&current, boot_pid, boot_start_time_ticks, &boot_id)
93        })
94    {
95        let _ = std::fs::remove_file(&state_path);
96    }
97    Ok(())
98}
99
100fn matches_watched_boot(
101    state: &teardown::BootState,
102    boot_pid: u32,
103    boot_start_time_ticks: Option<u64>,
104    boot_id: &str,
105) -> bool {
106    state.boot_pid == boot_pid
107        && state.boot_id == boot_id
108        && match (boot_start_time_ticks, state.boot_start_time_ticks) {
109            (Some(expected), Some(actual)) => expected == actual,
110            (Some(_), None) => false,
111            (None, _) => true,
112        }
113}
114
115fn watched_boot_is_alive(boot_pid: u32, boot_start_time_ticks: Option<u64>, boot_id: &str) -> bool {
116    match boot_start_time_ticks {
117        Some(expected) => robonix_cli::launch::proc_start_time_ticks(boot_pid) == Some(expected),
118        None => robonix_cli::launch::process_has_boot_id(boot_pid, boot_id),
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    #[ignore]
128    fn boot_identity_fixture_process() {
129        std::thread::sleep(Duration::from_secs(30));
130    }
131
132    #[test]
133    fn missing_start_ticks_fall_back_to_exact_live_boot_id() {
134        let boot_id = format!("watchdog-live-parent-{}", std::process::id());
135        let mut child = std::process::Command::new(std::env::current_exe().unwrap())
136            .args([
137                "--exact",
138                "cmd::boot_watchdog::tests::boot_identity_fixture_process",
139                "--ignored",
140                "--test-threads=1",
141            ])
142            .env("RBNX_BOOT_ID", &boot_id)
143            .spawn()
144            .unwrap();
145
146        let deadline = std::time::Instant::now() + Duration::from_secs(2);
147        let mut matched = false;
148        while std::time::Instant::now() < deadline {
149            if watched_boot_is_alive(child.id(), None, &boot_id) {
150                matched = true;
151                break;
152            }
153            std::thread::sleep(Duration::from_millis(10));
154        }
155        assert!(
156            matched,
157            "live boot parent must survive the no-procfs fallback"
158        );
159        assert!(!watched_boot_is_alive(
160            child.id(),
161            None,
162            "different-boot-id"
163        ));
164
165        let _ = child.kill();
166        let _ = child.wait();
167    }
168}