1use 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
13const 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
27pub 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 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 #[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 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}