Skip to main content

runtime/tools/exec/container/
guardian.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Independent cleanup on parent EOF (`crates/runtime/spec/Tools/Exec/Container.lean`, D52).
7
8use std::io::{Read, Write};
9use std::path::Path;
10use std::process::{Child, Command, Stdio};
11use std::time::Duration;
12
13use kernel::AxError;
14
15use super::{
16    cleanup::{Cleanup, Confirmation},
17    control, denied,
18};
19use serde::{Deserialize, Serialize};
20
21const ARGUMENT: &str = "__container-guardian";
22const READY: &[u8] = b"ready\n";
23const CREATE: &[u8] = b"create\n";
24const DONE: &[u8] = b"done\n";
25const POLLS: usize = 200;
26const INTERVAL: Duration = Duration::from_millis(10);
27const RETRY: Duration = Duration::from_secs(5);
28
29pub(super) struct Launch<'a> {
30    pub(super) cleanup: &'a Cleanup,
31    pub(super) command: &'a Command,
32}
33
34#[derive(Serialize, Deserialize)]
35struct Ownership {
36    #[serde(flatten)]
37    cleanup: Cleanup,
38    arguments: Vec<std::ffi::OsString>,
39    answer: std::path::PathBuf,
40}
41
42pub(super) struct Guard {
43    child: Child,
44    answer: std::path::PathBuf,
45}
46
47impl Guard {
48    pub(super) fn spawn(
49        harness: &Path,
50        launch: Launch<'_>,
51        ready: &Path,
52        errors: std::fs::File,
53    ) -> Result<Self, AxError> {
54        let answer = ready.with_extension("created");
55        std::fs::File::create(&answer)
56            .map_err(|err| denied("prepare container creation response", err))?;
57        let record = serde_json::to_vec(&Ownership {
58            cleanup: launch.cleanup.clone(),
59            arguments: launch
60                .command
61                .get_args()
62                .map(std::ffi::OsStr::to_os_string)
63                .collect(),
64            answer: answer.clone(),
65        })
66        .map_err(|err| denied("encode container guardian ownership", err))?;
67        let ownership = ready.with_extension("ownership");
68        std::fs::write(&ownership, record)
69            .map_err(|err| denied("keep container guardian ownership", err))?;
70        let output = std::fs::File::create(ready)
71            .map_err(|err| denied("prepare container guardian readiness", err))?;
72        let child = Command::new(harness)
73            .arg(ARGUMENT)
74            .arg(ownership)
75            .stdin(Stdio::piped())
76            .stdout(output)
77            .stderr(errors)
78            .spawn()
79            .map_err(|err| denied("start the container guardian", err))?;
80        let mut guard = Self { child, answer };
81        let ready_budget = u64::try_from(READY.len().saturating_add(1))
82            .map_err(|err| denied("read container guardian readiness", err))?;
83        for _ in 0..POLLS {
84            let mut bytes = Vec::with_capacity(READY.len().saturating_add(1));
85            std::fs::File::open(ready)
86                .and_then(|file| file.take(ready_budget).read_to_end(&mut bytes))
87                .map_err(|err| denied("read container guardian readiness", err))?;
88            if bytes == READY {
89                return Ok(guard);
90            }
91            if let Some(status) = guard
92                .child
93                .try_wait()
94                .map_err(|err| denied("poll container guardian readiness", err))?
95            {
96                return Err(denied(
97                    "start the container guardian",
98                    format!("helper ended before ready: {status}"),
99                ));
100            }
101            std::thread::sleep(INTERVAL);
102        }
103        Err(denied(
104            "start the container guardian",
105            "helper did not confirm readiness within its poll budget",
106        ))
107    }
108
109    pub(super) fn create(&mut self) -> Result<(), AxError> {
110        self.child
111            .stdin
112            .as_mut()
113            .ok_or_else(|| denied("request container creation", "guardian channel closed"))?
114            .write_all(CREATE)
115            .map_err(|err| denied("request container creation", err))?;
116        for _ in 0..control::POLLS {
117            let bytes = std::fs::read(&self.answer)
118                .map_err(|err| denied("read container creation response", err))?;
119            if bytes.last() == Some(&b'\n') {
120                return serde_json::from_slice::<Result<(), AxError>>(&bytes)
121                    .map_err(|err| denied("decode container creation response", err))?;
122            }
123            std::thread::sleep(control::INTERVAL);
124        }
125        Err(denied(
126            "wait for container creation",
127            "guardian retains the in-flight request",
128        ))
129    }
130
131    pub(super) fn finish_cleanup(&mut self) -> Result<(), AxError> {
132        drop(self.child.stdin.take());
133        match self
134            .child
135            .try_wait()
136            .map_err(|err| denied("reap the container guardian", err))?
137        {
138            Some(status) if status.success() => Ok(()),
139            Some(status) => Err(denied("reap the container guardian", status)),
140            None => Err(denied(
141                "wait for guardian cleanup",
142                "guardian still owns creation and removal",
143            )),
144        }
145    }
146
147    pub(super) fn disarm(&mut self) -> Result<(), AxError> {
148        if let Some(status) = self
149            .child
150            .try_wait()
151            .map_err(|err| denied("reap the container guardian", err))?
152        {
153            return if status.success() {
154                Ok(())
155            } else {
156                Err(denied("reap the container guardian", status))
157            };
158        }
159        if let Some(mut input) = self.child.stdin.take() {
160            input
161                .write_all(DONE)
162                .map_err(|err| denied("release the container guardian", err))?;
163        }
164        for _ in 0..POLLS {
165            if let Some(status) = self
166                .child
167                .try_wait()
168                .map_err(|err| denied("reap the container guardian", err))?
169            {
170                return if status.success() {
171                    Ok(())
172                } else {
173                    Err(denied("reap the container guardian", status))
174                };
175            }
176            std::thread::sleep(INTERVAL);
177        }
178        Err(denied(
179            "reap the container guardian",
180            "helper remains responsible after the poll budget",
181        ))
182    }
183}
184
185/// Runs the private child protocol after the ordinary CLI grammar refuses it.
186///
187/// The caller supplies only argv from its own executable; models have no wire door to it.
188///
189/// # Errors
190/// Returns malformed ownership and readiness failures before any target is created.
191/// After parent EOF, removal failures retain the helper and retry until confirmed.
192pub fn run_container_guard(args: &[String]) -> Option<Result<(), AxError>> {
193    if args.first().map(String::as_str) != Some(ARGUMENT) {
194        return None;
195    }
196    Some(run(args))
197}
198
199fn run(args: &[String]) -> Result<(), AxError> {
200    let [_, record] = args else {
201        return Err(denied(
202            "read container guardian ownership",
203            "expected one cleanup record",
204        ));
205    };
206    let bytes =
207        std::fs::read(record).map_err(|err| denied("read container guardian ownership", err))?;
208    let ownership: Ownership = serde_json::from_slice(&bytes)
209        .map_err(|err| denied("decode container guardian ownership", err))?;
210    std::io::stdout()
211        .lock()
212        .write_all(READY)
213        .map_err(|err| denied("confirm container guardian readiness", err))?;
214    let mut input = std::io::stdin().lock();
215    let mut confirmation = Confirmation::Absence;
216    let request = read_command(&mut input);
217    match request {
218        Ok(answer) if answer == CREATE => {
219            let mut create = Command::new(&ownership.cleanup.program);
220            create.args(&ownership.arguments);
221            let created =
222                control::output(&mut create, control::Wait::Creation).and_then(|answer| {
223                    if answer.status.success() {
224                        Ok(())
225                    } else {
226                        Err(denied(
227                            "create the owned container",
228                            String::from_utf8_lossy(&answer.stderr),
229                        ))
230                    }
231                });
232            if created.is_err() {
233                confirmation = Confirmation::Deletion;
234            }
235            let published = serde_json::to_vec(&created)
236                .map_err(|err| denied("encode container creation response", err))
237                .and_then(|mut bytes| {
238                    bytes.push(b'\n');
239                    std::fs::write(&ownership.answer, bytes)
240                        .map_err(|err| denied("publish container creation response", err))
241                });
242            if let Err(err) = published {
243                eprintln!("{err}; guardian takes cleanup responsibility");
244            } else {
245                match read_command(&mut input) {
246                    Ok(answer) if answer == DONE && created.is_ok() => return Ok(()),
247                    Ok(_) => {}
248                    Err(err) => eprintln!("{err}; guardian takes cleanup responsibility"),
249                }
250            }
251        }
252        Ok(answer) if answer == DONE => return Ok(()),
253        Ok(_) => {}
254        Err(err) => eprintln!("{err}; guardian takes cleanup responsibility"),
255    }
256    loop {
257        match ownership.cleanup.remove(confirmation).and_then(|()| {
258            confirmation = Confirmation::Absence;
259            ownership.cleanup.release_copy()
260        }) {
261            Ok(()) => return Ok(()),
262            Err(err) => {
263                eprintln!(
264                    "{err}; container {} remains owned by its guardian",
265                    ownership.cleanup.name
266                );
267                std::thread::sleep(RETRY);
268            }
269        }
270    }
271}
272
273fn read_command(input: &mut impl Read) -> Result<Vec<u8>, AxError> {
274    let mut answer = Vec::with_capacity(CREATE.len());
275    for _ in 0..CREATE.len() {
276        let mut byte = [0_u8; 1];
277        let count = input
278            .read(&mut byte)
279            .map_err(|err| denied("read guardian command", err))?;
280        if count == 0 {
281            break;
282        }
283        answer.extend_from_slice(&byte);
284        if byte == *b"\n" {
285            break;
286        }
287    }
288    Ok(answer)
289}