Skip to main content

monty_pool/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod checkout;
4mod pool;
5#[cfg(feature = "telemetry")]
6pub mod telemetry;
7/// Compatibility alias for the former public module name.
8#[cfg(feature = "telemetry")]
9pub use telemetry as telemetry_adapter;
10mod worker;
11
12use std::{borrow::Cow, error, fmt, io, num::NonZero, path::PathBuf, process::ExitStatus, thread, time::Duration};
13
14pub use monty_proto::DEFAULT_PRINT_FLUSH_INTERVAL;
15use monty_types::MontyException;
16
17#[cfg(feature = "telemetry")]
18use crate::telemetry::Metrics;
19pub use crate::{
20    checkout::{
21        Checkout, CheckoutOptions, MountSpec, MountSpecMode, OnPrint, OnRawEvent, Persistence, PrintFuture, ReplConfig,
22        ResumeValue, TurnEvent, on_print_sync,
23    },
24    pool::Pool,
25};
26
27/// How the pool reaches its workers.
28#[derive(Debug, Clone)]
29pub enum MontyTransport {
30    /// Spawn a local `monty subprocess` child and talk to it over framed
31    /// stdio pipes. Takes path to the `monty` (or compatible child) binary.
32    Subprocess(PathBuf),
33    /// Connect *out* to a remote child over a WebSocket — either a relay (which
34    /// pairs this connection with a child that dialed in with the same session
35    /// id) or a child running a server. One binary message per protocol frame.
36    ///
37    /// The URL is dialed verbatim — if a relay needs the two ends to share a
38    /// session id in the path (`/<uuid>/parent`), the caller is responsible for
39    /// putting it there. Takes full `ws://`/`wss://` URL to dial.
40    Websocket(String),
41}
42
43impl MontyTransport {
44    /// Whether this is the remote WebSocket transport, whose workers are
45    /// single-use (dialed per checkout, never pooled idle or reused).
46    pub(crate) fn is_websocket(&self) -> bool {
47        matches!(self, Self::Websocket(_))
48    }
49}
50
51/// Default grace on each of the two duration backstops: how long the parent
52/// waits past a sandbox time limit for the worker to raise `TimeoutError`
53/// itself before killing it.
54pub const DEFAULT_DURATION_LIMIT_GRACE: Duration = Duration::from_secs(1);
55
56/// Configuration for a [`Pool`].
57#[derive(Debug, Clone)]
58pub struct PoolConfig {
59    /// Workers spawned eagerly at pool creation and kept warm. Forced to 0 for
60    /// the [`MontyTransport::Websocket`] transport (connections are made
61    /// per-checkout, not pre-warmed).
62    pub min_processes: usize,
63    /// Hard cap on live workers; checkouts beyond this wait.
64    pub max_processes: usize,
65    /// How workers are reached (spawned locally or connected to remotely).
66    pub transport: MontyTransport,
67    /// How long [`Pool::checkout`] waits for a free worker before
68    /// [`PoolError::Exhausted`]. `None` waits forever.
69    pub checkout_timeout: Option<Duration>,
70    /// Parent-side hard deadline per protocol turn: when it expires the
71    /// worker is killed and the call fails with [`PoolError::Timeout`]. This
72    /// backstops the child-side `ResourceLimits` — it also catches a child
73    /// that hangs in ways the sandbox limits cannot see. Synchronous host
74    /// telemetry callbacks prevent the timer from being polled while they run.
75    pub request_timeout: Option<Duration>,
76    /// Grace period for the automatic `ResourceLimits::max_feed_duration`
77    /// backstop.
78    ///
79    /// When a session has a feed budget, the worker reports the running feed's
80    /// execution time on every turn-ending event (the sandbox clock is the
81    /// single source of truth: it runs only while the interpreter executes,
82    /// never during suspensions waiting on the host or between feeds), and the
83    /// parent bounds each turn by what that budget has left plus this grace.
84    ///
85    /// The grace is the window in which the sandbox may raise `TimeoutError`
86    /// itself and keep the session alive; a worker that misses it is killed and
87    /// the call fails with [`PoolError::Timeout`], so too short a grace costs
88    /// workers that would have recovered.
89    pub feed_duration_limit_grace: Option<Duration>,
90    /// [`feed_duration_limit_grace`](Self::feed_duration_limit_grace) for the
91    /// `ResourceLimits::max_turn_duration` backstop: each turn is bounded by
92    /// that whole limit plus this, the turn clock starting at zero.
93    pub turn_duration_limit_grace: Option<Duration>,
94    /// Recycle (kill and respawn) a worker after this many checkouts, to
95    /// bound the impact of any slow leak in a long-lived child.
96    pub max_checkouts_per_worker: Option<u32>,
97    /// Resume a session transparently when a relay that stores sessions answers
98    /// a request with `Shutdown`: the checkout redials, reloads the state the
99    /// shutdown named and re-sends the request the relay reported it did not run. The session's suspension
100    /// and sleep totals carry over. WebSocket transport only; on by default.
101    pub auto_resume: bool,
102    /// Where pool and turn metrics are recorded, from
103    /// [`TelemetryAdapterHandle::metrics`](telemetry::TelemetryAdapterHandle::metrics).
104    /// `None` records nothing at all. Independent of tracing: metrics cover
105    /// every checkout, traced or not. Worker up/down counters total over all
106    /// pools that record into the same host meter.
107    #[cfg(feature = "telemetry")]
108    pub metrics: Option<Metrics>,
109}
110
111impl PoolConfig {
112    /// Creates a subprocess-transport config with defaults: `min_processes = 1`,
113    /// `max_processes =` available parallelism, no timeouts, a 1s grace on
114    /// each of the two duration backstops, no recycling.
115    pub fn subprocess(binary_path: impl Into<PathBuf>) -> Self {
116        Self::with_transport(MontyTransport::Subprocess(binary_path.into()))
117    }
118
119    /// Creates a WebSocket-transport config dialing `url` verbatim per checkout.
120    /// `min_processes` is 0 (no pre-warming — connections are made per-checkout).
121    pub fn websocket(url: impl Into<String>) -> Self {
122        let mut config = Self::with_transport(MontyTransport::Websocket(url.into()));
123        config.min_processes = 0;
124        config
125    }
126
127    /// Shared constructor for both transports.
128    fn with_transport(transport: MontyTransport) -> Self {
129        Self {
130            min_processes: 1,
131            max_processes: thread::available_parallelism().map_or(4, NonZero::get),
132            transport,
133            checkout_timeout: None,
134            request_timeout: None,
135            feed_duration_limit_grace: Some(DEFAULT_DURATION_LIMIT_GRACE),
136            turn_duration_limit_grace: Some(DEFAULT_DURATION_LIMIT_GRACE),
137            max_checkouts_per_worker: None,
138            auto_resume: true,
139            #[cfg(feature = "telemetry")]
140            metrics: None,
141        }
142    }
143}
144
145/// Why a pool operation failed.
146#[derive(Debug)]
147pub enum PoolError {
148    /// The worker is gone: it died (segfault, abort, external kill, or EOF on
149    /// its pipes), or it announced a `FatalError` and exited — which a serving
150    /// relay also uses to report that it could not start a worker at all. The
151    /// worker has been discarded; the pool stays usable.
152    Crashed {
153        /// Exit status, when the process could be reaped.
154        status: Option<ExitStatus>,
155        /// How the pool learned of the death, and what it can say about it.
156        cause: CrashCause,
157    },
158    /// The worker was killed after its turn outlived `request_timeout` (or
159    /// one of the feed/turn duration backstop deadlines).
160    Timeout {
161        /// The configured timeout that expired.
162        timeout: Duration,
163    },
164    /// The worker violated the wire protocol, or the caller violated the
165    /// checkout state machine. Worker-originated protocol failures discard the
166    /// worker; caller misuse leaves it intact — except a turn cancelled
167    /// mid-flight, where the abandoned worker is discarded too.
168    Protocol(Cow<'static, str>),
169    /// The sandboxed code raised a Python exception. The worker and its
170    /// session remain alive and usable — except for the one `MemoryError` a
171    /// worker exceeding its memory limit produces, where the worker is already
172    /// dead and the checkout finished (its distinct message distinguishes it
173    /// from the interpreter's own `MemoryError`).
174    Runtime(MontyException),
175    /// Type checking rejected the fed snippet (sessions created with
176    /// `type_check`). The worker and session remain alive; the snippet did
177    /// not run.
178    Typing(String),
179    /// No worker became available within `checkout_timeout`.
180    Exhausted,
181    /// A worker process could not be spawned.
182    Spawn(String),
183    /// The checkout was already finished or its worker already discarded.
184    Finished,
185    /// The remote worker's connection dropped without a turn-ending event
186    /// (WebSocket transport only — the local analogue is [`Self::Crashed`]).
187    /// The sandbox may have died, or the server may have dropped the session
188    /// by policy (idle/session/turn timeout, capacity); the two are
189    /// indistinguishable to a client that only learns of the close, so this
190    /// deliberately says no more than "the connection went away".
191    Disconnected {
192        /// What the pool was doing when the disconnect was observed.
193        context: String,
194    },
195    /// The remote server is shutting down and did **not** run the request —
196    /// re-running it on a fresh session is safe. `dump` restores the session
197    /// via [`Checkout::restore`] on a fresh checkout: the session's ID from a
198    /// relay that stores sessions, returned only when
199    /// [`PoolConfig::auto_resume`] could not resume it.
200    Shutdown {
201        /// What restores the session; absent when there is nothing to load —
202        /// no session yet, an ephemeral one, a relay without storage, or a
203        /// park that failed.
204        dump: Option<Vec<u8>>,
205    },
206}
207
208/// How the pool learned a worker had died — the two are mutually exclusive,
209/// which is why they are one field rather than two `Option`s: a worker that
210/// states its own reason makes the pool's note of what it was doing
211/// redundant, and the message uses one or the other, never both.
212///
213/// Orthogonal to [`PoolError::Crashed`]'s `status`, which is present or not
214/// in either case (a worker can announce a reason *and* be reaped for its
215/// exit code — the usual shape of a version-skew death).
216#[derive(Debug)]
217pub enum CrashCause {
218    /// It vanished — segfault, abort, external kill, EOF on its pipes — so
219    /// all the pool can say is what it was doing at the time.
220    Vanished {
221        /// What the pool was doing when the death was observed.
222        context: String,
223    },
224    /// It announced a `FatalError` and exited, so its own account replaces
225    /// the pool's. A serving relay also uses this to report that it could not
226    /// start a worker at all. A worker that exceeded its memory limit is not
227    /// here at all: that exit code classifies into
228    /// [`PoolError::Runtime`] carrying a `MemoryError`.
229    Announced {
230        /// What the worker said before dying.
231        reason: String,
232    },
233}
234
235impl fmt::Display for PoolError {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        match self {
238            Self::Crashed { status, cause } => {
239                match cause {
240                    CrashCause::Announced { reason } => write!(f, "monty worker crashed: {reason}")?,
241                    CrashCause::Vanished { context } => write!(f, "monty worker crashed while {context}")?,
242                }
243                match status {
244                    Some(status) => write!(f, " ({status})"),
245                    None => Ok(()),
246                }
247            }
248            Self::Timeout { timeout } => {
249                write!(f, "monty worker killed after exceeding request timeout of {timeout:?}")
250            }
251            Self::Protocol(msg) => write!(f, "monty worker protocol error: {msg}"),
252            Self::Runtime(exc) => write!(f, "{exc}"),
253            Self::Typing(diagnostics) => write!(f, "type checking failed:\n{diagnostics}"),
254            Self::Exhausted => f.write_str("no monty worker became available within the checkout timeout"),
255            Self::Spawn(msg) => write!(f, "failed to spawn monty worker: {msg}"),
256            Self::Finished => f.write_str("this checkout has already been finished"),
257            Self::Disconnected { context } => write!(f, "monty worker connection closed while {context}"),
258            Self::Shutdown { dump } => match dump {
259                Some(_) => {
260                    f.write_str("monty server is shutting down; the request did not run (restorable state attached)")
261                }
262                None => f.write_str("monty server is shutting down; the request did not run"),
263            },
264        }
265    }
266}
267
268impl error::Error for PoolError {
269    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
270        match self {
271            Self::Runtime(exc) => Some(exc),
272            _ => None,
273        }
274    }
275}
276
277impl From<io::Error> for PoolError {
278    fn from(err: io::Error) -> Self {
279        Self::Crashed {
280            status: None,
281            cause: CrashCause::Vanished {
282                context: format!("performing I/O: {err}"),
283            },
284        }
285    }
286}