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 }
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 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}