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    }
428
429    fn delete_runtime_state(&mut self) -> Result<()> {
430        if self.cleaned {
431            return Ok(());
432        }
433        let output = self
434            .runtime_command("delete")
435            .arg("--force")
436            .arg(&self.container_id)
437            .output()
438            .map_err(|error| BoxError::ExecError(format!("Failed to run crun delete: {error}")))?;
439        if !output.status.success() && self.query_state()?.is_some() {
440            return Err(runtime_failure("crun delete --force", &output));
441        }
442        // Reap the wrapper first: its inherited stdout/stderr descriptors must
443        // close before the worker treats EOF as final. Then wait for the worker
444        // to drain both streams before removing durable generation artifacts.
445        self.reap_child();
446        self.reap_log_worker();
447        self.cleaned = true;
448        remove_file_if_exists(&self.runtime_record);
449        remove_dir_if_exists(&self.bundle_dir);
450        remove_dir_if_exists(&self.runtime_root);
451        Ok(())
452    }
453}
454
455impl VmHandler for CrunHandler {
456    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
457        let mut first_error = None;
458        if self.query_state()?.is_some() {
459            if let Err(error) = self.signal_container(signal) {
460                first_error = Some(error);
461            }
462            match self.wait_for_exit(timeout_ms) {
463                Ok(true) => {}
464                Ok(false) => {
465                    tracing::warn!(
466                        container_id = %self.container_id,
467                        timeout_ms,
468                        "Sandbox did not stop gracefully; sending SIGKILL"
469                    );
470                    if let Err(error) = self.signal_container(SIGKILL_NUMBER) {
471                        first_error.get_or_insert(error);
472                    }
473                    let _ = self.wait_for_exit(2_000);
474                }
475                Err(error) => {
476                    first_error.get_or_insert(error);
477                    let _ = self.signal_container(SIGKILL_NUMBER);
478                }
479            }
480        }
481
482        if let Err(error) = self.delete_runtime_state() {
483            first_error.get_or_insert(error);
484        }
485        match first_error {
486            Some(error) => Err(error),
487            None => Ok(()),
488        }
489    }
490
491    fn metrics(&self) -> VmMetrics {
492        let pid = Pid::from_u32(self.init_pid);
493        let mut system = match self.metrics_sys.lock() {
494            Ok(system) => system,
495            Err(error) => {
496                tracing::warn!(%error, "Sandbox metrics lock is poisoned");
497                return VmMetrics::default();
498            }
499        };
500        system.refresh_process(pid);
501        system
502            .process(pid)
503            .map(|process| VmMetrics {
504                cpu_percent: Some(process.cpu_usage()),
505                memory_bytes: Some(process.memory()),
506            })
507            .unwrap_or_default()
508    }
509
510    fn is_running(&self) -> bool {
511        self.query_state()
512            .ok()
513            .flatten()
514            .is_some_and(|state| matches!(state.status.as_str(), "created" | "running" | "paused"))
515    }
516
517    fn has_exited(&self) -> bool {
518        !self.is_running()
519    }
520
521    fn pid(&self) -> u32 {
522        self.init_pid
523    }
524
525    fn exit_code(&self) -> Option<i32> {
526        self.exit_code
527    }
528
529    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
530        let exit = self.poll_child()?;
531        if exit.is_some() {
532            self.delete_runtime_state()?;
533        }
534        Ok(exit)
535    }
536}
537
538fn runtime_failure(operation: &str, output: &Output) -> BoxError {
539    let stderr = String::from_utf8_lossy(&output.stderr);
540    BoxError::ExecError(format!(
541        "{operation} exited with {}: {}",
542        output.status,
543        stderr.trim()
544    ))
545}
546
547fn remove_file_if_exists(path: &Path) {
548    match std::fs::remove_file(path) {
549        Ok(()) => {}
550        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
551        Err(error) => {
552            tracing::warn!(path = %path.display(), %error, "Failed to remove Sandbox runtime record")
553        }
554    }
555}
556
557fn remove_dir_if_exists(path: &Path) {
558    match std::fs::remove_dir_all(path) {
559        Ok(()) => {}
560        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
561        Err(error) => {
562            tracing::warn!(path = %path.display(), %error, "Failed to remove Sandbox runtime directory")
563        }
564    }
565}
566
567#[cfg(all(test, target_os = "linux"))]
568mod tests {
569    use super::*;
570
571    fn lifecycle_runtime(temporary: &tempfile::TempDir) -> (PathBuf, PathBuf) {
572        use std::os::unix::fs::PermissionsExt;
573
574        let runtime_root = temporary.path().join("runtime");
575        std::fs::create_dir(&runtime_root).unwrap();
576        std::fs::write(runtime_root.join("state"), "running\n").unwrap();
577        let runtime = temporary.path().join("crun-fixture");
578        std::fs::write(
579            &runtime,
580            r#"#!/bin/sh
581root="$2"
582operation="$3"
583case "$operation" in
584  state)
585    status="$(cat "$root/state")"
586    printf '{"status":"%s","pid":42}\n' "$status"
587    ;;
588  pause)
589    printf 'paused\n' > "$root/state"
590    ;;
591  resume)
592    printf 'running\n' > "$root/state"
593    ;;
594  *)
595    exit 64
596    ;;
597esac
598"#,
599        )
600        .unwrap();
601        std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)).unwrap();
602        (runtime, runtime_root)
603    }
604
605    #[test]
606    fn crun_pause_and_resume_are_state_checked_and_idempotent() {
607        let temporary = tempfile::tempdir().unwrap();
608        let (runtime, runtime_root) = lifecycle_runtime(&temporary);
609
610        CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap();
611        CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap();
612        assert_eq!(
613            CrunHandler::query_state_at(&runtime, &runtime_root, "sandbox-1")
614                .unwrap()
615                .unwrap()
616                .status,
617            "paused"
618        );
619
620        CrunHandler::resume_at(&runtime, &runtime_root, "sandbox-1").unwrap();
621        CrunHandler::resume_at(&runtime, &runtime_root, "sandbox-1").unwrap();
622        assert_eq!(
623            CrunHandler::query_state_at(&runtime, &runtime_root, "sandbox-1")
624                .unwrap()
625                .unwrap()
626                .status,
627            "running"
628        );
629    }
630
631    #[test]
632    fn crun_pause_rejects_a_terminal_runtime() {
633        let temporary = tempfile::tempdir().unwrap();
634        let (runtime, runtime_root) = lifecycle_runtime(&temporary);
635        std::fs::write(runtime_root.join("state"), "stopped\n").unwrap();
636
637        let error = CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap_err();
638
639        assert!(error.to_string().contains("state stopped"));
640    }
641
642    #[cfg(feature = "vm")]
643    #[test]
644    fn recorded_runtime_handler_attaches_without_owning_a_wrapper_process() {
645        let temporary = tempfile::tempdir().unwrap();
646        let runtime_path = PathBuf::from("/bin/true");
647        let runtime_root = temporary.path().join("runtime");
648        let bundle_dir = temporary.path().join("bundle");
649        let runtime_record = temporary.path().join("runtime.json");
650
651        let handler = CrunHandler::from_recorded_runtime(
652            CrunHandlerSpec::new(
653                runtime_path.clone(),
654                runtime_root.clone(),
655                "recorded-test".to_string(),
656                42,
657                bundle_dir.clone(),
658                runtime_record.clone(),
659            ),
660            None,
661            None,
662        );
663
664        assert_eq!(handler.runtime_path, runtime_path);
665        assert_eq!(handler.runtime_root, runtime_root);
666        assert_eq!(handler.container_id, "recorded-test");
667        assert_eq!(handler.pid(), 42);
668        assert!(handler.process.is_none());
669        assert!(handler.log_worker.is_none());
670        assert!(handler.log_worker_pid.is_none());
671        assert_eq!(handler.bundle_dir, bundle_dir);
672        assert_eq!(handler.runtime_record, runtime_record);
673        assert!(!handler.cleaned);
674    }
675
676    #[test]
677    fn dropping_handler_detaches_from_live_runtime_process() {
678        let temporary = tempfile::tempdir().unwrap();
679        let child = Command::new("sleep").arg("30").spawn().unwrap();
680        let pid = child.id();
681        let log_worker = Command::new("sleep").arg("30").spawn().unwrap();
682        let log_worker_pid = log_worker.id();
683        let log_worker_pid_start_time = crate::process::pid_start_time(log_worker_pid).unwrap();
684        let handler = CrunHandler::from_child(
685            CrunHandlerSpec::new(
686                PathBuf::from("/bin/true"),
687                temporary.path().join("runtime"),
688                "detached-test".to_string(),
689                pid,
690                temporary.path().join("bundle"),
691                temporary.path().join("runtime.json"),
692            ),
693            child,
694            log_worker,
695            log_worker_pid_start_time,
696        );
697
698        drop(handler);
699        let remained_alive = unsafe { libc::kill(pid as i32, 0) == 0 };
700        let log_worker_remained_alive = unsafe { libc::kill(log_worker_pid as i32, 0) == 0 };
701
702        unsafe {
703            libc::kill(pid as i32, libc::SIGKILL);
704            let mut status = 0;
705            libc::waitpid(pid as i32, &mut status, 0);
706            libc::kill(log_worker_pid as i32, libc::SIGKILL);
707            libc::waitpid(log_worker_pid as i32, &mut status, 0);
708        }
709        assert!(
710            remained_alive,
711            "dropping a runtime handle must not destroy a detached Sandbox"
712        );
713        assert!(
714            log_worker_remained_alive,
715            "dropping a runtime handle must not destroy its detached log worker"
716        );
717    }
718}