Skip to main content

holodeck_simctl_core/
recorder.rs

1use tokio::process::Child;
2use tokio::sync::Mutex;
3
4/// Owns the long-running child process. Sending SIGINT — not SIGKILL — is
5/// required for `simctl io recordVideo` to finalize a valid MP4; Rust's
6/// `Child::kill()` only sends SIGKILL, so this shells out to `libc::kill`
7/// directly (see plan §6.2 / §3a).
8#[derive(Default)]
9pub struct Recorder {
10    child: Mutex<Option<Child>>,
11}
12
13impl Recorder {
14    pub fn new() -> Self {
15        Self { child: Mutex::new(None) }
16    }
17
18    pub async fn is_running(&self) -> bool {
19        let mut guard = self.child.lock().await;
20        match guard.as_mut() {
21            Some(child) => matches!(child.try_wait(), Ok(None)),
22            None => false,
23        }
24    }
25
26    /// Idempotent: a second `start()` while already recording is a no-op,
27    /// matching the Swift `RecorderActor.start`.
28    pub async fn start(&self, launch_path: &str, arguments: &[String]) -> std::io::Result<()> {
29        let mut guard = self.child.lock().await;
30        if let Some(child) = guard.as_mut()
31            && matches!(child.try_wait(), Ok(None))
32        {
33            return Ok(());
34        }
35        let child = tokio::process::Command::new(launch_path)
36            .args(arguments)
37            .stdout(std::process::Stdio::null())
38            .stderr(std::process::Stdio::null())
39            .spawn()?;
40        *guard = Some(child);
41        Ok(())
42    }
43
44    pub async fn stop(&self) {
45        let mut child = {
46            let mut guard = self.child.lock().await;
47            let Some(child) = guard.take() else {
48                return;
49            };
50            child
51        };
52        if let Some(pid) = child.id() {
53            // SAFETY: `pid` is the child we just spawned and still hold; SIGINT
54            // is what lets `simctl io recordVideo` finalize a valid MP4.
55            unsafe {
56                libc::kill(pid as libc::pid_t, libc::SIGINT);
57            }
58        }
59        // The mutex is released before this potentially-slow wait so
60        // `is_running()`/`start()` don't block on it for the whole shutdown.
61        let _ = child.wait().await;
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[tokio::test]
70    async fn not_running_before_start() {
71        let recorder = Recorder::new();
72        assert!(!recorder.is_running().await);
73    }
74
75    #[tokio::test]
76    async fn tracks_running_state_across_start_and_stop() {
77        let recorder = Recorder::new();
78        recorder.start("/bin/sleep", &["5".to_string()]).await.unwrap();
79        assert!(recorder.is_running().await);
80        recorder.stop().await;
81        assert!(!recorder.is_running().await);
82    }
83
84    #[tokio::test]
85    async fn start_is_idempotent_while_already_running() {
86        let recorder = Recorder::new();
87        recorder.start("/bin/sleep", &["5".to_string()]).await.unwrap();
88        recorder.start("/bin/sleep", &["5".to_string()]).await.unwrap();
89        recorder.stop().await;
90    }
91}