1use 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
42pub 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 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 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 Ok(self.exit_code)
440 }
441}
442
443enum SignalWaitOutcome {
444 Exited,
445 Running(Option<BoxError>),
446 Failed(BoxError),
447}
448
449fn 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}