Skip to main content

lean_ctx/shell/containment/
guard.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The guard of one process group, and the Engine's handle on it.
3//!
4//! The guard is started as a MEMBER of the group it guards (the spawner puts
5//! it there before it runs). It then
6//!
7//! 1. checks that it is in the expected group,
8//! 2. forks a pin — a child that exits at once and is not reaped, so the
9//!    group's number stays allocated for as long as the guard lives,
10//! 3. moves to a group of its own and checks that it left,
11//! 4. reports that it is ready, and blocks until its input ends,
12//! 5. ends the guarded group — from outside, so it survives the signal and
13//!    can repeat it until no live member is left — and exits.
14//!
15//! It holds none of the guarded processes' pipes: its input is the control
16//! pipe, its output carries the one ready line, its error output is null.
17
18use std::io::Read as _;
19use std::os::unix::process::CommandExt as _;
20use std::process::{Child, ChildStdout, Command, Stdio};
21use std::time::Duration;
22
23use super::{END_BUDGET, EXIT_CONTAINMENT, EXIT_USAGE, GroupEnd};
24
25/// The line a guard writes once the group is pinned and it has left it.
26const READY: &[u8] = b"leanctx-group-guard ready\n";
27/// Exit code: the signals were sent, the process table could not be read.
28const EXIT_UNCONFIRMED: i32 = 2;
29/// Exit code: live members remained when the budget was spent.
30const EXIT_SURVIVORS: i32 = 3;
31/// How long a started guard may take to report that it is ready.
32const READY_BUDGET: Duration = Duration::from_secs(5);
33/// How long an ended guard may take to become reapable.
34const REAP_BUDGET: Duration = Duration::from_secs(2);
35
36/// `__group-guard --group <pgid>`.
37pub(super) fn main(mut args: impl Iterator<Item = std::ffi::OsString>) -> i32 {
38    let group = match (args.next(), args.next(), args.next()) {
39        (Some(flag), Some(value), None) if flag == "--group" => value
40            .to_str()
41            .and_then(|value| value.parse::<libc::pid_t>().ok()),
42        _ => None,
43    };
44    group.filter(|group| *group > 1).map_or(EXIT_USAGE, run)
45}
46
47/// The guard itself. Returns its exit code.
48pub(super) fn run(expected: libc::pid_t) -> i32 {
49    // SAFETY: getpgrp has no preconditions.
50    if unsafe { libc::getpgrp() } != expected {
51        return EXIT_CONTAINMENT;
52    }
53    // The guard ends with its input, never earlier: a signal meant for
54    // "everything" must not remove the backstop while its owner still runs.
55    super::set_termination_signals(libc::SIG_IGN);
56    // SAFETY: the child calls nothing but _exit, which is async-signal-safe.
57    let pin = unsafe { libc::fork() };
58    if pin == 0 {
59        // SAFETY: see above.
60        unsafe { libc::_exit(0) };
61    }
62    if pin < 0 {
63        return EXIT_CONTAINMENT;
64    }
65    // SAFETY: plain syscalls on this process. A process that is no session
66    // leader may always open a group of its own.
67    let left = unsafe { libc::setpgid(0, 0) } == 0 && unsafe { libc::getpgrp() } != expected;
68    if !left {
69        reap_pin(pin);
70        return EXIT_CONTAINMENT;
71    }
72    // A failed write means the owner is gone already; the input ends at once.
73    let _ = super::write_fd(libc::STDOUT_FILENO, READY);
74    wait_for_end_of_input();
75    let end = super::end_pinned_group(expected, END_BUDGET);
76    reap_pin(pin);
77    match end {
78        GroupEnd::Empty => 0,
79        GroupEnd::Unconfirmed => EXIT_UNCONFIRMED,
80        GroupEnd::Survivors => EXIT_SURVIVORS,
81        GroupEnd::Refused => EXIT_CONTAINMENT,
82    }
83}
84
85/// Blocks until standard input is at its end. Data on it is ignored; an input
86/// that cannot be read counts as ended, so a guard never waits on nothing.
87fn wait_for_end_of_input() {
88    let mut buffer = [0_u8; 64];
89    loop {
90        // SAFETY: the pointer and length describe `buffer`.
91        let read =
92            unsafe { libc::read(libc::STDIN_FILENO, buffer.as_mut_ptr().cast(), buffer.len()) };
93        if read == 0 {
94            return;
95        }
96        if read < 0 && std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
97            return;
98        }
99    }
100}
101
102fn reap_pin(pin: libc::pid_t) {
103    let mut status: libc::c_int = 0;
104    // SAFETY: waits for our own child; the status pointer is valid.
105    unsafe { libc::waitpid(pin, &raw mut status, 0) };
106}
107
108/// The command that starts a guard for `expected`.
109fn guard_command(expected: libc::pid_t) -> std::io::Result<Command> {
110    #[cfg(any(test, feature = "test-support"))]
111    if super::harness_helper() {
112        return harness_guard_command(expected);
113    }
114    let mut command = Command::new(std::env::current_exe()?);
115    command.args([super::GUARD_MODE, "--group", &expected.to_string()]);
116    Ok(command)
117}
118
119/// In the unit tests the running binary is the test harness; its helper test
120/// runs the same guard function.
121#[cfg(any(test, feature = "test-support"))]
122fn harness_guard_command(expected: libc::pid_t) -> std::io::Result<Command> {
123    let mut command = Command::new(std::env::current_exe()?);
124    command
125        .args([
126            super::tests::HELPER_TEST,
127            "--exact",
128            "--ignored",
129            "--nocapture",
130        ])
131        .env(super::tests::HELPER_ENV, "group-guard")
132        .env(super::tests::GROUP_ENV, expected.to_string());
133    Ok(command)
134}
135
136/// What a guard reported when its owner let go of it.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum GuardOutcome {
139    /// The group has no live member left.
140    Ended,
141    /// The group was signalled; whether it is empty could not be observed.
142    Unconfirmed,
143    /// The guard did not end the group, or did not finish in time.
144    Failed,
145}
146
147/// The owner's handle on a guard: the guard ends its group when this handle
148/// goes away — by [`CallGuard::finish`], by a drop, or by the owner's death.
149pub struct CallGuard {
150    guard: Child,
151    hold: Option<std::io::PipeWriter>,
152}
153
154impl CallGuard {
155    /// Starts a guard for the group `expected`, as a member of the group
156    /// `join`; the two differ only in tests. The one caller is the gate
157    /// (`gate::start_behind_guard`): the group's leader waits there and has
158    /// not executed its program, so it cannot have exited.
159    pub(super) fn attach_to(join: libc::pid_t, expected: libc::pid_t) -> Result<Self, String> {
160        let (reader, writer) = std::io::pipe().map_err(|error| error.to_string())?;
161        let mut command = guard_command(expected).map_err(|error| error.to_string())?;
162        command
163            .stdin(reader)
164            .stdout(Stdio::piped())
165            .stderr(Stdio::null())
166            .process_group(join);
167        let mut guard = command
168            .spawn()
169            .map_err(|error| format!("the guard could not join the group: {error}"))?;
170        // The command keeps a copy of the read end; it is of no use any more.
171        drop(command);
172        let ready = guard
173            .stdout
174            .take()
175            .is_some_and(|output| ready_within(output, READY_BUDGET));
176        if !ready {
177            // Whatever was started is not a working guard. Closing its input
178            // lets a real one end; then it is ended by handle and reaped.
179            drop(writer);
180            let _ = guard.kill();
181            let _ = crate::shell::exec::reap_within(&mut guard, REAP_BUDGET);
182            return Err("the guard did not become ready".to_string());
183        }
184        Ok(Self {
185            guard,
186            hold: Some(writer),
187        })
188    }
189
190    /// Lets go: the guard ends what is left of the group, and is reaped.
191    pub fn finish(mut self) -> GuardOutcome {
192        drop(self.hold.take());
193        let outcome = if let Some(status) =
194            crate::shell::exec::reap_within(&mut self.guard, END_BUDGET + REAP_BUDGET)
195        {
196            match status.code() {
197                Some(0) => GuardOutcome::Ended,
198                Some(EXIT_UNCONFIRMED) => GuardOutcome::Unconfirmed,
199                _ => GuardOutcome::Failed,
200            }
201        } else {
202            let _ = self.guard.kill();
203            let _ = crate::shell::exec::reap_within(&mut self.guard, REAP_BUDGET);
204            GuardOutcome::Failed
205        };
206        // Test seam: the guard has done its real work; only the reported
207        // outcome is replaced, for the calling thread.
208        #[cfg(any(test, feature = "test-support"))]
209        if let Some(forced) = FORCED_OUTCOME.with(std::cell::Cell::get) {
210            return forced;
211        }
212        outcome
213    }
214}
215
216/// A handle that goes away without [`CallGuard::finish`] — an early return,
217/// an unwinding owner — still lets go of the guard and reaps it, so the group
218/// is ended and no guard process is left behind as a zombie.
219impl Drop for CallGuard {
220    fn drop(&mut self) {
221        if self.hold.take().is_some()
222            && crate::shell::exec::reap_within(&mut self.guard, END_BUDGET + REAP_BUDGET).is_none()
223        {
224            let _ = self.guard.kill();
225            let _ = crate::shell::exec::reap_within(&mut self.guard, REAP_BUDGET);
226        }
227    }
228}
229
230#[cfg(any(test, feature = "test-support"))]
231thread_local! {
232    static FORCED_OUTCOME: std::cell::Cell<Option<GuardOutcome>> =
233        const { std::cell::Cell::new(None) };
234}
235
236/// Tests only: every `finish` on this thread reports `outcome` until it is
237/// reset with `None`.
238#[cfg(any(test, feature = "test-support"))]
239pub fn force_outcome_for_test(outcome: Option<GuardOutcome>) {
240    FORCED_OUTCOME.with(|forced| forced.set(outcome));
241}
242
243/// Whether the ready line arrives within `budget`.
244fn ready_within(mut output: ChildStdout, budget: Duration) -> bool {
245    const MAX_PREAMBLE: usize = 8192;
246    let (sender, receiver) = std::sync::mpsc::channel();
247    std::thread::spawn(move || {
248        let mut seen = Vec::new();
249        let mut chunk = [0_u8; 256];
250        let found = loop {
251            match output.read(&mut chunk) {
252                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
253                Ok(0) | Err(_) => break false,
254                Ok(read) => {
255                    seen.extend_from_slice(&chunk[..read]);
256                    if seen.windows(READY.len()).any(|window| window == READY) {
257                        break true;
258                    }
259                    if seen.len() > MAX_PREAMBLE {
260                        break false;
261                    }
262                }
263            }
264        };
265        let _ = sender.send(found);
266    });
267    receiver.recv_timeout(budget).unwrap_or(false)
268}