Skip to main content

a3s_box_runtime/sandbox/
a3s_oci_handler.rs

1//! Runtime handler for a live A3S OCI Sandbox container.
2
3use std::path::{Path, PathBuf};
4use std::process::Child;
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 a3s_oci_sdk::{
11    ContainerId, ContainerOperationRequest, ContainerRecord, ContainerTarget, DeleteMode,
12    DeleteRequest, DriverKind, ExitStatus, Generation, KillRequest, LinuxResources,
13    OciContainerState, OperationContext, OperationId, Signal, StateRequest, StatsRequest,
14    UpdateRequest, WaitRequest,
15};
16use sysinfo::{Pid, System};
17
18use super::a3s_oci_client::A3sOciClient;
19
20const SIGKILL_NUMBER: i32 = 9;
21const LIFECYCLE_POLL_INTERVAL: Duration = Duration::from_millis(25);
22const CLEANUP_RETRY_TIMEOUT: Duration = Duration::from_secs(2);
23
24#[derive(Debug)]
25pub(crate) struct A3sOciState {
26    pub(crate) status: String,
27    pub(crate) pid: u32,
28}
29
30pub(crate) struct A3sOciHandlerSpec {
31    pub(crate) runtime_socket: PathBuf,
32    pub(crate) runtime_root: PathBuf,
33    pub(crate) container_id: ContainerId,
34    pub(crate) generation: Generation,
35    pub(crate) init_pid: u32,
36    pub(crate) owner_pid: u32,
37    pub(crate) owner_pid_start_time: u64,
38    pub(crate) bundle_dir: PathBuf,
39    pub(crate) runtime_record: PathBuf,
40}
41
42/// Owns one exact A3S OCI generation and its detached runtime owner.
43pub struct A3sOciHandler {
44    client: A3sOciClient,
45    target: ContainerTarget,
46    init_pid: u32,
47    owner: Option<Child>,
48    owner_pid: u32,
49    owner_pid_start_time: u64,
50    log_worker: Option<Child>,
51    log_worker_pid: Option<u32>,
52    log_worker_pid_start_time: Option<u64>,
53    metrics_sys: Mutex<System>,
54    exit_code: Option<i32>,
55    runtime_root: PathBuf,
56    bundle_dir: PathBuf,
57    runtime_record: PathBuf,
58    cleaned: bool,
59}
60
61impl A3sOciHandler {
62    pub(crate) fn from_child(
63        spec: A3sOciHandlerSpec,
64        client: A3sOciClient,
65        owner: Child,
66        log_worker: Child,
67        log_worker_pid_start_time: u64,
68    ) -> Self {
69        let log_worker_pid = log_worker.id();
70        Self {
71            client,
72            target: ContainerTarget::exact(spec.container_id, spec.generation),
73            init_pid: spec.init_pid,
74            owner: Some(owner),
75            owner_pid: spec.owner_pid,
76            owner_pid_start_time: spec.owner_pid_start_time,
77            log_worker: Some(log_worker),
78            log_worker_pid: Some(log_worker_pid),
79            log_worker_pid_start_time: Some(log_worker_pid_start_time),
80            metrics_sys: Mutex::new(System::new()),
81            exit_code: None,
82            runtime_root: spec.runtime_root,
83            bundle_dir: spec.bundle_dir,
84            runtime_record: spec.runtime_record,
85            cleaned: false,
86        }
87    }
88
89    pub(crate) async fn from_recorded_runtime(
90        spec: A3sOciHandlerSpec,
91        log_worker_pid: Option<u32>,
92        log_worker_pid_start_time: Option<u64>,
93    ) -> Result<Self> {
94        let client = A3sOciClient::connect(spec.runtime_socket).await?;
95        let target = ContainerTarget::exact(spec.container_id, spec.generation);
96        let record = client
97            .state_optional(StateRequest {
98                target: target.clone(),
99            })?
100            .ok_or_else(|| {
101                BoxError::StateError("Recorded A3S OCI generation is absent".to_string())
102            })?;
103        validate_record(&record, &target, Some(spec.init_pid))?;
104        Ok(Self {
105            client,
106            target,
107            init_pid: spec.init_pid,
108            owner: None,
109            owner_pid: spec.owner_pid,
110            owner_pid_start_time: spec.owner_pid_start_time,
111            log_worker: None,
112            log_worker_pid,
113            log_worker_pid_start_time,
114            metrics_sys: Mutex::new(System::new()),
115            exit_code: None,
116            runtime_root: spec.runtime_root,
117            bundle_dir: spec.bundle_dir,
118            runtime_record: spec.runtime_record,
119            cleaned: false,
120        })
121    }
122
123    pub(crate) fn query_state_at(
124        runtime_socket: &Path,
125        container_id: &str,
126        generation: u64,
127    ) -> Result<Option<A3sOciState>> {
128        let client = A3sOciClient::connect_blocking(runtime_socket.to_path_buf())?;
129        let id = ContainerId::new(container_id).map_err(sdk_argument_error)?;
130        let target = ContainerTarget::exact(id, Generation(generation));
131        let state = client.state_optional(StateRequest {
132            target: target.clone(),
133        })?;
134        client.close();
135        state
136            .map(|record| state_summary(&record, &target, None))
137            .transpose()
138    }
139
140    /// Poll the exact detached A3S OCI generation for its terminal status.
141    /// The runtime retains this status until Box explicitly deletes it.
142    pub(crate) fn try_wait_at(
143        runtime_socket: &Path,
144        container_id: &str,
145        generation: u64,
146    ) -> Result<Option<i32>> {
147        let client = A3sOciClient::connect_blocking(runtime_socket.to_path_buf())?;
148        let id = ContainerId::new(container_id).map_err(sdk_argument_error)?;
149        let result = client
150            .try_wait(WaitRequest {
151                target: ContainerTarget::exact(id, Generation(generation)),
152                timeout_ms: Some(0),
153            })
154            .map(|status| status.map(|status| exit_code(&status)));
155        client.close();
156        result
157    }
158
159    pub(crate) fn pause_at(
160        runtime_socket: &Path,
161        container_id: &str,
162        generation: u64,
163    ) -> Result<()> {
164        Self::transition_at(runtime_socket, container_id, generation, true)
165    }
166
167    pub(crate) fn resume_at(
168        runtime_socket: &Path,
169        container_id: &str,
170        generation: u64,
171    ) -> Result<()> {
172        Self::transition_at(runtime_socket, container_id, generation, false)
173    }
174
175    /// Apply one complete resource contract to an exact live Sandbox generation.
176    pub(crate) fn update_at(
177        runtime_socket: &Path,
178        container_id: &str,
179        generation: u64,
180        resources: LinuxResources,
181    ) -> Result<()> {
182        let client = A3sOciClient::connect_blocking(runtime_socket.to_path_buf())?;
183        let result = (|| {
184            let id = ContainerId::new(container_id).map_err(sdk_argument_error)?;
185            let target = ContainerTarget::exact(id, Generation(generation));
186            let record = client.update(UpdateRequest {
187                context: operation_context(container_id, "update")?,
188                target: target.clone(),
189                resources,
190            })?;
191            validate_record(&record, &target, None)
192        })();
193        client.close();
194        result
195    }
196
197    fn transition_at(
198        runtime_socket: &Path,
199        container_id: &str,
200        generation: u64,
201        pause: bool,
202    ) -> Result<()> {
203        let client = A3sOciClient::connect_blocking(runtime_socket.to_path_buf())?;
204        let id = ContainerId::new(container_id).map_err(sdk_argument_error)?;
205        let target = ContainerTarget::exact(id, Generation(generation));
206        let context = operation_context(container_id, if pause { "pause" } else { "resume" })?;
207        let request = ContainerOperationRequest {
208            context,
209            target: target.clone(),
210        };
211        let record = if pause {
212            client.pause(request)?
213        } else {
214            client.resume(request)?
215        };
216        validate_record(&record, &target, None)?;
217        if record.is_paused() != pause {
218            return Err(BoxError::StateError(format!(
219                "A3S OCI runtime did not {} {container_id}",
220                if pause { "pause" } else { "resume" }
221            )));
222        }
223        client.close();
224        Ok(())
225    }
226
227    fn query_state(&self) -> Result<Option<ContainerRecord>> {
228        self.client.state_optional(StateRequest {
229            target: self.target.clone(),
230        })
231    }
232
233    fn signal_container(&self, signal: i32, suffix: &str) -> Result<()> {
234        let signal = Signal::new(signal).map_err(sdk_argument_error)?;
235        let record = self.client.kill(KillRequest {
236            context: operation_context(self.target.id.as_str(), suffix)?,
237            target: self.target.clone(),
238            signal,
239            all: true,
240        })?;
241        validate_record(&record, &self.target, None)
242    }
243
244    fn wait_for_exit(&mut self, timeout_ms: u64) -> Result<bool> {
245        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
246        loop {
247            match self.query_state()? {
248                None => return Ok(true),
249                Some(record) if *record.state.status() == OciContainerState::Stopped => {
250                    self.capture_exit_status()?;
251                    return Ok(true);
252                }
253                Some(_) if Instant::now() < deadline => {
254                    std::thread::sleep(LIFECYCLE_POLL_INTERVAL);
255                }
256                Some(_) => return Ok(false),
257            }
258        }
259    }
260
261    fn capture_exit_status(&mut self) -> Result<()> {
262        if self.exit_code.is_some() {
263            return Ok(());
264        }
265        let status = self.client.wait(WaitRequest {
266            target: self.target.clone(),
267            timeout_ms: Some(0),
268        })?;
269        self.exit_code = Some(exit_code(&status));
270        Ok(())
271    }
272
273    fn delete_runtime_state(&mut self) -> Result<()> {
274        if self.cleaned {
275            return Ok(());
276        }
277        self.client.delete_if_present(DeleteRequest {
278            context: operation_context(self.target.id.as_str(), "delete")?,
279            target: self.target.clone(),
280            mode: DeleteMode::Force,
281        })?;
282        self.client.close();
283        self.stop_owner()?;
284        self.reap_log_worker();
285        remove_dir_until_absent(&self.bundle_dir)?;
286        remove_dir_until_absent(&self.runtime_root)?;
287        remove_file_if_exists(&self.runtime_record)?;
288        self.cleaned = true;
289        Ok(())
290    }
291
292    fn stop_owner(&mut self) -> Result<()> {
293        super::a3s_oci_owner::stop(self.owner_pid, self.owner_pid_start_time)?;
294        if let Some(mut owner) = self.owner.take() {
295            let _ = owner.try_wait();
296            let _ = owner.wait();
297        }
298        Ok(())
299    }
300
301    fn reap_log_worker(&mut self) {
302        const LOG_WORKER_EXIT_TIMEOUT: Duration = Duration::from_secs(2);
303        if let Some(mut worker) = self.log_worker.take() {
304            let deadline = Instant::now() + LOG_WORKER_EXIT_TIMEOUT;
305            loop {
306                match worker.try_wait() {
307                    Ok(Some(_)) => return,
308                    Ok(None) if Instant::now() < deadline => {
309                        std::thread::sleep(Duration::from_millis(10));
310                    }
311                    _ => break,
312                }
313            }
314            let _ = worker.kill();
315            let _ = worker.wait();
316            return;
317        }
318        let (Some(pid), Some(start_time)) = (self.log_worker_pid, self.log_worker_pid_start_time)
319        else {
320            return;
321        };
322        if !crate::process::wait_for_process_exit_with_identity(
323            pid,
324            start_time,
325            LOG_WORKER_EXIT_TIMEOUT,
326        ) && crate::process::is_process_alive_with_identity(pid, Some(start_time))
327        {
328            if let Ok(pid) = i32::try_from(pid) {
329                unsafe {
330                    libc::kill(pid, libc::SIGKILL);
331                }
332            }
333            let _ = crate::process::wait_for_process_exit_with_identity(
334                self.log_worker_pid.unwrap_or_default(),
335                start_time,
336                Duration::from_secs(1),
337            );
338        }
339    }
340}
341
342impl VmHandler for A3sOciHandler {
343    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()> {
344        let mut first_error = None;
345        match self.query_state()? {
346            Some(record) if *record.state.status() != OciContainerState::Stopped => {
347                let signal_error = self.signal_container(signal, "stop").err();
348                let wait_result = self.wait_for_exit(timeout_ms);
349                match reconcile_signal_with_wait(signal_error, wait_result) {
350                    SignalWaitOutcome::Exited => {}
351                    SignalWaitOutcome::Running(error) => {
352                        first_error = error;
353                        if let Err(error) = self.signal_container(SIGKILL_NUMBER, "force-stop") {
354                            first_error.get_or_insert(error);
355                        }
356                        let _ = self.wait_for_exit(2_000);
357                    }
358                    SignalWaitOutcome::Failed(error) => {
359                        first_error = Some(error);
360                        let _ = self.signal_container(SIGKILL_NUMBER, "failed-stop-cleanup");
361                    }
362                }
363            }
364            Some(_) => {
365                if let Err(error) = self.capture_exit_status() {
366                    first_error = Some(error);
367                }
368            }
369            None => {}
370        }
371        if let Err(error) = self.delete_runtime_state() {
372            first_error.get_or_insert(error);
373        }
374        match first_error {
375            Some(error) => Err(error),
376            None => Ok(()),
377        }
378    }
379
380    fn metrics(&self) -> VmMetrics {
381        if let Ok(stats) = self.client.stats(StatsRequest {
382            target: self.target.clone(),
383        }) {
384            return VmMetrics {
385                cpu_percent: None,
386                memory_bytes: Some(stats.memory.usage_bytes),
387            };
388        }
389        let pid = Pid::from_u32(self.init_pid);
390        let mut system = match self.metrics_sys.lock() {
391            Ok(system) => system,
392            Err(_) => return VmMetrics::default(),
393        };
394        system.refresh_process(pid);
395        system
396            .process(pid)
397            .map(|process| VmMetrics {
398                cpu_percent: Some(process.cpu_usage()),
399                memory_bytes: Some(process.memory()),
400            })
401            .unwrap_or_default()
402    }
403
404    fn is_running(&self) -> bool {
405        self.query_state().ok().flatten().is_some_and(|record| {
406            matches!(
407                *record.state.status(),
408                OciContainerState::Created | OciContainerState::Running
409            )
410        })
411    }
412
413    fn has_exited(&self) -> bool {
414        !self.is_running()
415    }
416
417    fn pid(&self) -> u32 {
418        self.init_pid
419    }
420
421    fn exit_code(&self) -> Option<i32> {
422        self.exit_code
423    }
424
425    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
426        if self.exit_code.is_none() {
427            let Some(status) = self.client.try_wait(WaitRequest {
428                target: self.target.clone(),
429                timeout_ms: Some(0),
430            })?
431            else {
432                return Ok(None);
433            };
434            self.exit_code = Some(exit_code(&status));
435        }
436        // Polling observes completion but does not own teardown. Keeping the
437        // terminal generation lets the backend complete its one authoritative
438        // destroy path after it has durably projected this exact status.
439        Ok(self.exit_code)
440    }
441}
442
443enum SignalWaitOutcome {
444    Exited,
445    Running(Option<BoxError>),
446    Failed(BoxError),
447}
448
449/// Resolve a signal result against the later authoritative lifecycle state.
450///
451/// A workload can exit naturally after `state` reports it running but before
452/// the runtime handles `kill`. If the subsequent wait captures that terminal
453/// generation, a "stopped container" signal error is stale and cleanup is
454/// already successful. When the workload is still live or wait itself fails,
455/// retain the first lifecycle error as before.
456fn reconcile_signal_with_wait(
457    signal_error: Option<BoxError>,
458    wait_result: Result<bool>,
459) -> SignalWaitOutcome {
460    match wait_result {
461        Ok(true) => SignalWaitOutcome::Exited,
462        Ok(false) => SignalWaitOutcome::Running(signal_error),
463        Err(wait_error) => SignalWaitOutcome::Failed(signal_error.unwrap_or(wait_error)),
464    }
465}
466
467pub(crate) fn validate_record(
468    record: &ContainerRecord,
469    target: &ContainerTarget,
470    expected_pid: Option<u32>,
471) -> Result<()> {
472    if record.state.id() != target.id.as_str()
473        || record.generation != target.generation.unwrap_or_default()
474        || record.driver != DriverKind::NativeLinux
475    {
476        return Err(BoxError::StateError(
477            "A3S OCI runtime returned a different container identity".to_string(),
478        ));
479    }
480    if let Some(expected_pid) = expected_pid {
481        let pid = record
482            .state
483            .pid()
484            .and_then(|pid| u32::try_from(pid).ok())
485            .ok_or_else(|| {
486                BoxError::StateError("A3S OCI runtime returned no valid init PID".to_string())
487            })?;
488        if pid != expected_pid {
489            return Err(BoxError::StateError(
490                "A3S OCI runtime PID disagrees with its durable record".to_string(),
491            ));
492        }
493    }
494    Ok(())
495}
496
497fn state_summary(
498    record: &ContainerRecord,
499    target: &ContainerTarget,
500    expected_pid: Option<u32>,
501) -> Result<A3sOciState> {
502    validate_record(record, target, expected_pid)?;
503    let status = if record.is_paused() {
504        "paused".to_string()
505    } else {
506        record.state.status().to_string()
507    };
508    let pid = record
509        .state
510        .pid()
511        .and_then(|pid| u32::try_from(pid).ok())
512        .unwrap_or(0);
513    Ok(A3sOciState { status, pid })
514}
515
516fn operation_context(container_id: &str, operation: &str) -> Result<OperationContext> {
517    OperationId::new(format!(
518        "{container_id}-{operation}-{}",
519        uuid::Uuid::new_v4().simple()
520    ))
521    .map(OperationContext::new)
522    .map_err(sdk_argument_error)
523}
524
525fn sdk_argument_error(error: a3s_oci_sdk::Error) -> BoxError {
526    BoxError::ConfigError(error.to_string())
527}
528
529fn exit_code(status: &ExitStatus) -> i32 {
530    status
531        .exit_code
532        .or_else(|| status.signal.map(|signal| 128 + signal))
533        .unwrap_or(128)
534}
535
536fn remove_file_if_exists(path: &Path) -> Result<()> {
537    match std::fs::remove_file(path) {
538        Ok(()) => Ok(()),
539        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
540        Err(error) => Err(BoxError::StateError(format!(
541            "Failed to remove A3S OCI runtime record {}: {error}",
542            path.display()
543        ))),
544    }
545}
546
547fn remove_dir_until_absent(path: &Path) -> Result<()> {
548    let deadline = Instant::now() + CLEANUP_RETRY_TIMEOUT;
549    loop {
550        match std::fs::remove_dir_all(path) {
551            Ok(()) => return Ok(()),
552            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
553            Err(_) if Instant::now() < deadline => {
554                std::thread::sleep(LIFECYCLE_POLL_INTERVAL);
555            }
556            Err(error) => {
557                return Err(BoxError::StateError(format!(
558                    "Failed to remove A3S OCI runtime directory {}: {error}",
559                    path.display()
560                )))
561            }
562        }
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::{reconcile_signal_with_wait, SignalWaitOutcome};
569    use a3s_box_core::error::BoxError;
570
571    fn state_error(message: &str) -> BoxError {
572        BoxError::StateError(message.to_string())
573    }
574
575    #[test]
576    fn authoritative_exit_suppresses_a_stale_signal_failure() {
577        let outcome = reconcile_signal_with_wait(
578            Some(state_error("cannot signal stopped container")),
579            Ok(true),
580        );
581
582        assert!(matches!(outcome, SignalWaitOutcome::Exited));
583    }
584
585    #[test]
586    fn signal_failure_is_retained_while_the_container_remains_live() {
587        let outcome = reconcile_signal_with_wait(Some(state_error("signal failed")), Ok(false));
588
589        let SignalWaitOutcome::Running(Some(error)) = outcome else {
590            panic!("expected a live container with its signal failure");
591        };
592        assert!(error.to_string().contains("signal failed"));
593    }
594
595    #[test]
596    fn wait_failure_is_retained_when_signaling_succeeded() {
597        let outcome = reconcile_signal_with_wait(None, Err(state_error("wait failed")));
598
599        let SignalWaitOutcome::Failed(error) = outcome else {
600            panic!("expected the wait failure");
601        };
602        assert!(error.to_string().contains("wait failed"));
603    }
604}