Skip to main content

a3s_box_runtime/sandbox/
handler.rs

1//! Runtime handler for a live `crun` Sandbox container.
2
3use std::path::{Path, PathBuf};
4use std::process::{Child, Command, Output};
5use std::sync::Mutex;
6use std::time::{Duration, Instant};
7
8use a3s_box_core::error::{BoxError, Result};
9use a3s_box_core::vmm::{VmHandler, VmMetrics};
10use serde::Deserialize;
11use sysinfo::{Pid, System};
12
13// `crun kill` accepts Linux signal numbers even though this module must also
14// type-check on hosts where libc does not expose POSIX signal constants.
15const SIGKILL_NUMBER: i32 = 9;
16const LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(5);
17const LIFECYCLE_POLL_INTERVAL: Duration = Duration::from_millis(25);
18
19#[derive(Debug, Deserialize)]
20pub(crate) struct CrunState {
21    pub status: String,
22    #[serde(default)]
23    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
24    pub pid: u32,
25}
26
27/// Owns both the foreground `crun run` process and the OCI runtime state.
28/// Lifecycle operations always target the container ID through `crun`; merely
29/// signalling the wrapper process is never treated as cleanup.
30/// Dropping the in-process handle deliberately detaches without destroying the
31/// workload so short-lived CLI commands can launch persistent boxes. Explicit
32/// lifecycle operations and crash reconciliation own runtime cleanup.
33pub struct CrunHandler {
34    runtime_path: PathBuf,
35    runtime_root: PathBuf,
36    container_id: String,
37    init_pid: u32,
38    process: Option<Child>,
39    log_worker: Option<Child>,
40    log_worker_pid: Option<u32>,
41    log_worker_pid_start_time: Option<u64>,
42    metrics_sys: Mutex<System>,
43    exit_code: Option<i32>,
44    bundle_dir: PathBuf,
45    runtime_record: PathBuf,
46    cleaned: bool,
47}
48
49#[cfg(target_os = "linux")]
50pub(crate) struct CrunHandlerSpec {
51    runtime_path: PathBuf,
52    runtime_root: PathBuf,
53    container_id: String,
54    init_pid: u32,
55    bundle_dir: PathBuf,
56    runtime_record: PathBuf,
57}
58
59#[cfg(target_os = "linux")]
60impl CrunHandlerSpec {
61    pub(crate) fn new(
62        runtime_path: PathBuf,
63        runtime_root: PathBuf,
64        container_id: String,
65        init_pid: u32,
66        bundle_dir: PathBuf,
67        runtime_record: PathBuf,
68    ) -> Self {
69        Self {
70            runtime_path,
71            runtime_root,
72            container_id,
73            init_pid,
74            bundle_dir,
75            runtime_record,
76        }
77    }
78}
79
80impl CrunHandler {
81    #[cfg(target_os = "linux")]
82    pub(crate) fn from_child(
83        spec: CrunHandlerSpec,
84        process: Child,
85        log_worker: Child,
86        log_worker_pid_start_time: u64,
87    ) -> Self {
88        let log_worker_pid = log_worker.id();
89        Self {
90            runtime_path: spec.runtime_path,
91            runtime_root: spec.runtime_root,
92            container_id: spec.container_id,
93            init_pid: spec.init_pid,
94            process: Some(process),
95            log_worker: Some(log_worker),
96            log_worker_pid: Some(log_worker_pid),
97            log_worker_pid_start_time: Some(log_worker_pid_start_time),
98            metrics_sys: Mutex::new(System::new()),
99            exit_code: None,
100            bundle_dir: spec.bundle_dir,
101            runtime_record: spec.runtime_record,
102            cleaned: false,
103        }
104    }
105
106    #[cfg(all(target_os = "linux", feature = "vm"))]
107    pub(crate) fn from_recorded_runtime(
108        spec: CrunHandlerSpec,
109        log_worker_pid: Option<u32>,
110        log_worker_pid_start_time: Option<u64>,
111    ) -> Self {
112        Self {
113            runtime_path: spec.runtime_path,
114            runtime_root: spec.runtime_root,
115            container_id: spec.container_id,
116            init_pid: spec.init_pid,
117            process: None,
118            log_worker: None,
119            log_worker_pid,
120            log_worker_pid_start_time,
121            metrics_sys: Mutex::new(System::new()),
122            exit_code: None,
123            bundle_dir: spec.bundle_dir,
124            runtime_record: spec.runtime_record,
125            cleaned: false,
126        }
127    }
128
129    pub(crate) fn query_state_at(
130        runtime_path: &Path,
131        runtime_root: &Path,
132        container_id: &str,
133    ) -> Result<Option<CrunState>> {
134        let output = Command::new(runtime_path)
135            .arg("--root")
136            .arg(runtime_root)
137            .arg("state")
138            .arg(container_id)
139            .env("LC_ALL", "C")
140            .output()
141            .map_err(|error| BoxError::BoxBootError {
142                message: format!("Failed to query Sandbox runtime state: {error}"),
143                hint: None,
144            })?;
145        if !output.status.success() {
146            let stderr = String::from_utf8_lossy(&output.stderr);
147            let normalized = stderr.to_ascii_lowercase();
148            if normalized.contains("does not exist")
149                || normalized.contains("not found")
150                || normalized.contains("no such file or directory")
151            {
152                return Ok(None);
153            }
154            return Err(BoxError::BoxBootError {
155                message: format!("crun state failed for {container_id}: {}", stderr.trim()),
156                hint: None,
157            });
158        }
159        let state =
160            serde_json::from_slice(&output.stdout).map_err(|error| BoxError::BoxBootError {
161                message: format!("Invalid crun state response: {error}"),
162                hint: None,
163            })?;
164        Ok(Some(state))
165    }
166
167    pub(crate) fn pause_at(
168        runtime_path: &Path,
169        runtime_root: &Path,
170        container_id: &str,
171    ) -> Result<()> {
172        Self::transition_state_at(
173            runtime_path,
174            runtime_root,
175            container_id,
176            "pause",
177            &["created", "running"],
178            "paused",
179        )
180    }
181
182    pub(crate) fn resume_at(
183        runtime_path: &Path,
184        runtime_root: &Path,
185        container_id: &str,
186    ) -> Result<()> {
187        Self::transition_state_at(
188            runtime_path,
189            runtime_root,
190            container_id,
191            "resume",
192            &["paused"],
193            "running",
194        )
195    }
196
197    fn transition_state_at(
198        runtime_path: &Path,
199        runtime_root: &Path,
200        container_id: &str,
201        operation: &str,
202        source_states: &[&str],
203        target_state: &str,
204    ) -> Result<()> {
205        let state =
206            Self::query_state_at(runtime_path, runtime_root, container_id)?.ok_or_else(|| {
207                BoxError::StateError(format!(
208                    "Sandbox runtime {container_id} does not exist for {operation}"
209                ))
210            })?;
211        if state.status == target_state {
212            return Ok(());
213        }
214        if !source_states.contains(&state.status.as_str()) {
215            return Err(BoxError::StateError(format!(
216                "Cannot {operation} Sandbox runtime {container_id} in state {}",
217                state.status
218            )));
219        }
220
221        let output = Command::new(runtime_path)
222            .arg("--root")
223            .arg(runtime_root)
224            .arg(operation)
225            .arg(container_id)
226            .env("LC_ALL", "C")
227            .output()
228            .map_err(|error| {
229                BoxError::ExecError(format!("Failed to run crun {operation}: {error}"))
230            })?;
231        if !output.status.success() {
232            if Self::query_state_at(runtime_path, runtime_root, container_id)?
233                .is_some_and(|state| state.status == target_state)
234            {
235                return Ok(());
236            }
237            return Err(runtime_failure(&format!("crun {operation}"), &output));
238        }
239
240        let deadline = Instant::now() + LIFECYCLE_TIMEOUT;
241        loop {
242            match Self::query_state_at(runtime_path, runtime_root, container_id)? {
243                Some(state) if state.status == target_state => return Ok(()),
244                Some(state) if state.status == "stopped" => {
245                    return Err(BoxError::StateError(format!(
246                        "Sandbox runtime {container_id} stopped while waiting for {operation}"
247                    )))
248                }
249                None => {
250                    return Err(BoxError::StateError(format!(
251                        "Sandbox runtime {container_id} disappeared while waiting for {operation}"
252                    )))
253                }
254                Some(_) if Instant::now() < deadline => {
255                    std::thread::sleep(LIFECYCLE_POLL_INTERVAL);
256                }
257                Some(state) => {
258                    return Err(BoxError::StateError(format!(
259                        "Timed out waiting for Sandbox runtime {container_id} to enter {target_state}; current state is {}",
260                        state.status
261                    )))
262                }
263            }
264        }
265    }
266
267    fn runtime_command(&self, operation: &str) -> Command {
268        let mut command = Command::new(&self.runtime_path);
269        command
270            .arg("--root")
271            .arg(&self.runtime_root)
272            .arg(operation)
273            .env("LC_ALL", "C");
274        command
275    }
276
277    fn signal_container(&self, signal: i32) -> Result<()> {
278        let output = self
279            .runtime_command("kill")
280            .arg(&self.container_id)
281            .arg(signal.to_string())
282            .output()
283            .map_err(|error| BoxError::ExecError(format!("Failed to run crun kill: {error}")))?;
284        if output.status.success() {
285            return Ok(());
286        }
287        match self.query_state()? {
288            None => return Ok(()),
289            Some(state) if state.status == "stopped" => return Ok(()),
290            Some(_) => {}
291        }
292        Err(runtime_failure("crun kill", &output))
293    }
294
295    fn query_state(&self) -> Result<Option<CrunState>> {
296        Self::query_state_at(&self.runtime_path, &self.runtime_root, &self.container_id)
297    }
298
299    fn wait_for_exit(&mut self, timeout_ms: u64) -> Result<bool> {
300        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
301        loop {
302            if self.poll_child()?.is_some() || self.query_state()?.is_none() {
303                return Ok(true);
304            }
305            if Instant::now() >= deadline {
306                return Ok(false);
307            }
308            std::thread::sleep(Duration::from_millis(25));
309        }
310    }
311
312    fn poll_child(&mut self) -> Result<Option<i32>> {
313        if self.exit_code.is_some() {
314            return Ok(self.exit_code);
315        }
316        let Some(process) = self.process.as_mut() else {
317            return Ok(None);
318        };
319        match process.try_wait() {
320            Ok(Some(status)) => {
321                self.exit_code = status.code().or(Some(128));
322                Ok(self.exit_code)
323            }
324            Ok(None) => Ok(None),
325            Err(error) => Err(BoxError::ExecError(format!(
326                "Failed to poll crun process for {}: {error}",
327                self.container_id
328            ))),
329        }
330    }
331
332    fn reap_child(&mut self) {
333        let Some(mut process) = self.process.take() else {
334            return;
335        };
336        match process.try_wait() {
337            Ok(Some(status)) => {
338                self.exit_code = status.code().or(self.exit_code).or(Some(128));
339                return;
340            }
341            Ok(None) => {
342                // OCI cleanup already ran before this helper. Killing a stuck
343                // wrapper here cannot replace container cleanup; it only
344                // guarantees that handler teardown never blocks indefinitely.
345                let _ = process.kill();
346            }
347            Err(error) => {
348                tracing::warn!(
349                    container_id = %self.container_id,
350                    %error,
351                    "Failed to poll crun run process before reaping"
352                );
353                let _ = process.kill();
354            }
355        }
356        match process.wait() {
357            Ok(status) => {
358                self.exit_code = status.code().or(self.exit_code).or(Some(128));
359            }
360            Err(error) => {
361                tracing::warn!(
362                    container_id = %self.container_id,
363                    %error,
364                    "Failed to reap crun run process"
365                );
366            }
367        }
368    }
369
370    fn reap_log_worker(&mut self) {
371        const LOG_WORKER_EXIT_TIMEOUT: Duration = Duration::from_secs(2);
372        const LOG_WORKER_EXIT_POLL: Duration = Duration::from_millis(10);
373
374        if let Some(mut worker) = self.log_worker.take() {
375            let deadline = Instant::now() + LOG_WORKER_EXIT_TIMEOUT;
376            loop {
377                match worker.try_wait() {
378                    Ok(Some(_)) => return,
379                    Ok(None) if Instant::now() < deadline => {
380                        std::thread::sleep(LOG_WORKER_EXIT_POLL);
381                    }
382                    Ok(None) => break,
383                    Err(error) => {
384                        tracing::warn!(
385                            container_id = %self.container_id,
386                            %error,
387                            "Failed to poll Sandbox log worker before reaping"
388                        );
389                        break;
390                    }
391                }
392            }
393            tracing::warn!(
394                container_id = %self.container_id,
395                "Sandbox log worker did not exit after crun; terminating it"
396            );
397            let _ = worker.kill();
398            let _ = worker.wait();
399            return;
400        }
401
402        let (Some(pid), Some(start_time)) = (self.log_worker_pid, self.log_worker_pid_start_time)
403        else {
404            return;
405        };
406        let deadline = Instant::now() + LOG_WORKER_EXIT_TIMEOUT;
407        while crate::process::is_process_running_with_identity(pid, Some(start_time))
408            && Instant::now() < deadline
409        {
410            std::thread::sleep(LOG_WORKER_EXIT_POLL);
411        }
412        if crate::process::is_process_running_with_identity(pid, Some(start_time)) {
413            tracing::warn!(
414                container_id = %self.container_id,
415                log_worker_pid = pid,
416                "Recovered Sandbox log worker did not exit after crun; terminating it"
417            );
418            // The start-time token was revalidated immediately before the
419            // signal, so a reused PID cannot be targeted.
420            #[cfg(target_os = "linux")]
421            if let Ok(pid) = i32::try_from(pid) {
422                unsafe {
423                    libc::kill(pid, libc::SIGKILL);
424                }
425            }
426        }
427        #[cfg(target_os = "linux")]
428        if !crate::process::wait_for_process_exit_with_identity(
429            pid,
430            start_time,
431            LOG_WORKER_EXIT_TIMEOUT,
432        ) {
433            tracing::warn!(
434                container_id = %self.container_id,
435                log_worker_pid = pid,
436                "Recovered Sandbox log worker remained present after cleanup"
437            );
438        }
439    }
440
441    fn delete_runtime_state(&mut self) -> Result<()> {
442        if self.cleaned {
443            return Ok(());
444        }
445        let output = self
446            .runtime_command("delete")
447            .arg("--force")
448            .arg(&self.container_id)
449            .output()
450            .map_err(|error| BoxError::ExecError(format!("Failed to run crun delete: {error}")))?;
451        if !output.status.success() && self.query_state()?.is_some() {
452            return Err(runtime_failure("crun delete --force", &output));
453        }
454        // Reap the wrapper first: its inherited stdout/stderr descriptors must
455        // close before the worker treats EOF as final. Then wait for the worker
456        // to drain both streams before removing durable generation artifacts.
457        self.reap_child();
458        self.reap_log_worker();
459        self.cleaned = true;
460        remove_file_if_exists(&self.runtime_record);
461        remove_dir_if_exists(&self.bundle_dir);
462        remove_dir_if_exists(&self.runtime_root);
463        Ok(())
464    }
465}
466
467impl VmHandler for CrunHandler {
468    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
469        let mut first_error = None;
470        if self.query_state()?.is_some() {
471            if let Err(error) = self.signal_container(signal) {
472                first_error = Some(error);
473            }
474            match self.wait_for_exit(timeout_ms) {
475                Ok(true) => {}
476                Ok(false) => {
477                    tracing::warn!(
478                        container_id = %self.container_id,
479                        timeout_ms,
480                        "Sandbox did not stop gracefully; sending SIGKILL"
481                    );
482                    if let Err(error) = self.signal_container(SIGKILL_NUMBER) {
483                        first_error.get_or_insert(error);
484                    }
485                    let _ = self.wait_for_exit(2_000);
486                }
487                Err(error) => {
488                    first_error.get_or_insert(error);
489                    let _ = self.signal_container(SIGKILL_NUMBER);
490                }
491            }
492        }
493
494        if let Err(error) = self.delete_runtime_state() {
495            first_error.get_or_insert(error);
496        }
497        match first_error {
498            Some(error) => Err(error),
499            None => Ok(()),
500        }
501    }
502
503    fn metrics(&self) -> VmMetrics {
504        let pid = Pid::from_u32(self.init_pid);
505        let mut system = match self.metrics_sys.lock() {
506            Ok(system) => system,
507            Err(error) => {
508                tracing::warn!(%error, "Sandbox metrics lock is poisoned");
509                return VmMetrics::default();
510            }
511        };
512        system.refresh_process(pid);
513        system
514            .process(pid)
515            .map(|process| VmMetrics {
516                cpu_percent: Some(process.cpu_usage()),
517                memory_bytes: Some(process.memory()),
518            })
519            .unwrap_or_default()
520    }
521
522    fn is_running(&self) -> bool {
523        self.query_state()
524            .ok()
525            .flatten()
526            .is_some_and(|state| matches!(state.status.as_str(), "created" | "running" | "paused"))
527    }
528
529    fn has_exited(&self) -> bool {
530        !self.is_running()
531    }
532
533    fn pid(&self) -> u32 {
534        self.init_pid
535    }
536
537    fn exit_code(&self) -> Option<i32> {
538        self.exit_code
539    }
540
541    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
542        let exit = self.poll_child()?;
543        if exit.is_some() {
544            self.delete_runtime_state()?;
545        }
546        Ok(exit)
547    }
548}
549
550fn runtime_failure(operation: &str, output: &Output) -> BoxError {
551    let stderr = String::from_utf8_lossy(&output.stderr);
552    BoxError::ExecError(format!(
553        "{operation} exited with {}: {}",
554        output.status,
555        stderr.trim()
556    ))
557}
558
559fn remove_file_if_exists(path: &Path) {
560    match std::fs::remove_file(path) {
561        Ok(()) => {}
562        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
563        Err(error) => {
564            tracing::warn!(path = %path.display(), %error, "Failed to remove Sandbox runtime record")
565        }
566    }
567}
568
569fn remove_dir_if_exists(path: &Path) {
570    match std::fs::remove_dir_all(path) {
571        Ok(()) => {}
572        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
573        Err(error) => {
574            tracing::warn!(path = %path.display(), %error, "Failed to remove Sandbox runtime directory")
575        }
576    }
577}
578
579#[cfg(all(test, target_os = "linux"))]
580mod tests {
581    use super::*;
582
583    fn lifecycle_runtime(temporary: &tempfile::TempDir) -> (PathBuf, PathBuf) {
584        use std::os::unix::fs::PermissionsExt;
585
586        let runtime_root = temporary.path().join("runtime");
587        std::fs::create_dir(&runtime_root).unwrap();
588        std::fs::write(runtime_root.join("state"), "running\n").unwrap();
589        let runtime = temporary.path().join("crun-fixture");
590        std::fs::write(
591            &runtime,
592            r#"#!/bin/sh
593root="$2"
594operation="$3"
595case "$operation" in
596  state)
597    status="$(cat "$root/state")"
598    printf '{"status":"%s","pid":42}\n' "$status"
599    ;;
600  pause)
601    printf 'paused\n' > "$root/state"
602    ;;
603  resume)
604    printf 'running\n' > "$root/state"
605    ;;
606  *)
607    exit 64
608    ;;
609esac
610"#,
611        )
612        .unwrap();
613        std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)).unwrap();
614        (runtime, runtime_root)
615    }
616
617    #[test]
618    fn crun_pause_and_resume_are_state_checked_and_idempotent() {
619        let temporary = tempfile::tempdir().unwrap();
620        let (runtime, runtime_root) = lifecycle_runtime(&temporary);
621
622        CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap();
623        CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap();
624        assert_eq!(
625            CrunHandler::query_state_at(&runtime, &runtime_root, "sandbox-1")
626                .unwrap()
627                .unwrap()
628                .status,
629            "paused"
630        );
631
632        CrunHandler::resume_at(&runtime, &runtime_root, "sandbox-1").unwrap();
633        CrunHandler::resume_at(&runtime, &runtime_root, "sandbox-1").unwrap();
634        assert_eq!(
635            CrunHandler::query_state_at(&runtime, &runtime_root, "sandbox-1")
636                .unwrap()
637                .unwrap()
638                .status,
639            "running"
640        );
641    }
642
643    #[test]
644    fn crun_pause_rejects_a_terminal_runtime() {
645        let temporary = tempfile::tempdir().unwrap();
646        let (runtime, runtime_root) = lifecycle_runtime(&temporary);
647        std::fs::write(runtime_root.join("state"), "stopped\n").unwrap();
648
649        let error = CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap_err();
650
651        assert!(error.to_string().contains("state stopped"));
652    }
653
654    #[cfg(feature = "vm")]
655    #[test]
656    fn recorded_runtime_handler_attaches_without_owning_a_wrapper_process() {
657        let temporary = tempfile::tempdir().unwrap();
658        let runtime_path = PathBuf::from("/bin/true");
659        let runtime_root = temporary.path().join("runtime");
660        let bundle_dir = temporary.path().join("bundle");
661        let runtime_record = temporary.path().join("runtime.json");
662
663        let handler = CrunHandler::from_recorded_runtime(
664            CrunHandlerSpec::new(
665                runtime_path.clone(),
666                runtime_root.clone(),
667                "recorded-test".to_string(),
668                42,
669                bundle_dir.clone(),
670                runtime_record.clone(),
671            ),
672            None,
673            None,
674        );
675
676        assert_eq!(handler.runtime_path, runtime_path);
677        assert_eq!(handler.runtime_root, runtime_root);
678        assert_eq!(handler.container_id, "recorded-test");
679        assert_eq!(handler.pid(), 42);
680        assert!(handler.process.is_none());
681        assert!(handler.log_worker.is_none());
682        assert!(handler.log_worker_pid.is_none());
683        assert_eq!(handler.bundle_dir, bundle_dir);
684        assert_eq!(handler.runtime_record, runtime_record);
685        assert!(!handler.cleaned);
686    }
687
688    #[test]
689    fn dropping_handler_detaches_from_live_runtime_process() {
690        let temporary = tempfile::tempdir().unwrap();
691        let child = Command::new("sleep").arg("30").spawn().unwrap();
692        let pid = child.id();
693        let log_worker = Command::new("sleep").arg("30").spawn().unwrap();
694        let log_worker_pid = log_worker.id();
695        let log_worker_pid_start_time = crate::process::pid_start_time(log_worker_pid).unwrap();
696        let handler = CrunHandler::from_child(
697            CrunHandlerSpec::new(
698                PathBuf::from("/bin/true"),
699                temporary.path().join("runtime"),
700                "detached-test".to_string(),
701                pid,
702                temporary.path().join("bundle"),
703                temporary.path().join("runtime.json"),
704            ),
705            child,
706            log_worker,
707            log_worker_pid_start_time,
708        );
709
710        drop(handler);
711        let remained_alive = unsafe { libc::kill(pid as i32, 0) == 0 };
712        let log_worker_remained_alive = unsafe { libc::kill(log_worker_pid as i32, 0) == 0 };
713
714        unsafe {
715            libc::kill(pid as i32, libc::SIGKILL);
716            let mut status = 0;
717            libc::waitpid(pid as i32, &mut status, 0);
718            libc::kill(log_worker_pid as i32, libc::SIGKILL);
719            libc::waitpid(log_worker_pid as i32, &mut status, 0);
720        }
721        assert!(
722            remained_alive,
723            "dropping a runtime handle must not destroy a detached Sandbox"
724        );
725        assert!(
726            log_worker_remained_alive,
727            "dropping a runtime handle must not destroy its detached log worker"
728        );
729    }
730}