Skip to main content

lean_ctx/shell/containment/
spawner.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The Engine's side of the spawn helper (see `spawn_helper`): one helper per
3//! Engine process, started on the first MCP shell call, and the handle on a
4//! shell that the helper started.
5//!
6//! Failure rule: a helper that cannot be started, or that has died, is
7//! started once more for the call at hand. If that fails too, the caller runs
8//! the shell itself, as it did before helpers existed, and is handed a fixed
9//! notice for the result. A call is never refused for want of a helper, and
10//! never runs without one silently.
11
12use std::collections::HashMap;
13use std::io::PipeReader;
14use std::os::fd::{AsRawFd as _, OwnedFd};
15use std::os::unix::net::UnixStream;
16use std::process::{Child, Command, ExitStatus, Stdio};
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18use std::sync::{Arc, Mutex, PoisonError, mpsc};
19use std::time::Duration;
20
21use super::spawn_helper::{HELLO, exit_status};
22use super::wire::{Inbox, Reply, Request, SpawnSpec, Unsent, send_frame};
23
24/// How long a started helper may take to say hello, and a running one to
25/// answer a spawn request.
26const ANSWER_BUDGET: Duration = Duration::from_secs(5);
27
28/// The helper of this process, once one was needed.
29static LINK: Mutex<Option<Arc<Link>>> = Mutex::new(None);
30
31/// The connection to one helper process.
32struct Link {
33    socket: UnixStream,
34    /// One frame at a time.
35    sending: Mutex<()>,
36    /// Where the reader hands each call's replies.
37    calls: Mutex<HashMap<u64, mpsc::Sender<Reply>>>,
38    next_call: AtomicU64,
39    /// The helper is gone, or is not to be used any more.
40    gone: AtomicBool,
41    helper: Mutex<Child>,
42}
43
44/// The command that starts a helper.
45fn helper_command() -> std::io::Result<Command> {
46    #[cfg(any(test, feature = "test-support"))]
47    if super::harness_helper() {
48        return harness_helper_command();
49    }
50    let mut command = Command::new(std::env::current_exe()?);
51    command.arg(super::HELPER_MODE);
52    Ok(command)
53}
54
55/// In the unit tests the running binary is the test harness; its helper test
56/// runs the same helper function.
57#[cfg(any(test, feature = "test-support"))]
58fn harness_helper_command() -> std::io::Result<Command> {
59    if HELPER_BROKEN.load(Ordering::Relaxed) {
60        return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
61    }
62    let mut command = Command::new(std::env::current_exe()?);
63    command
64        .args([
65            super::tests::HELPER_TEST,
66            "--exact",
67            "--ignored",
68            "--nocapture",
69        ])
70        .env(super::tests::HELPER_ENV, "spawn-helper")
71        .env_remove(super::tests::FAULT_ENV);
72    if let Some(fault) = *HELPER_FAULT.lock().unwrap_or_else(PoisonError::into_inner) {
73        command.env(super::tests::FAULT_ENV, fault);
74    }
75    Ok(command)
76}
77
78impl Link {
79    /// Starts a helper and waits for its hello.
80    fn start() -> Option<Arc<Self>> {
81        let (ours, theirs) = UnixStream::pair().ok()?;
82        super::wire::quiet_socket(ours.as_raw_fd());
83        let mut command = helper_command().ok()?;
84        command
85            .stdin(OwnedFd::from(theirs))
86            .stdout(Stdio::null())
87            .stderr(Stdio::null());
88        let helper = command.spawn().ok()?;
89        // The command keeps the helper's end of the socket; with it open the
90        // helper's end of input could not be seen here.
91        drop(command);
92        let reading = ours.try_clone().ok();
93        let link = Arc::new(Self {
94            socket: ours,
95            sending: Mutex::new(()),
96            calls: Mutex::new(HashMap::new()),
97            next_call: AtomicU64::new(1),
98            gone: AtomicBool::new(false),
99            helper: Mutex::new(helper),
100        });
101        let (hello, greeted) = mpsc::channel();
102        let reader = reading.and_then(|reading| {
103            let link = Arc::clone(&link);
104            std::thread::Builder::new()
105                .name("spawn-helper-replies".into())
106                .spawn(move || link.read_replies(&reading, &hello))
107                .ok()
108        });
109        if reader.is_none() || greeted.recv_timeout(ANSWER_BUDGET).is_err() {
110            // Whatever was started is not a working helper: it is ended by
111            // its handle. The reader, if there is one, reaps it.
112            let mut helper = link.helper.lock().unwrap_or_else(PoisonError::into_inner);
113            let _ = helper.kill();
114            if reader.is_none() {
115                let _ = helper.wait();
116            }
117            drop(helper);
118            link.abandon();
119            return None;
120        }
121        Some(link)
122    }
123
124    /// The reader thread: routes replies to their calls until the connection
125    /// ends, then reaps the helper.
126    fn read_replies(&self, socket: &UnixStream, hello: &mpsc::Sender<()>) {
127        let mut inbox = Inbox::default();
128        let mut greeted = false;
129        let mut understood = true;
130        'connection: while matches!(inbox.fill(socket.as_raw_fd()), Ok(true)) {
131            loop {
132                let frame = match inbox.next_frame() {
133                    Ok(Some(frame)) => frame,
134                    Ok(None) => break,
135                    Err(_) => {
136                        understood = false;
137                        break 'connection;
138                    }
139                };
140                if !greeted {
141                    if frame != HELLO {
142                        understood = false;
143                        break 'connection;
144                    }
145                    greeted = true;
146                    let _ = hello.send(());
147                    continue;
148                }
149                let Some(reply) = Reply::decode(&frame) else {
150                    understood = false;
151                    break 'connection;
152                };
153                let (Reply::Spawned { call, .. }
154                | Reply::SpawnFailed { call, .. }
155                | Reply::Exited { call, .. }) = reply;
156                if let Some(sender) = self.lock_calls().get(&call) {
157                    let _ = sender.send(reply);
158                }
159            }
160        }
161        self.gone.store(true, Ordering::SeqCst);
162        // Every waiting call sees its channel close: the helper is gone.
163        self.lock_calls().clear();
164        let mut helper = self.helper.lock().unwrap_or_else(PoisonError::into_inner);
165        if !understood {
166            // Not a helper of this version: ended by its handle.
167            let _ = helper.kill();
168        }
169        // A helper whose connection ended exits by itself, after it has
170        // ended what it still held; it is only reaped here.
171        let _ = helper.wait();
172    }
173
174    fn lock_calls(&self) -> std::sync::MutexGuard<'_, HashMap<u64, mpsc::Sender<Reply>>> {
175        self.calls.lock().unwrap_or_else(PoisonError::into_inner)
176    }
177
178    /// Stops using this helper. Closing the socket is its end of input.
179    fn abandon(&self) {
180        self.gone.store(true, Ordering::SeqCst);
181        let _ = self.socket.shutdown(std::net::Shutdown::Both);
182    }
183
184    fn send(&self, request: &Request, descriptors: &[std::os::fd::RawFd]) -> Result<(), Unsent> {
185        let _one_frame = self.sending.lock().unwrap_or_else(PoisonError::into_inner);
186        #[cfg(any(test, feature = "test-support"))]
187        if matches!(request, Request::Spawn { .. }) && SEND_FAILS_ONCE.swap(false, Ordering::SeqCst)
188        {
189            return Err(Unsent::Nothing);
190        }
191        send_frame(self.socket.as_raw_fd(), &request.encode(), descriptors)
192    }
193}
194
195/// The live helper of this process; started, or started again, when there is
196/// none.
197fn link() -> Option<Arc<Link>> {
198    let mut slot = LINK.lock().unwrap_or_else(PoisonError::into_inner);
199    if let Some(link) = slot.as_ref()
200        && !link.gone.load(Ordering::SeqCst)
201    {
202        return Some(Arc::clone(link));
203    }
204    *slot = Link::start();
205    slot.clone()
206}
207
208/// The Engine ends in order: the helper exits and ends nothing. Without this
209/// message the helper takes the closed socket for the Engine's death.
210pub fn shutdown_spawn_helper() {
211    let link = LINK.lock().unwrap_or_else(PoisonError::into_inner).take();
212    if let Some(link) = link {
213        // Unsent: the helper is gone already and signals nothing either way.
214        let _ = link.send(&Request::Shutdown, &[]);
215        link.gone.store(true, Ordering::SeqCst);
216    }
217}
218
219/// What became of the request to start a shell through the helper.
220pub enum HelperSpawn {
221    /// The helper is the shell's parent.
222    Helped {
223        child: HelpedChild,
224        stdout: PipeReader,
225        stderr: PipeReader,
226    },
227    /// No helper, or a helper that could execute nothing; the command has
228    /// provably not run: the caller starts the shell itself and shows the notice,
229    /// if there is one, in front of the result.
230    Direct(Option<&'static str>),
231}
232
233/// Starts the shell described by `command` — program, arguments, environment
234/// and working directory; its input is null and its outputs are the returned
235/// pipes — as a child of this process's spawn helper.
236pub fn spawn_shell(command: &Command) -> HelperSpawn {
237    if !super::guard_host() {
238        // Not the `lean-ctx` binary: nothing here can be started as a helper.
239        // Same limit, and same single debug line, as `guard_child`.
240        super::note_no_guard_host();
241        return HelperSpawn::Direct(None);
242    }
243    let spec = SpawnSpec {
244        program: command.get_program().to_os_string(),
245        args: command
246            .get_args()
247            .map(std::ffi::OsStr::to_os_string)
248            .collect(),
249        env: effective_environment(command),
250        cwd: command
251            .get_current_dir()
252            .map(|dir| dir.as_os_str().to_os_string()),
253    };
254    let mut request = Request::Spawn { call: 0, spec };
255    // The helper at hand, and — if that one is proven not to have received
256    // the request — one more. Then, or when a helper answered that it could
257    // execute nothing, the caller starts the shell itself: the command has
258    // provably not run yet (see `Link::spawn`).
259    for _ in 0..2 {
260        let Some(link) = link() else {
261            continue;
262        };
263        match link.spawn(&mut request) {
264            Attempt::Done(spawned) => return spawned,
265            Attempt::NotStarted => {}
266            Attempt::Refused => break,
267        }
268    }
269    HelperSpawn::Direct(Some(super::HELPER_NOTICE))
270}
271
272/// The environment the shell gets: this process's, with the command's own
273/// changes applied — what a direct spawn of `command` would pass on.
274fn effective_environment(command: &Command) -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
275    let mut environment: std::collections::BTreeMap<_, _> = std::env::vars_os().collect();
276    for (name, value) in command.get_envs() {
277        match value {
278            Some(value) => environment.insert(name.to_os_string(), value.to_os_string()),
279            None => environment.remove(name),
280        };
281    }
282    environment.into_iter().collect()
283}
284
285enum Attempt {
286    Done(HelperSpawn),
287    /// Proven: the request did not reach a helper. Nothing runs; another
288    /// helper may be tried.
289    NotStarted,
290    /// Proven: the helper tried and executed nothing. Nothing runs; the same
291    /// request would fail the same way with another helper.
292    Refused,
293}
294
295impl Link {
296    fn spawn(self: &Arc<Self>, request: &mut Request) -> Attempt {
297        let (Ok((stdout, stdout_end)), Ok((stderr, stderr_end))) =
298            (std::io::pipe(), std::io::pipe())
299        else {
300            return Attempt::NotStarted;
301        };
302        let call = self.next_call.fetch_add(1, Ordering::Relaxed);
303        if let Request::Spawn { call: slot, .. } = request {
304            *slot = call;
305        }
306        let (sender, replies) = mpsc::channel();
307        self.lock_calls().insert(call, sender);
308        let mut child = HelpedChild {
309            call,
310            pid: 0,
311            link: Arc::clone(self),
312            replies,
313            status: None,
314            settled: false,
315            lost: false,
316            unclear: false,
317        };
318        // THE DECISION whether a command may be started a second time.
319        //
320        // The helper starts the shell before it answers, and a command can
321        // do all it does and end without writing a byte. So once a request
322        // may have reached the helper, nothing seen from here — no answer,
323        // empty pipes at their end, a dead helper — proves that the command
324        // did not run. Exactly three things are proof that it did NOT:
325        //
326        // 1. the helper was already known to be gone before anything was
327        //    sent (`gone`: its end of the socket had closed);
328        // 2. the send failed before one byte of the frame left this process
329        //    (`Unsent::Nothing`) — a partial send is not proof;
330        // 3. the helper answered `SpawnFailed`: its `spawn` returned an
331        //    error, which it does only when no program was executed.
332        //
333        // Everything else is an unclear outcome: the command is not started
334        // again, here or by the caller, and the call says so.
335        let sent = if self.gone.load(Ordering::SeqCst) {
336            Err(Unsent::Nothing)
337        } else {
338            self.send(request, &[stdout_end.as_raw_fd(), stderr_end.as_raw_fd()])
339        };
340        let reply = match sent {
341            Ok(()) => child.replies.recv_timeout(ANSWER_BUDGET).ok(),
342            Err(Unsent::Nothing) => {
343                child.settled = true;
344                self.abandon();
345                return Attempt::NotStarted;
346            }
347            Err(Unsent::Part) => None,
348        };
349        // The shell has its descriptors, or nobody will: ours are closed
350        // either way, so the readers see the end of the shell's output.
351        drop((stdout_end, stderr_end));
352        match reply {
353            Some(Reply::Spawned { pid, .. }) => child.pid = pid,
354            Some(Reply::SpawnFailed { .. }) => {
355                child.settled = true;
356                return Attempt::Refused;
357            }
358            Some(Reply::Exited { .. }) | None => {
359                // The helper died, hangs or got half a request. It is not
360                // used again; the next call starts a new one.
361                self.abandon();
362                child.settled = true;
363                child.unclear = true;
364            }
365        }
366        Attempt::Done(HelperSpawn::Helped {
367            child,
368            stdout,
369            stderr,
370        })
371    }
372}
373
374/// A shell whose parent is the spawn helper. What the Engine does with a
375/// child process, it does with this through the helper: learn its exit, end
376/// its group, let go of it.
377pub struct HelpedChild {
378    call: u64,
379    pid: u32,
380    link: Arc<Link>,
381    replies: mpsc::Receiver<Reply>,
382    status: Option<ExitStatus>,
383    /// Nothing is left to ask of the helper for this call.
384    settled: bool,
385    /// The helper went away before it reported the shell's exit.
386    lost: bool,
387    /// The helper never said whether it started the shell.
388    unclear: bool,
389}
390
391impl HelpedChild {
392    /// The shell's process id, which is also the id of its session and group.
393    pub(crate) fn id(&self) -> u32 {
394        self.pid
395    }
396
397    /// The fixed notice for a call whose helper went away: before it said
398    /// whether the command was started at all (it may or may not have run and
399    /// was not repeated), or before it reported the shell's exit (the shell
400    /// has no parent that answers for it any more). `None` otherwise.
401    pub(crate) fn notice(&self) -> Option<&'static str> {
402        if self.unclear {
403            Some(super::UNCLEAR_NOTICE)
404        } else {
405            self.lost.then_some(super::END_NOTICE)
406        }
407    }
408
409    /// Takes one answer of the helper; returns the exit status once known.
410    fn note_reply(&mut self, reply: &Reply) -> Option<ExitStatus> {
411        if let Reply::Exited { status, .. } = reply {
412            self.status = Some(exit_status(*status));
413        }
414        self.status
415    }
416
417    /// The helper is gone and has not said how the shell ended.
418    fn note_gone(&mut self) -> std::io::Error {
419        self.lost = true;
420        self.settled = true;
421        std::io::Error::other("the spawn helper is gone")
422    }
423
424    /// The exit status, if the shell has exited. `Err`: nobody can say.
425    pub(crate) fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
426        if self.status.is_some() {
427            return Ok(self.status);
428        }
429        if self.lost || self.unclear {
430            return Err(self.note_gone());
431        }
432        match self.replies.try_recv() {
433            Ok(reply) => Ok(self.note_reply(&reply)),
434            Err(mpsc::TryRecvError::Empty) => Ok(None),
435            Err(mpsc::TryRecvError::Disconnected) => Err(self.note_gone()),
436        }
437    }
438
439    /// Ends the shell's whole group: the helper signals it while the leader
440    /// is its unreaped child, and reaps the leader.
441    pub(crate) fn kill(&mut self) {
442        if !self.settled {
443            self.settled = true;
444            // Unsent: the helper is gone, nobody ends the group, and the
445            // `wait` that follows reports the call as lost. Nothing is run
446            // again on any outcome of this message.
447            let _ = self.link.send(&Request::Kill { call: self.call }, &[]);
448        }
449    }
450
451    /// Waits until the helper has reported the shell's exit, or is gone.
452    pub(crate) fn wait(&mut self) {
453        while self.status.is_none() && !self.lost {
454            match self.replies.recv() {
455                Ok(reply) => {
456                    self.note_reply(&reply);
457                }
458                Err(_) => {
459                    let _ = self.note_gone();
460                }
461            }
462        }
463    }
464
465    /// Lets go of the call: the helper reaps the leader and never signals
466    /// the group again. What the command left running stays.
467    ///
468    /// `false`: the release did not leave this process. The helper's end of
469    /// the socket is then closed — the helper is gone or going — and a helper
470    /// that sees the socket closed ends what it still holds, which includes
471    /// this call's group if it had not read a release for it. The caller
472    /// says so in the call's result ([`super::RELEASE_NOTICE`]).
473    pub(crate) fn release(mut self) -> bool {
474        if self.settled {
475            return true;
476        }
477        self.settled = true;
478        #[cfg(any(test, feature = "test-support"))]
479        if RELEASE_FAILS_ONCE.swap(false, Ordering::SeqCst) {
480            return false;
481        }
482        let sent = self
483            .link
484            .send(&Request::Release { call: self.call }, &[])
485            .is_ok();
486        if !sent {
487            tracing::warn!(
488                "spawn helper: a release could not be sent; what the command left running may be ended"
489            );
490        }
491        sent
492    }
493}
494
495/// A handle that goes away without a release — an unwinding owner — ends the
496/// shell's group instead of leaving it to run unowned.
497impl Drop for HelpedChild {
498    fn drop(&mut self) {
499        self.kill();
500        self.link.lock_calls().remove(&self.call);
501    }
502}
503
504#[cfg(any(test, feature = "test-support"))]
505static SEND_FAILS_ONCE: AtomicBool = AtomicBool::new(false);
506
507/// Tests only: the next spawn request fails before one byte of it is sent.
508#[cfg(any(test, feature = "test-support"))]
509pub fn fail_next_spawn_send_for_test() {
510    SEND_FAILS_ONCE.store(true, Ordering::SeqCst);
511}
512
513#[cfg(any(test, feature = "test-support"))]
514static RELEASE_FAILS_ONCE: AtomicBool = AtomicBool::new(false);
515
516/// Tests only: the next release is not sent, as with a closed socket.
517#[cfg(any(test, feature = "test-support"))]
518pub fn fail_next_release_send_for_test() {
519    RELEASE_FAILS_ONCE.store(true, Ordering::SeqCst);
520}
521
522#[cfg(any(test, feature = "test-support"))]
523static HELPER_FAULT: Mutex<Option<&'static str>> = Mutex::new(None);
524
525/// Tests only: the fault every helper started from now on runs with (see
526/// `spawn_helper::start_with_fault`); `None` for a sound helper.
527#[cfg(any(test, feature = "test-support"))]
528pub fn set_helper_fault_for_test(fault: Option<&'static str>) {
529    *HELPER_FAULT.lock().unwrap_or_else(PoisonError::into_inner) = fault;
530}
531
532#[cfg(any(test, feature = "test-support"))]
533static HELPER_BROKEN: AtomicBool = AtomicBool::new(false);
534
535/// Tests only: while set, no helper can be started in this process.
536#[cfg(any(test, feature = "test-support"))]
537pub fn break_helper_for_test(broken: bool) {
538    HELPER_BROKEN.store(broken, Ordering::Relaxed);
539}
540
541/// Tests only: the process id of this process's live helper, if any.
542#[cfg(any(test, feature = "test-support"))]
543pub fn helper_pid_for_test() -> Option<u32> {
544    let slot = LINK.lock().unwrap_or_else(PoisonError::into_inner);
545    let link = slot
546        .as_ref()
547        .filter(|link| !link.gone.load(Ordering::SeqCst))?;
548    Some(
549        link.helper
550            .lock()
551            .unwrap_or_else(PoisonError::into_inner)
552            .id(),
553    )
554}
555
556/// Tests only: ends this process's helper by its handle, as a crash would,
557/// and waits until its end was seen.
558#[cfg(any(test, feature = "test-support"))]
559pub fn kill_helper_for_test() {
560    let link = LINK
561        .lock()
562        .unwrap_or_else(PoisonError::into_inner)
563        .clone()
564        .expect("a helper runs");
565    let _ = link
566        .helper
567        .lock()
568        .unwrap_or_else(PoisonError::into_inner)
569        .kill();
570    let deadline = std::time::Instant::now() + Duration::from_secs(5);
571    while !link.gone.load(Ordering::SeqCst) {
572        assert!(
573            std::time::Instant::now() < deadline,
574            "the helper's end is seen"
575        );
576        std::thread::sleep(Duration::from_millis(5));
577    }
578}