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}