Skip to main content

outl_exec/
sandbox.rs

1//! Cross-platform sandbox helpers shared by every runtime.
2//!
3//! For now this is just a timeout primitive — `with_timeout` spawns the
4//! work on a worker thread and gives the caller back an `ExecError::Timeout`
5//! if the channel doesn't deliver in time. The worker thread is *not*
6//! joined: if it overruns, it keeps running until it finishes or the
7//! process exits. That's a known leak for runtimes without cooperative
8//! cancellation (the toy Lisp). The wasmtime backends coming in M2
9//! cancel cooperatively via [`wasmtime::Engine::increment_epoch`], so
10//! they'll route through the same helper without the leak.
11
12use std::sync::mpsc;
13use std::thread;
14use std::time::Duration;
15
16use crate::runtime::ExecError;
17
18/// Run `work` on a worker thread; return its result or
19/// `Err(ExecError::Timeout)` if it doesn't finish within `timeout`.
20///
21/// `work` must be `'static + Send` so it can move to the worker. Pass
22/// owned data in (clone if needed) — borrowing across the boundary
23/// would force `'static` lifetimes everywhere upstream.
24pub fn with_timeout<F, T>(timeout: Duration, work: F) -> Result<T, ExecError>
25where
26    F: FnOnce() -> Result<T, ExecError> + Send + 'static,
27    T: Send + 'static,
28{
29    let (tx, rx) = mpsc::sync_channel::<Result<T, ExecError>>(1);
30    thread::Builder::new()
31        .name("outl-exec".into())
32        .spawn(move || {
33            // If the receiver has been dropped (timeout fired), this
34            // send fails silently — that's fine, we're just throwing
35            // the result away.
36            let _ = tx.send(work());
37        })
38        .map_err(|e| ExecError::Sandbox(format!("spawn worker: {e}")))?;
39
40    match rx.recv_timeout(timeout) {
41        Ok(result) => result,
42        Err(mpsc::RecvTimeoutError::Timeout) => Err(ExecError::Timeout(timeout)),
43        Err(mpsc::RecvTimeoutError::Disconnected) => Err(ExecError::Sandbox(
44            "worker thread vanished without a result".into(),
45        )),
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn fast_work_returns_value() {
55        let v: Result<i32, ExecError> = with_timeout(Duration::from_secs(2), || Ok(42));
56        assert!(matches!(v, Ok(42)));
57    }
58
59    #[test]
60    fn slow_work_times_out() {
61        let v: Result<(), ExecError> = with_timeout(Duration::from_millis(50), || {
62            std::thread::sleep(Duration::from_millis(500));
63            Ok(())
64        });
65        assert!(matches!(v, Err(ExecError::Timeout(_))));
66    }
67
68    #[test]
69    fn inner_error_propagates() {
70        let v: Result<(), ExecError> = with_timeout(Duration::from_secs(2), || {
71            Err(ExecError::Language("oops".into()))
72        });
73        match v {
74            Err(ExecError::Language(m)) => assert_eq!(m, "oops"),
75            other => panic!("expected Language error, got {other:?}"),
76        }
77    }
78}