monty_pool/checkout.rs
1//! A checked-out worker: one REPL session, driven turn by turn.
2
3#[cfg(feature = "telemetry")]
4use std::time::Instant;
5use std::{
6 borrow::Cow,
7 fmt,
8 future::{Future, ready},
9 mem,
10 path::Path,
11 pin::Pin,
12 process::ExitStatus,
13 sync::Arc,
14 time::Duration,
15};
16
17use monty_fs::{MountCallOutcome, MountMode, MountRoot, MountTable, OverlayState};
18use monty_proto::{
19 FrameError, PROTOCOL_VERSION, ext_result_to_proto, future_results_to_proto, named_values_to_proto,
20 os_call_from_proto, pb, validate_requirement,
21};
22use monty_types::{
23 AssertMessageAnnotations, CallArgs, DEFAULT_MAX_SUSPENSIONS, ExcType, ExtFunctionResult, IMPORT_STUBS_CALL_ID,
24 MONTY_VERSION, MontyException, MontyObject, MontyUuid, NameLookupResult, NamedValues, OsFunctionCall, OsPolicy,
25 PrintStream, ResourceLimits, SleepMode, SourceRange, TypeCheckingConfig, validate_cwd,
26};
27#[cfg(feature = "telemetry")]
28use opentelemetry::trace::{FutureExt, TraceContextExt};
29use tokio::{task::spawn_blocking, time::timeout};
30
31#[cfg(feature = "telemetry")]
32use crate::telemetry::{TelemetryContext, metrics::outcome};
33use crate::{
34 CrashCause, PoolConfig, PoolError,
35 pool::{CapacityGuard, PoolInner},
36 worker::Worker,
37};
38
39/// Arguments for the REPL session a checkout creates — mirrors
40/// `MontyRepl`'s constructor surface.
41#[derive(Debug, Clone)]
42pub struct ReplConfig {
43 /// Script name used in tracebacks and type-check diagnostics, and the
44 /// basis of the sandbox's `__file__`: its final path component placed
45 /// under the working directory a feed starts in (`/main.py` at the root).
46 pub script_name: String,
47 /// Sandbox resource limits enforced inside the worker. `None` means
48 /// unlimited (except monty's standard recursion-depth default).
49 pub limits: Option<ResourceLimits>,
50 /// Type-check every fed snippet before executing it.
51 pub type_check: bool,
52 /// Stub declarations made available to type checking.
53 pub type_check_stubs: Option<String>,
54 /// How the worker renders typing diagnostics. Chosen here rather than on
55 /// the raised error because the structured diagnostics never leave the
56 /// worker — only the rendered text crosses the wire.
57 pub type_check_config: TypeCheckingConfig,
58 /// Give failed `assert` statements pytest-style introspected messages
59 /// (see `limitations/assert.md`). On by default with a 120-byte
60 /// operand-repr truncation; `MaxBytes` customizes the truncation.
61 pub assert_message_annotations: AssertMessageAnnotations,
62 /// How long the worker may hold buffered `print()` output before sending
63 /// it, batching a burst of prints into one `Print` event instead of one
64 /// each. `None` takes the worker's default
65 /// ([`crate::DEFAULT_PRINT_FLUSH_INTERVAL`]); `Duration::ZERO` restores line
66 /// buffering, delivering each completed line on its own.
67 ///
68 /// Output is always flushed before a suspension or a turn ends, so this
69 /// only sets how long live output may lag — never what arrives, or in
70 /// what order. The wire carries whole milliseconds, so a positive interval
71 /// below 1 ms is sent as 1 ms rather than rounding down into the
72 /// line-buffering sentinel.
73 pub print_flush_interval: Option<Duration>,
74 /// Session clock, initial random state and sleep policy; defaults use the worker's clock and entropy.
75 /// `CallHost` delegates to the caller's OS handler through [`TurnEvent::OsCall`].
76 /// `System` sleeps set `system_sleep` for the caller to await directly; `Zero` sleeps return immediately.
77 pub os_policy: OsPolicy,
78 /// Whether a serving relay may store the session; subprocess workers ignore it.
79 pub persistence: Persistence,
80 /// The serving relay's profile to run the session under; `None` takes the
81 /// relay's default. Subprocess workers ignore it.
82 pub profile: Option<String>,
83}
84
85/// How a serving relay (`monty-server`) treats a session's state.
86///
87/// A storing relay mints a session ID ([`Checkout::session_id`]) that resumes the
88/// session from any process. Local subprocess workers never store anything.
89// non_exhaustive: a relay may grow new storage policies
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum Persistence {
93 /// Whatever the relay does by default.
94 #[default]
95 ServerDefault,
96 /// Never stored by the relay on its own: no session ID and never parked.
97 Ephemeral,
98 /// Parked on idle, drain or disconnect, and resumable by its session ID.
99 Stored,
100}
101
102impl From<Persistence> for pb::Persistence {
103 fn from(persistence: Persistence) -> Self {
104 match persistence {
105 Persistence::ServerDefault => Self::Unspecified,
106 Persistence::Ephemeral => Self::Ephemeral,
107 Persistence::Stored => Self::Stored,
108 }
109 }
110}
111
112impl Default for ReplConfig {
113 fn default() -> Self {
114 Self {
115 script_name: "main.py".to_owned(),
116 limits: None,
117 type_check: false,
118 type_check_stubs: None,
119 type_check_config: TypeCheckingConfig::default(),
120 assert_message_annotations: AssertMessageAnnotations::default(),
121 print_flush_interval: None,
122 os_policy: OsPolicy::default(),
123 persistence: Persistence::default(),
124 profile: None,
125 }
126 }
127}
128
129/// Host-side context for one checkout, as opposed to the [`ReplConfig`] the
130/// worker is sent.
131// non_exhaustive: options may be added without breaking callers
132#[derive(Default)]
133#[non_exhaustive]
134pub struct CheckoutOptions {
135 /// Distributed trace context captured by a host adapter. Its span also
136 /// goes out as `traceparent`/`tracestate` on a WebSocket dial, so a remote
137 /// worker's own spans join the host's trace.
138 #[cfg(feature = "telemetry")]
139 pub telemetry: Option<TelemetryContext>,
140 /// Extra headers for this checkout's WebSocket upgrade request; the
141 /// subprocess transport makes no request and ignores them. Duplicate
142 /// names are last-wins, even against the default `user-agent`
143 /// (`monty-pool/<version>`), the `traceparent` from `telemetry`, `host`
144 /// and the other handshake headers, and a malformed name or value fails
145 /// the dial.
146 pub connect_headers: Vec<(String, String)>,
147}
148
149impl CheckoutOptions {
150 /// Sets the distributed trace context, if the host captured one.
151 #[cfg(feature = "telemetry")]
152 #[must_use]
153 pub fn with_telemetry(mut self, telemetry: Option<TelemetryContext>) -> Self {
154 self.telemetry = telemetry;
155 self
156 }
157
158 /// Sets the headers for this checkout's WebSocket upgrade request.
159 #[must_use]
160 pub fn with_connect_headers(mut self, connect_headers: Vec<(String, String)>) -> Self {
161 self.connect_headers = connect_headers;
162 self
163 }
164}
165
166/// Names each connect header, never its value: monty never interprets the
167/// values, but a caller may send e.g. a token for a proxy/relay in front of
168/// the worker, and options get logged.
169impl fmt::Debug for CheckoutOptions {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 let header_names: Vec<&str> = self.connect_headers.iter().map(|(name, _)| name.as_str()).collect();
172 f.debug_struct("CheckoutOptions")
173 .field("connect_headers", &header_names)
174 .finish_non_exhaustive()
175 }
176}
177
178/// A host directory mounted into the sandbox for one feed. Mounts are handled
179/// entirely on the parent: the checkout services covered filesystem OS calls
180/// from the host path itself (so mounts work even when the worker runs on a
181/// remote machine). Every OS call still surfaces as a [`TurnEvent::OsCall`];
182/// mounts are consulted only when the caller asks, via
183/// [`Checkout::resume_from_mounts`].
184#[derive(Debug, Clone)]
185pub struct MountSpec {
186 /// The host directory, opened when this spec was built and shared by every
187 /// feed that reuses it. The path is never resolved again, so sandbox code
188 /// cannot make it name a different directory between feeds.
189 root: MountRoot,
190 /// Access mode.
191 pub mode: MountSpecMode,
192 /// Cap on total bytes written through this mount.
193 pub write_bytes_limit: Option<u64>,
194 /// Aggregate budget for retained overlay data and transient results.
195 pub memory_usage_limit: u64,
196}
197
198impl MountSpec {
199 /// Opens `host_path` and creates mount configuration with the default
200 /// memory budget ([`monty_fs::DEFAULT_MEMORY_USAGE_LIMIT`]) and no cumulative write limit.
201 ///
202 /// Build this once and reuse it; each call resolves the path afresh. The
203 /// open is blocking filesystem I/O, so an async caller opening a directory
204 /// that may stall (NFS, FUSE) should either build the spec before entering
205 /// the runtime or open the [`MountRoot`] under `spawn_blocking` and pass it
206 /// to [`Self::from_root`]. Feeds never reopen it, so this cost is paid once
207 /// per mount rather than once per feed.
208 ///
209 /// # Errors
210 ///
211 /// Returns [`PoolError::Runtime`] if the virtual path is not absolute, or
212 /// the host path cannot be opened as a directory.
213 pub fn new(virtual_path: &str, host_path: impl AsRef<Path>, mode: MountSpecMode) -> Result<Self, PoolError> {
214 let root = MountRoot::open(virtual_path, host_path).map_err(|err| PoolError::Runtime(err.into_exception()))?;
215 Ok(Self::from_root(root, mode))
216 }
217
218 /// Creates mount configuration from an already-opened [`MountRoot`], for
219 /// hosts that open it themselves to map failures their own way.
220 #[must_use]
221 pub fn from_root(root: MountRoot, mode: MountSpecMode) -> Self {
222 Self {
223 root,
224 mode,
225 write_bytes_limit: None,
226 memory_usage_limit: monty_fs::DEFAULT_MEMORY_USAGE_LIMIT,
227 }
228 }
229
230 /// Returns the normalized virtual path this mount answers on.
231 #[must_use]
232 pub fn virtual_path(&self) -> &str {
233 self.root.virtual_path()
234 }
235
236 /// Returns the host directory path. Diagnostics only — operations run
237 /// against the descriptor, not this path.
238 #[must_use]
239 pub fn host_path(&self) -> &Path {
240 self.root.host_path()
241 }
242}
243
244/// Access mode for a [`MountSpec`].
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum MountSpecMode {
247 /// Reads only; writes raise `PermissionError` in the sandbox.
248 ReadOnly,
249 /// Files written by sandboxed code persist on the host and are untrusted;
250 /// the host must not execute them, including indirectly via a Python
251 /// `import` when the directory is on `sys.path`. [`Self::Overlay`] keeps
252 /// writes in memory instead.
253 ReadWrite,
254 /// Copy-on-write overlay in parent memory; writes are discarded when the
255 /// feed ends.
256 Overlay,
257}
258
259/// How a protocol turn ended: a suspension that needs an answer from the
260/// caller, or completion of the fed snippet.
261#[derive(Debug)]
262pub enum TurnEvent {
263 /// The sandbox called an external function — answer with
264 /// [`Checkout::resume`]. When `object_id` is set this is a method call on
265 /// a host-backed object, routed by uuid — a class instance, or a class
266 /// type (a classmethod call, or construction of a host class, which is
267 /// spelled `__call__`); the receiver is NOT included in `args`.
268 FunctionCall {
269 function_name: String,
270 /// One arena holding every positional and keyword argument.
271 args: CallArgs,
272 call_id: u32,
273 object_id: Option<MontyUuid>,
274 /// Coroutine results may be awaited and returned via [`Checkout::resume_futures`].
275 allow_eager_await: bool,
276 /// Where the call expression is in the source.
277 position: SourceRange,
278 },
279 /// The sandbox performed an OS operation (e.g. `"Path.read_text"`).
280 /// Answer it from this feed's mounts with
281 /// [`Checkout::resume_from_mounts`], or directly with
282 /// [`Checkout::resume`]. A caller with no handler should resume with
283 /// [`ResumeValue::NotHandled`]; the sandbox then raises the call's own
284 /// no-handler default.
285 ///
286 /// Answer host imports from the caller's modules before other handlers.
287 /// [`OsFunctionCall::Import`] takes a module value; `NotFound` / `NotHandled` raise
288 /// `ModuleNotFoundError`. [`OsFunctionCall::ImportStubs`] takes a dict of dotted
289 /// module paths to `.pyi` source or `None`, or `NotHandled` for no stubs.
290 OsCall {
291 function_name: String,
292 /// One arena holding every positional and keyword argument.
293 args: CallArgs,
294 call_id: u32,
295 /// As on [`FunctionCall`](Self::FunctionCall): the caller may await
296 /// the wait and answer with [`Checkout::resume_futures`]. Only set on
297 /// a call `OsFunctionCall::accepts_future` allows a future for.
298 allow_eager_await: bool,
299 /// Wait this duration, then return `MontyObject::none()` (as a future for `asyncio.sleep`).
300 /// The parent caps the untrusted worker's delay and charges `max_total_sleep` before returning it.
301 /// `None` delegates to the caller's OS handler.
302 system_sleep: Option<Duration>,
303 /// Where the call expression is in the source.
304 position: SourceRange,
305 },
306 /// The sandbox read an undefined name, or — when `object_id` is set — a
307 /// lazy attribute on the host-backed object with that uuid (a class
308 /// instance, or a class type) — answer with
309 /// [`Checkout::resume_name_lookup`]. An `Undefined` (or `None`) answer
310 /// raises `NameError` for plain lookups, `AttributeError` for attribute
311 /// lookups; an `Error` answer raises the host's exception in the sandbox.
312 NameLookup {
313 name: String,
314 object_id: Option<MontyUuid>,
315 /// Where the name (or attribute access) is in the source.
316 position: SourceRange,
317 },
318 /// Every sandbox task is blocked on external futures — answer with
319 /// [`Checkout::resume_futures`].
320 ResolveFutures {
321 pending_call_ids: Vec<u32>,
322 /// Where the main task's blocked `await` is in the source.
323 position: SourceRange,
324 },
325 /// The fed snippet completed with this value; the session is ready for
326 /// the next [`Checkout::feed`].
327 Complete(MontyObject),
328}
329
330/// The caller's answer to a [`TurnEvent::FunctionCall`] or
331/// [`TurnEvent::OsCall`].
332#[derive(Debug)]
333pub enum ResumeValue {
334 /// The call returned this value.
335 Return(MontyObject),
336 /// The call raised this exception.
337 Error(MontyException),
338 /// The call is asynchronous: register an external future and continue
339 /// other tasks; resolve later via [`Checkout::resume_futures`].
340 Future,
341 /// No handler exists for the called name — the sandbox raises
342 /// `NameError` (`ModuleNotFoundError` for an `import`).
343 NotFound,
344 /// No handler accepted this OS call — the sandbox raises the call's own
345 /// no-handler default (`PermissionError` naming the path for filesystem
346 /// calls, `RuntimeError` for the rest). Only valid answering a
347 /// [`TurnEvent::OsCall`].
348 NotHandled,
349}
350
351/// The (boxed) future an [`OnPrint`] callback returns for one fragment; the
352/// turn awaits it before reading on, so a slow sink backpressures the worker.
353pub type PrintFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
354
355/// Callback receiving sandbox `print()` output streamed during a turn.
356///
357/// The callback returns a future so genuinely async sinks (a JS callback, a
358/// socket) can be awaited per fragment; synchronous sinks wrap themselves
359/// with [`on_print_sync`]. The future must be `'static`, so it captures owned
360/// copies of whatever it needs (including the text, if consumed async).
361pub type OnPrint<'a> = &'a mut (dyn FnMut(PrintStream, &str) -> PrintFuture + Send);
362
363/// Callback for the events a [`Checkout::turn_raw`] streams before the
364/// turn-ender — `Print`s today. Returns a future for the same reason
365/// [`OnPrint`] does: a slow sink backpressures the worker.
366pub type OnRawEvent<'a> = &'a mut (dyn FnMut(&pb::ChildEvent) -> PrintFuture + Send);
367
368/// Adapts a synchronous print sink to the [`OnPrint`] callback shape.
369///
370/// ```rust,no_run
371/// # use monty_pool::on_print_sync;
372/// let mut on_print = on_print_sync(|_stream, text| print!("{text}"));
373/// // session.feed("print('hi')", vec![], vec![], false, &mut on_print).await?;
374/// ```
375pub fn on_print_sync<F>(mut sink: F) -> impl FnMut(PrintStream, &str) -> PrintFuture + Send
376where
377 F: FnMut(PrintStream, &str) + Send,
378{
379 move |stream, text| {
380 sink(stream, text);
381 Box::pin(ready(()))
382 }
383}
384
385/// One worker dedicated to one REPL session.
386///
387/// Obtained from [`crate::Pool::checkout`]. [`Checkout::finish`] returns the
388/// worker to the pool; dropping without finishing kills the worker instead —
389/// mid-execution state cannot be trusted back into the pool.
390///
391/// # Cancellation
392///
393/// Turn futures (`feed`, `resume*`, `dump`, ...) are **not** resumable after
394/// being dropped mid-flight: the request may already be on the wire (or a
395/// mount's host I/O abandoned mid-service), so the protocol state is
396/// unknowable. The checkout notices on its next call,
397/// discards the worker, and fails with [`PoolError::Protocol`]; `finish` on
398/// such a session likewise discards the worker rather than returning it.
399#[expect(
400 clippy::struct_excessive_bools,
401 reason = "independent turn, abort, cwd and send flags"
402)]
403pub struct Checkout {
404 /// `None` after `finish()` or after the worker was discarded on error.
405 worker: Option<Worker>,
406 pool: Arc<PoolInner>,
407 /// The suspension awaiting an answer, when mid-feed.
408 pending: Option<Pending>,
409 /// Set while a turn's I/O is in flight; still set on the next call only
410 /// if the previous turn future was cancelled mid-I/O (see the type docs).
411 turn_in_flight: bool,
412 /// Set between sending `AbortFeed` and its reply. The child answers an
413 /// abort with `Error` (or a crash announcement), never a suspension, so
414 /// anything else is a protocol violation rather than a turn to service.
415 abort_in_flight: bool,
416 /// Parent-enforced limits and the session's consumption of them.
417 budget: SessionBudget,
418 /// The budget in force before an in-flight `Load`, put back by
419 /// [`Checkout::abort_if_over_budget`] unless the child's first reply confirms the
420 /// dump was adopted (an `Ok`, or the re-announced suspension) — so a
421 /// refused `Load` cannot reset the suspension count, and an abort of the
422 /// re-announced suspension does not discard the dump's limit.
423 pending_load_budget: Option<SessionBudget>,
424 /// The deadline armed for the most recent turn, surfaced by
425 /// [`PoolError::Timeout`] when it fires.
426 armed_deadline: Option<Duration>,
427 /// The script name a `restore` adopted, captured from the worker's `Load`
428 /// reply (the name travels inside the opaque dump, so the parent learns it
429 /// only by the worker echoing it). Reset at the start of each `restore` and
430 /// taken by `restore` to return; unset for non-restore turns.
431 restored_script_name: Option<String>,
432 /// The session ID a storing relay minted, from its reply to `Configure` or `Load`.
433 session_id: Option<Vec<u8>>,
434 /// How to redial the relay after a `Shutdown`; `None` unless auto-resume applies.
435 redial: Option<Box<Redial>>,
436 /// Parent-side mount table for the in-flight feed, built from the
437 /// [`MountSpec`]s passed to [`Checkout::feed`] / [`Checkout::restore`].
438 /// Consulted only by [`Checkout::resume_from_mounts`]. Dropped when the
439 /// feed ends so overlay writes never leak into the next feed.
440 feed_mounts: Option<MountTable>,
441 /// Whether the session's working directory has been established (a feed
442 /// chose it, or a restored dump carried it). Until then a feed without an
443 /// explicit `cwd` sends the mount-derived default; afterwards it sends
444 /// nothing and the worker keeps the directory, `os.chdir` included.
445 cwd_set: bool,
446 /// Whether the latest request reached the worker. An oversize frame is
447 /// rejected before any bytes are written, so state the request would have
448 /// set on the worker (the working directory) stays as it was.
449 request_sent: bool,
450 /// When the session started, for `monty.pool.session.duration`. Taken by
451 /// `finish`, terminal worker loss, or `Drop`, so it is recorded once.
452 #[cfg(feature = "telemetry")]
453 started: Option<Instant>,
454}
455
456/// Parent-enforced limits from `Configure` or the first reply after `Load`.
457/// Restoring resets suspension and sleep totals, but keeps every limit as a ceiling:
458/// untrusted worker replies, including the `Load` reply, can only tighten them.
459#[derive(Clone, Copy)]
460struct SessionBudget {
461 /// The session's `max_feed_duration`, when configured.
462 feed_budget: Option<Duration>,
463 /// The session's `max_turn_duration`, when configured.
464 turn_budget: Option<Duration>,
465 /// Worker-reported sandbox time consumed by the feed in progress, and
466 /// monotonic, so a compromised worker cannot rewind the parent's view of
467 /// how much of the feed budget is spent. The legitimate drop to zero is
468 /// [`begin_feed`](Self::begin_feed), driven by the `Feed` request going
469 /// out, so a worker reporting less than it did mid-feed cannot loosen its
470 /// own feed backstop.
471 reported_feed_execution: Duration,
472 /// The session's `max_suspensions` in force (the configured one, else
473 /// [`DEFAULT_MAX_SUSPENSIONS`]).
474 suspension_limit: u64,
475 /// Suspensions this checkout has received from the worker.
476 suspensions_seen: u64,
477 /// Configured `max_total_sleep`, tightened by worker replies.
478 sleep_limit: Option<Duration>,
479 /// Parent-enforced ceiling on each system sleep, even from a compromised worker.
480 /// Uses the configured maximum, or the default for other modes because a restored dump may sleep.
481 system_sleep_max: Duration,
482 /// Capped system sleep accepted so far; `charge_sleep` refuses a sleep that would exceed `sleep_limit`.
483 sleep_used: Duration,
484}
485
486impl SessionBudget {
487 /// Creates a fresh budget from `Configure` limits.
488 fn from_config(repl: &ReplConfig) -> Self {
489 let limits = repl.limits.as_ref();
490 Self {
491 feed_budget: limits.and_then(|limits| limits.max_feed_duration),
492 turn_budget: limits.and_then(|limits| limits.max_turn_duration),
493 reported_feed_execution: Duration::ZERO,
494 suspension_limit: limits.map_or(DEFAULT_MAX_SUSPENSIONS as u64, |limits| limits.max_suspensions as u64),
495 suspensions_seen: 0,
496 sleep_limit: limits.and_then(|limits| limits.max_total_sleep),
497 sleep_used: Duration::ZERO,
498 system_sleep_max: match repl.os_policy.sleep {
499 SleepMode::System(max) => max,
500 SleepMode::CallHost | SleepMode::Zero => SleepMode::DEFAULT_MAX,
501 },
502 }
503 }
504
505 /// Clears consumption before adopting a dump's budget. The limits stay:
506 /// they are the ceilings on the dump's.
507 fn forget(&mut self) {
508 *self = Self {
509 feed_budget: self.feed_budget,
510 turn_budget: self.turn_budget,
511 reported_feed_execution: Duration::ZERO,
512 suspension_limit: self.suspension_limit,
513 suspensions_seen: 0,
514 sleep_limit: self.sleep_limit,
515 sleep_used: Duration::ZERO,
516 system_sleep_max: self.system_sleep_max,
517 };
518 }
519
520 /// Tightens limits and records consumption, counting suspensions in the parent.
521 /// Untrusted replies can only increase reported time and tighten limits.
522 /// Ordinary replies echo limits; a `Load` reply reports the dump's.
523 fn update_from(&mut self, event: &pb::ChildEvent) {
524 self.reported_feed_execution = self
525 .reported_feed_execution
526 .max(Duration::from_micros(event.feed_execution_micros));
527 self.feed_budget = tighter(self.feed_budget, event.max_feed_duration_micros);
528 self.turn_budget = tighter(self.turn_budget, event.max_turn_duration_micros);
529 self.sleep_limit = tighter(self.sleep_limit, event.max_total_sleep_micros);
530 if let Some(reported) = event.max_suspensions {
531 self.suspension_limit = self.suspension_limit.min(reported);
532 }
533 if is_suspension(event) {
534 self.suspensions_seen += 1;
535 }
536 }
537
538 /// Clears the feed clock as a feed request goes out; see
539 /// [`reported_feed_execution`](Self::reported_feed_execution).
540 fn begin_feed(&mut self) {
541 self.reported_feed_execution = Duration::ZERO;
542 }
543
544 /// Reports when this event exceeds the suspension limit.
545 fn over_suspension_limit(&self, event: &pb::ChildEvent) -> Option<u64> {
546 (is_suspension(event) && self.suspensions_seen > self.suspension_limit).then_some(self.suspension_limit)
547 }
548
549 /// Charges a capped system sleep; other events, including `CallHost` sleeps, are free.
550 /// Returns `Some((limit, total))` without charging if the caller must refuse the sleep.
551 /// Invalid worker delays count as the ceiling.
552 fn charge_sleep(&mut self, event: &pb::ChildEvent) -> Option<(Duration, Duration)> {
553 let seconds = match &event.kind {
554 Some(pb::child_event::Kind::OsCall(call)) => match call.call {
555 Some(pb::os_call::Call::SystemSleep(pb::os_call::Sleep { seconds })) => seconds,
556 Some(pb::os_call::Call::AsyncSystemSleep(pb::os_call::AsyncSleep { delay })) => delay,
557 _ => return None,
558 },
559 _ => return None,
560 };
561 let delay = self.cap_system_sleep(Duration::try_from_secs_f64(seconds).unwrap_or(Duration::MAX));
562 let total = self.sleep_used.saturating_add(delay);
563 match self.sleep_limit {
564 Some(limit) if total > limit => Some((limit, total)),
565 _ => {
566 self.sleep_used = total;
567 None
568 }
569 }
570 }
571
572 fn cap_system_sleep(&self, delay: Duration) -> Duration {
573 delay.min(self.system_sleep_max)
574 }
575
576 /// Returns the tighter of the two duration backstops: what each configured
577 /// budget has left, plus that budget's grace. The turn budget has all of
578 /// its limit left, since the child resets that clock on the request being
579 /// sent.
580 ///
581 /// The child normally raises `TimeoutError` inside the grace; this catches
582 /// one that stops checking its clock. A `None` grace means no backstop —
583 /// the host would rather wait than lose the worker.
584 fn backstop_deadline(&self, graces: DurationGraces) -> Option<Duration> {
585 [
586 remaining_deadline(self.feed_budget, self.reported_feed_execution, graces.feed),
587 remaining_deadline(self.turn_budget, Duration::ZERO, graces.turn),
588 ]
589 .into_iter()
590 .flatten()
591 .min()
592 }
593}
594
595/// The stricter of a limit and a worker-reported one, in microseconds; `None` is unlimited.
596fn tighter(limit: Option<Duration>, reported_micros: Option<u64>) -> Option<Duration> {
597 match (limit, reported_micros.map(Duration::from_micros)) {
598 (Some(limit), Some(reported)) => Some(limit.min(reported)),
599 (limit, reported) => limit.or(reported),
600 }
601}
602
603/// The grace period configured for each of the two duration backstops.
604///
605/// Bundled so [`SessionBudget::backstop_deadline`] takes one argument rather
606/// than two same-typed `Option<Duration>`s in an order nothing enforces.
607#[derive(Clone, Copy)]
608struct DurationGraces {
609 feed: Option<Duration>,
610 turn: Option<Duration>,
611}
612
613impl DurationGraces {
614 /// Reads both graces off a pool config.
615 fn from_config(config: &PoolConfig) -> Self {
616 Self {
617 feed: config.feed_duration_limit_grace,
618 turn: config.turn_duration_limit_grace,
619 }
620 }
621}
622
623/// One budget's backstop deadline: what it has left, plus its grace. `None`
624/// when the budget or its grace is unset — either way there is nothing to
625/// backstop.
626fn remaining_deadline(budget: Option<Duration>, consumed: Duration, grace: Option<Duration>) -> Option<Duration> {
627 Some(budget?.saturating_sub(consumed).saturating_add(grace?))
628}
629
630/// Recognizes the requests that run sandbox code, whose turns are bounded by
631/// the duration backstops as well as `request_timeout`.
632fn is_execution_turn(request: &pb::ParentRequest) -> bool {
633 matches!(
634 request.kind,
635 Some(
636 pb::parent_request::Kind::Feed(_)
637 | pb::parent_request::Kind::ResumeCall(_)
638 | pb::parent_request::Kind::ResumeNameLookup(_)
639 | pb::parent_request::Kind::ResumeFutures(_)
640 )
641 )
642}
643
644/// Recognizes turn-ending events that await a host answer.
645fn is_suspension(event: &pb::ChildEvent) -> bool {
646 matches!(
647 event.kind,
648 Some(
649 pb::child_event::Kind::FunctionCall(_)
650 | pb::child_event::Kind::OsCall(_)
651 | pb::child_event::Kind::NameLookup(_)
652 | pb::child_event::Kind::ResolveFutures(_)
653 )
654 )
655}
656
657/// Recognizes the turn-enders a child may answer `AbortFeed` with: the
658/// uncatchable exception's `Error`, or the announcement that it died.
659fn is_abort_reply(event: &pb::ChildEvent) -> bool {
660 matches!(
661 event.kind,
662 Some(
663 pb::child_event::Kind::Error(_) | pb::child_event::Kind::FatalError(_) | pb::child_event::Kind::Shutdown(_)
664 )
665 )
666}
667
668/// The exception a feed is aborted with once it suspends past `max_suspensions`.
669fn suspension_limit_exceeded(limit: u64) -> MontyException {
670 MontyException::new(
671 ExcType::RuntimeError,
672 Some(format!("suspension limit {limit} exceeded")),
673 )
674}
675
676/// Uncatchable feed error; `total` includes the refused sleep.
677/// The documented message uses `Duration` debug formatting (`1.5s > 1s`).
678fn sleep_limit_exceeded(limit: Duration, total: Duration) -> MontyException {
679 MontyException::new(
680 ExcType::TimeoutError,
681 Some(format!("sleep limit exceeded: {total:?} > {limit:?}")),
682 )
683}
684
685/// Which kind of suspension is awaiting an answer.
686enum Pending {
687 /// FunctionCall or OsCall; carries the call id and name (the name feeds
688 /// `ResumeValue::NotFound`'s NameError).
689 Call {
690 call_id: u32,
691 function_name: String,
692 /// The typed OS call, retained so [`Checkout::resume_from_mounts`] can
693 /// offer it to this feed's mount table after the caller has seen it.
694 /// `None` for an external function call, which also gates
695 /// [`ResumeValue::NotHandled`] — only an OS call can resolve that way.
696 os_call: Option<Box<OsFunctionCall>>,
697 /// Accept a settled coroutine for this call via `ResumeFutures`.
698 allow_eager_await: bool,
699 },
700 /// The lookup's name and receiver, kept only to recognise it re-announced after a shutdown.
701 NameLookup { name: String, object_id: Option<MontyUuid> },
702 /// The ids awaited, kept only to recognise them re-announced after a shutdown.
703 Futures { pending_call_ids: Vec<u32> },
704}
705
706/// A suspension's identity, compared when a resumed session re-announces it:
707/// the call id and name, the looked-up name and receiver, or the awaited ids.
708#[derive(Debug, PartialEq, Eq)]
709enum PendingKey {
710 Call { call_id: u32, function_name: String },
711 NameLookup { name: String, object_id: Option<MontyUuid> },
712 Futures { pending_call_ids: Vec<u32> },
713}
714
715impl PendingKey {
716 fn of(pending: &Pending) -> Self {
717 match pending {
718 Pending::Call {
719 call_id, function_name, ..
720 } => Self::Call {
721 call_id: *call_id,
722 function_name: function_name.clone(),
723 },
724 Pending::NameLookup { name, object_id } => Self::NameLookup {
725 name: name.clone(),
726 object_id: *object_id,
727 },
728 Pending::Futures { pending_call_ids } => Self::Futures {
729 pending_call_ids: pending_call_ids.clone(),
730 },
731 }
732 }
733}
734
735/// What a WebSocket checkout keeps to redial its relay and reload the session
736/// after a `Shutdown` (see [`PoolConfig::auto_resume`]).
737pub(crate) struct Redial {
738 /// Re-sent as the new worker's `Configure`.
739 pub(crate) repl: ReplConfig,
740 /// The upgrade headers the checkout first dialed with.
741 pub(crate) connect_headers: Vec<(String, String)>,
742 /// Parents the new worker's session span as the first one was.
743 #[cfg(feature = "telemetry")]
744 pub(crate) telemetry: Option<TelemetryContext>,
745}
746
747impl Checkout {
748 /// Sends `Configure` on a fresh worker (the worker materializes the repl
749 /// lazily on the first feed, or restores one via `load_snapshot` instead).
750 ///
751 /// `redial` is kept for [`PoolConfig::auto_resume`]; `None` disables it.
752 pub(crate) async fn create(
753 worker: Worker,
754 pool: Arc<PoolInner>,
755 repl: &ReplConfig,
756 redial: Option<Redial>,
757 ) -> Result<Self, PoolError> {
758 let request = configure_request(repl);
759 let mut this = Self {
760 worker: Some(worker),
761 pool,
762 pending: None,
763 turn_in_flight: false,
764 abort_in_flight: false,
765 budget: SessionBudget::from_config(repl),
766 pending_load_budget: None,
767 armed_deadline: None,
768 restored_script_name: None,
769 session_id: None,
770 redial: redial.map(Box::new),
771 feed_mounts: None,
772 cwd_set: false,
773 request_sent: false,
774 #[cfg(feature = "telemetry")]
775 started: Some(Instant::now()),
776 };
777 let mut no_print = on_print_sync(|_, _| {});
778 let deadline = this.pool.config.request_timeout;
779 match this.request_turn(&request, deadline, &mut no_print).await? {
780 ControlEvent::Ok => Ok(this),
781 other => Err(this.protocol_violation(format!("unexpected reply to Configure: {other:?}"))),
782 }
783 }
784
785 /// Restores a dumped session into this checkout's freshly configured (but
786 /// not-yet-fed) worker, returning the re-announced suspension event when the
787 /// dump was taken mid-feed (`None` for an idle, between-feeds dump).
788 ///
789 /// This is the low-level restore both `session.load_session` (idle dumps) and
790 /// `session.load_snapshot` (suspended dumps) drive: the caller inspects the
791 /// returned `Option` to tell which kind of dump it was and reject a
792 /// mismatch. Only valid before the worker has been fed (the child rejects a
793 /// `Load` once a repl exists).
794 ///
795 /// `mounts` re-establish a suspended feed's mounts, which are never part of
796 /// the dump (they are host configuration the parent services itself). Pass
797 /// the same mounts the original feed used, so the resumed feed's covered
798 /// calls can still be answered by [`Checkout::resume_from_mounts`]. A dump
799 /// taken mid-OS-call re-announces the call in full, so the returned event
800 /// is that same [`TurnEvent::OsCall`] — restoring never answers it here.
801 /// The worker caps snapshot resource limits at the checkout's configured limits.
802 /// Stricter snapshot limits and elapsed execution time remain in effect.
803 /// The parent adopts these effective limits from the worker's reply, but keeps
804 /// its configured ones as ceilings, so a lying worker cannot loosen them.
805 /// The suspension count restarts at zero.
806 ///
807 /// Returns the re-announced suspension (`Some` — a suspended dump) or `None`
808 /// (an idle dump), paired with the worker's adopted script name (the dump's,
809 /// not the `Configure` one), which the parent surfaces in restored snapshots.
810 ///
811 /// # Snapshot trust
812 /// The caller must verify the dump's provenance and integrity before restoring it.
813 /// Invalid snapshots have no correctness or availability guarantees.
814 /// Successful loading is not authentication or validation.
815 ///
816 /// Against a relay that stores sessions, `state` may instead be a session ID
817 /// it minted. Loading one always starts a new session, with its own
818 /// [`Checkout::session_id`], from the state last stored under that ID.
819 pub async fn restore(
820 &mut self,
821 state: Vec<u8>,
822 mounts: Vec<MountSpec>,
823 on_print: OnPrint<'_>,
824 ) -> Result<(Option<TurnEvent>, Option<String>), PoolError> {
825 self.ensure_ready()?;
826 let feed_mounts = Self::build_feed_mounts(mounts)?;
827 // the dump carries its own limits/consumed time/script name — forget
828 // what the worker's Configure established and re-adopt from the reply
829 // (see `pending_load_budget` for when the old budget comes back)
830 self.pending = None;
831 self.begin_load();
832 self.restored_script_name = None;
833 // a load names a new session; a storing relay's reply supplies its ID
834 let session_id = self.session_id.take();
835 self.feed_mounts = feed_mounts;
836 let request = request(pb::parent_request::Kind::Load(pb::Load { state: state.into() }));
837 let outcome = self
838 .request_turn(&request, self.pool.config.request_timeout, on_print)
839 .await;
840 self.end_load();
841 // A `Runtime` error that named no session leaves the worker's as it
842 // was — a frame-size rejection before the send, or a load the worker
843 // refused — so it keeps the ID it had, which a storing relay still
844 // holds it under. (An aborted over-budget load named the new one.)
845 if matches!(outcome, Err(PoolError::Runtime(_))) && self.session_id.is_none() {
846 self.session_id = session_id;
847 }
848 let event = match outcome? {
849 ControlEvent::Ok => None,
850 ControlEvent::Turn(event) => Some(event),
851 other @ ControlEvent::Dump(_) => {
852 return Err(self.protocol_violation(format!("unexpected reply to Load: {other:?}")));
853 }
854 };
855 // The adopted dump carries the session's working directory too.
856 self.cwd_set = true;
857 Ok((event, self.restored_script_name.take()))
858 }
859
860 /// Executes one snippet against the session. Inputs become sandbox
861 /// globals; mounts apply to this feed only and are serviced by the parent
862 /// (specs with overlapping host directories fail here, before any frame is
863 /// sent, as a session-preserving [`PoolError::Runtime`]). The session's
864 /// first feed sets the sandbox working directory to its first mount's
865 /// virtual path, or `/` without mounts; later feeds keep the directory
866 /// (`os.chdir` included) unless [`Checkout::feed_with_cwd`] switches it.
867 /// Returns the first suspension (or completion); `print()` output streams
868 /// to `on_print` throughout.
869 ///
870 /// # Errors
871 /// [`PoolError::Runtime`] / [`PoolError::Typing`] leave the session
872 /// usable; all other errors mean the worker was discarded.
873 pub async fn feed(
874 &mut self,
875 code: impl Into<String>,
876 inputs: impl Into<NamedValues>,
877 mounts: Vec<MountSpec>,
878 skip_type_check: bool,
879 on_print: OnPrint<'_>,
880 ) -> Result<TurnEvent, PoolError> {
881 self.feed_with_cwd(code, inputs, mounts, None, skip_type_check, on_print)
882 .await
883 }
884
885 /// [`Checkout::feed`] with an explicit switch of the sandbox working
886 /// directory before the feed: an absolute POSIX virtual path that
887 /// `os.getcwd()` reports and relative paths resolve against. `None` keeps
888 /// the session's current directory, which the first feed defaults to its
889 /// first mount (else `/`).
890 ///
891 /// # Errors
892 /// A relative or NUL-containing `cwd` is a session-preserving
893 /// [`PoolError::Runtime`] (`ValueError`), raised before any frame is sent.
894 pub async fn feed_with_cwd(
895 &mut self,
896 code: impl Into<String>,
897 inputs: impl Into<NamedValues>,
898 mounts: Vec<MountSpec>,
899 cwd: Option<&str>,
900 skip_type_check: bool,
901 on_print: OnPrint<'_>,
902 ) -> Result<TurnEvent, PoolError> {
903 self.ensure_ready()?;
904 if self.pending.is_some() {
905 return Err(PoolError::Protocol(
906 "feed called while a suspension is awaiting an answer".into(),
907 ));
908 }
909 let cwd = match cwd {
910 Some(cwd) => checked_cwd(cwd)?,
911 // An empty wire cwd keeps the worker's current directory.
912 None if self.cwd_set => String::new(),
913 None => mounts
914 .first()
915 .map_or_else(|| "/".to_owned(), |mount| mount.virtual_path().to_owned()),
916 };
917 self.feed_mounts = Self::build_feed_mounts(mounts)?;
918 let (inputs, values) = named_values_to_proto(inputs.into());
919 // The child resets its feed clock on this request, so the last feed's
920 // reported total must not shorten this feed's backstop.
921 self.budget.begin_feed();
922 let request = request(pb::parent_request::Kind::Feed(pb::Feed {
923 code: code.into(),
924 inputs,
925 values: Some(values),
926 skip_type_check,
927 cwd,
928 }));
929 let outcome = self.expect_turn(&request, on_print).await;
930 // The worker adopts the directory as the request arrives, before the
931 // stubs ask and type checking, so once the request reached it every
932 // reply means it took effect; a lost worker takes the session with it.
933 if self.request_sent {
934 self.cwd_set = true;
935 }
936 outcome
937 }
938
939 /// Answers a [`TurnEvent::FunctionCall`] or [`TurnEvent::OsCall`].
940 pub async fn resume(&mut self, value: ResumeValue, on_print: OnPrint<'_>) -> Result<TurnEvent, PoolError> {
941 self.ensure_ready()?;
942 let Some(Pending::Call {
943 call_id,
944 function_name,
945 os_call,
946 ..
947 }) = &self.pending
948 else {
949 return Err(PoolError::Protocol("no suspended call to resume".into()));
950 };
951 let (call_id, function_name, is_os_call) = (*call_id, function_name.clone(), os_call.is_some());
952 if matches!(value, ResumeValue::NotHandled) && !is_os_call {
953 return Err(PoolError::Protocol(
954 "NotHandled is only valid answering an OS call".into(),
955 ));
956 }
957 let (result, values) = match value {
958 ResumeValue::Return(value) => ext_result_to_proto(ExtFunctionResult::Return(value)),
959 ResumeValue::Error(exc) => ext_result_to_proto(ExtFunctionResult::Error(exc)),
960 ResumeValue::Future => ext_result_to_proto(ExtFunctionResult::Future(call_id)),
961 ResumeValue::NotFound => ext_result_to_proto(ExtFunctionResult::NotFound(function_name)),
962 ResumeValue::NotHandled => (
963 pb::ExtFunctionResult {
964 kind: Some(pb::ext_function_result::Kind::NotHandled(pb::Unit {})),
965 },
966 None,
967 ),
968 };
969 let request = request(pb::parent_request::Kind::ResumeCall(pb::ResumeCall {
970 call_id,
971 result: Some(result),
972 values,
973 }));
974 // `pending` is deliberately NOT cleared here: an oversize answer is
975 // rejected by `Worker::send` before any bytes reach the child, and
976 // `request_turn` surfaces it with the suspension still answerable.
977 // Every turn-ending reply overwrites `pending` anyway.
978 self.expect_turn(&request, on_print).await
979 }
980
981 /// Answers a pending [`TurnEvent::OsCall`] from this feed's mounts, when
982 /// they cover it.
983 ///
984 /// `Ok(None)` means no mount covers the call (or the feed has none): the
985 /// suspension is left intact for the caller to answer itself, typically via
986 /// its own `os` handler and then [`Checkout::resume`]. `Ok(Some(event))`
987 /// means a mount serviced the call — including servicing it into an error
988 /// such as `PermissionError` — and the feed ran on to `event`.
989 ///
990 /// This is how mounts are reached now that every OS call surfaces: an
991 /// auto-answering driver tries mounts first and falls back to its handler,
992 /// while a caller driving suspensions by hand can ignore mounts entirely.
993 /// Path containment inside covered calls is enforced by the [`MountTable`].
994 ///
995 /// Covered calls perform real host filesystem I/O, serviced on tokio's
996 /// blocking pool so a stalled volume cannot pin a runtime worker. Dropping
997 /// this future mid-servicing abandons the feed's mount state and is
998 /// treated exactly like a cancellation mid-turn: the worker is discarded
999 /// on the next call.
1000 pub async fn resume_from_mounts(&mut self, on_print: OnPrint<'_>) -> Result<Option<TurnEvent>, PoolError> {
1001 self.ensure_ready()?;
1002 let Some(Pending::Call { os_call, .. }) = &mut self.pending else {
1003 return Err(PoolError::Protocol("no suspended call to resume".into()));
1004 };
1005 let Some(call) = os_call.take() else {
1006 return Err(PoolError::Protocol(
1007 "resume_from_mounts is only valid answering an OS call".into(),
1008 ));
1009 };
1010 // The call is *moved* into the table so a covered write's payload
1011 // reaches overlay storage without a copy; an uncovered call comes back
1012 // unchanged and is put back for the caller to answer.
1013 let outcome = match self.feed_mounts.take() {
1014 Some(mut mounts) => {
1015 // Table and call move into the blocking task and back out.
1016 // `turn_in_flight` spans the await: a caller cancelling here
1017 // abandons both, condemning the worker via the same mechanism
1018 // as a cancellation mid-turn-I/O (see `request_turn`).
1019 self.turn_in_flight = true;
1020 let (mounts, outcome) = self
1021 .run_blocking(move || {
1022 let outcome = mounts.handle_os_call(*call);
1023 (mounts, outcome)
1024 })
1025 .await?;
1026 self.feed_mounts = Some(mounts);
1027 self.turn_in_flight = false;
1028 outcome
1029 }
1030 None => MountCallOutcome::NotHandled(*call),
1031 };
1032 match outcome {
1033 MountCallOutcome::Handled(result) => {
1034 let value = match result {
1035 Ok(obj) => ResumeValue::Return(obj),
1036 Err(err) => ResumeValue::Error(err.into_exception()),
1037 };
1038 match self.resume(value, &mut *on_print).await {
1039 // The result never reached the child (too large or too deep
1040 // to encode), so the call is still suspended — answer it
1041 // with that error instead, letting the sandbox raise a
1042 // catchable exception rather than stranding the feed. Only
1043 // a rejection before the frame is written leaves `pending`
1044 // set, so this cannot catch a genuine sandbox exception.
1045 Err(PoolError::Runtime(exc)) if self.pending.is_some() => {
1046 self.resume(ResumeValue::Error(exc), on_print).await.map(Some)
1047 }
1048 other => other.map(Some),
1049 }
1050 }
1051 MountCallOutcome::NotHandled(call) => {
1052 let Some(Pending::Call { os_call, .. }) = &mut self.pending else {
1053 unreachable!("checked above");
1054 };
1055 *os_call = Some(Box::new(call));
1056 Ok(None)
1057 }
1058 }
1059 }
1060
1061 /// Answers a [`TurnEvent::NameLookup`] with a [`NameLookupResult`] (or a
1062 /// `MontyObject`, an `Option<MontyObject>` where `None` is `Undefined`, or
1063 /// a `MontyException` for `Error`): a value resolves the name; `Undefined`
1064 /// makes the sandbox raise `NameError` for a plain lookup, or
1065 /// `AttributeError` when the lookup carried an `object_id` (a lazy
1066 /// attribute on a host-backed object — a class instance or class type);
1067 /// `Error` raises the host's exception in the sandbox, bypassing
1068 /// `hasattr()` / `getattr()` defaults the way a raising property does.
1069 pub async fn resume_name_lookup(
1070 &mut self,
1071 result: impl Into<NameLookupResult>,
1072 on_print: OnPrint<'_>,
1073 ) -> Result<TurnEvent, PoolError> {
1074 self.ensure_ready()?;
1075 if !matches!(self.pending, Some(Pending::NameLookup { .. })) {
1076 return Err(PoolError::Protocol("no suspended name lookup to resume".into()));
1077 }
1078 let request = request(pb::parent_request::Kind::ResumeNameLookup(result.into().into()));
1079 // `pending` left set — see the comment in [`Self::resume`]
1080 self.expect_turn(&request, on_print).await
1081 }
1082
1083 /// Answers a [`TurnEvent::ResolveFutures`] with results for some or all
1084 /// pending call ids. Each result must be `Return` or `Error` — a future
1085 /// cannot resolve to another future or to "not found".
1086 /// Also accepts exactly one matching result for a call with `allow_eager_await` set.
1087 pub async fn resume_futures(
1088 &mut self,
1089 results: Vec<(u32, ResumeValue)>,
1090 on_print: OnPrint<'_>,
1091 ) -> Result<TurnEvent, PoolError> {
1092 self.ensure_ready()?;
1093 match &self.pending {
1094 Some(Pending::Call {
1095 call_id,
1096 allow_eager_await: true,
1097 ..
1098 }) => {
1099 if results.len() != 1 || results[0].0 != *call_id {
1100 return Err(PoolError::Protocol(
1101 "eager result must match the suspended call id".into(),
1102 ));
1103 }
1104 }
1105 Some(Pending::Futures { .. }) => {}
1106 _ => return Err(PoolError::Protocol("no suspended futures to resume".into())),
1107 }
1108 let results = results
1109 .into_iter()
1110 .map(|(call_id, value)| {
1111 let result = match value {
1112 ResumeValue::Return(value) => ExtFunctionResult::Return(value),
1113 ResumeValue::Error(exc) => ExtFunctionResult::Error(exc),
1114 ResumeValue::Future | ResumeValue::NotFound | ResumeValue::NotHandled => {
1115 return Err(PoolError::Protocol(
1116 format!("future {call_id} must resolve to Return or Error").into(),
1117 ));
1118 }
1119 };
1120 Ok((call_id, result))
1121 })
1122 .collect::<Result<Vec<_>, _>>()?;
1123 let request = request(pb::parent_request::Kind::ResumeFutures(future_results_to_proto(
1124 results,
1125 )));
1126 // `pending` left set — see the comment in [`Self::resume`]
1127 self.expect_turn(&request, on_print).await
1128 }
1129
1130 /// Installs third-party Python packages into the session, making them
1131 /// importable by subsequent feeds. Session-scoped and repeatable; an empty
1132 /// `requirements` list is a no-op.
1133 ///
1134 /// Only an embedded-CPython worker honors this. The
1135 /// `monty` sandbox worker has no host interpreter to install for and a uv
1136 /// install failure both surface as [`PoolError::Runtime`] (the latter
1137 /// carrying uv's stderr); the session stays usable in either case. Bounded
1138 /// by the pool's `request_timeout`, so raise it for large dependency sets.
1139 ///
1140 /// Each requirement is validated here, at the pool boundary, before any
1141 /// frame is sent: a string that uv would parse as an option rather than a
1142 /// package specifier is rejected with [`PoolError::Runtime`] (a
1143 /// `ValueError`). See [`validate_requirement`] for the rationale.
1144 pub async fn install_dependencies(&mut self, requirements: Vec<String>) -> Result<(), PoolError> {
1145 self.ensure_ready()?;
1146 if self.pending.is_some() {
1147 return Err(PoolError::Protocol(
1148 "install_dependencies called while a suspension is awaiting an answer".into(),
1149 ));
1150 }
1151 // Installing nothing trivially succeeds on any worker — including the
1152 // sandbox worker, which would otherwise reject the request outright.
1153 if requirements.is_empty() {
1154 return Ok(());
1155 }
1156 for requirement in &requirements {
1157 validate_requirement(requirement).map_err(invalid_requirement)?;
1158 }
1159 let request = request(pb::parent_request::Kind::InstallDependencies(pb::InstallDependencies {
1160 requirements: requirements.into(),
1161 }));
1162 let mut no_print = on_print_sync(|_, _| {});
1163 let deadline = self.pool.config.request_timeout;
1164 match self.request_turn(&request, deadline, &mut no_print).await? {
1165 ControlEvent::Ok => Ok(()),
1166 other => Err(self.protocol_violation(format!("unexpected reply to InstallDependencies: {other:?}"))),
1167 }
1168 }
1169
1170 /// Serializes the session (idle or suspended) into opaque bytes that
1171 /// [`Checkout::restore`] can restore — including into a
1172 /// different worker after this one crashes. The session stays live.
1173 pub async fn dump(&mut self) -> Result<Vec<u8>, PoolError> {
1174 let request = request(pb::parent_request::Kind::Dump(pb::Dump {}));
1175 let mut no_print = on_print_sync(|_, _| {});
1176 let deadline = self.pool.config.request_timeout;
1177 match self.request_turn(&request, deadline, &mut no_print).await? {
1178 ControlEvent::Dump(state) => Ok(state),
1179 other => Err(self.protocol_violation(format!("unexpected reply to Dump: {other:?}"))),
1180 }
1181 }
1182
1183 /// Ends the session and returns the worker to the pool.
1184 ///
1185 /// Consumes the checkout. On error the worker is discarded (and the
1186 /// error reported), but the pool remains healthy either way.
1187 pub async fn finish(mut self) -> Result<(), PoolError> {
1188 let result = self.finish_session().await;
1189 #[cfg(feature = "telemetry")]
1190 self.record_finish(outcome(result.is_ok()));
1191 result
1192 }
1193
1194 /// The body of [`Self::finish`], split out so the session's outcome is
1195 /// recorded on the error paths too — a `?` here would otherwise leave the
1196 /// drop below to report the session as abandoned.
1197 async fn finish_session(&mut self) -> Result<(), PoolError> {
1198 // A websocket worker is single-use — the pool discards it after every
1199 // checkout — so there is no point round-tripping a `Reset` to ready it
1200 // for reuse. Closing the connection (Close frame, then socket) is what
1201 // ends the session; the child reads it as a clean EOF and exits. Only
1202 // subprocess workers are reset and returned to the idle pool for the
1203 // next checkout.
1204 if self.pool.config.transport.is_websocket() {
1205 if let Some(mut worker) = self.worker.take() {
1206 // guard, not a trailing release: the worker is already out of
1207 // `self`, so a caller dropping this future mid-goodbye would
1208 // leave `Checkout::drop` with nothing to release. Disarmed
1209 // before `release_worker`, which releases the slot itself.
1210 let capacity = CapacityGuard::new(&self.pool);
1211 worker.close_transport().await;
1212 capacity.disarm();
1213 self.pool.release_worker(worker);
1214 }
1215 return Ok(());
1216 }
1217 let request = request(pb::parent_request::Kind::Reset(pb::Reset {}));
1218 let mut no_print = on_print_sync(|_, _| {});
1219 let deadline = self.pool.config.request_timeout;
1220 match self.request_turn(&request, deadline, &mut no_print).await? {
1221 ControlEvent::Ok => {
1222 if let Some(mut worker) = self.worker.take() {
1223 worker.checkouts_served += 1;
1224 self.pool.release_worker(worker);
1225 }
1226 Ok(())
1227 }
1228 other => Err(self.protocol_violation(format!("unexpected reply to Reset: {other:?}"))),
1229 }
1230 }
1231
1232 /// Records the session's lifetime, once, when it ends.
1233 #[cfg(feature = "telemetry")]
1234 fn record_finish(&mut self, outcome: &'static str) {
1235 if let (Some(started), Some(metrics)) = (self.started.take(), &self.pool.config.metrics) {
1236 metrics.session_duration(started.elapsed(), outcome);
1237 }
1238 }
1239
1240 /// Stable identity within this pool, independent of PID reuse or transport.
1241 /// Returns `None` after the worker has been released or discarded.
1242 #[must_use]
1243 pub fn worker_id(&self) -> Option<u64> {
1244 self.worker.as_ref().map(|worker| worker.id)
1245 }
1246
1247 /// OS process id of the worker, when it is a local subprocess (`None` for a
1248 /// remote WebSocket worker, or a finished checkout). Diagnostics/tests.
1249 pub fn pid(&self) -> Option<u32> {
1250 self.worker.as_ref().and_then(Worker::pid)
1251 }
1252
1253 /// The opaque session ID a storing relay minted, which [`Checkout::restore`]
1254 /// resumes from any process. `None` for local workers and ephemeral sessions.
1255 #[must_use]
1256 pub fn session_id(&self) -> Option<&[u8]> {
1257 self.session_id.as_deref()
1258 }
1259
1260 /// Sends a request and requires the reply to be a [`TurnEvent`].
1261 ///
1262 /// This is the entry point for *execution* turns (feed/resume — the
1263 /// turns where the sandbox runs code), so the deadline includes
1264 /// [`Self::backstop_deadline`] on top of the configured request timeout.
1265 async fn expect_turn(
1266 &mut self,
1267 request: &pb::ParentRequest,
1268 on_print: OnPrint<'_>,
1269 ) -> Result<TurnEvent, PoolError> {
1270 let deadline = min_deadline(self.pool.config.request_timeout, self.backstop_deadline());
1271 match self.request_turn(request, deadline, on_print).await? {
1272 ControlEvent::Turn(event) => Ok(event),
1273 other => Err(self.protocol_violation(format!("expected a turn event, got {other:?}"))),
1274 }
1275 }
1276
1277 /// The duration backstop deadline; see [`SessionBudget::backstop_deadline`].
1278 fn backstop_deadline(&self) -> Option<Duration> {
1279 self.budget
1280 .backstop_deadline(DurationGraces::from_config(&self.pool.config))
1281 }
1282
1283 /// Snapshots the budget and forgets it ahead of a `Load`, so the reply can
1284 /// re-adopt the dump's.
1285 fn begin_load(&mut self) {
1286 self.pending_load_budget = Some(self.budget);
1287 self.budget.forget();
1288 }
1289
1290 /// Puts the pre-`Load` budget back if no reply ever decided its fate (the
1291 /// pre-send frame-size rejection, or a worker that died first).
1292 fn end_load(&mut self) {
1293 if let Some(budget) = self.pending_load_budget.take() {
1294 self.budget = budget;
1295 }
1296 }
1297
1298 /// Records consumption and aborts feeds exceeding suspension or system sleep limits.
1299 /// Returns `true` after `AbortFeed`; the caller must read an error or crash next to avoid an abort loop.
1300 /// Invalid suspension payloads are protocol violations, even when over budget.
1301 /// The first non-print `Load` reply settles the budget: `Ok` or a suspension adopts the dump;
1302 /// any other reply restores the live session's budget.
1303 async fn abort_if_over_budget(&mut self, event: &mut pb::ChildEvent) -> Result<bool, PoolError> {
1304 let is_print = matches!(event.kind, Some(pb::child_event::Kind::Print(_)));
1305 if !is_print && mem::take(&mut self.abort_in_flight) && !is_abort_reply(event) {
1306 return Err(self.protocol_violation("worker answered AbortFeed with something other than an Error"));
1307 }
1308 if !is_print
1309 && let Some(saved) = self.pending_load_budget.take()
1310 && !(matches!(event.kind, Some(pb::child_event::Kind::Ok(_))) || is_suspension(event))
1311 {
1312 self.budget = saved;
1313 }
1314 self.budget.update_from(event);
1315 let exceeded = self
1316 .budget
1317 .over_suspension_limit(event)
1318 .map(suspension_limit_exceeded)
1319 .or_else(|| {
1320 self.budget
1321 .charge_sleep(event)
1322 .map(|(limit, total)| sleep_limit_exceeded(limit, total))
1323 });
1324 let Some(exception) = exceeded else {
1325 return Ok(false);
1326 };
1327 // an aborted event is dropped by the caller, so validation consumes the
1328 // call rather than cloning a worker-sized arena
1329 if let Some(pb::child_event::Kind::OsCall(call)) = &mut event.kind
1330 && let Err(err) = os_call_from_proto(mem::take(call))
1331 {
1332 return Err(self.protocol_violation(format!("invalid OS call payload: {err}")));
1333 }
1334 let abort = request(pb::parent_request::Kind::AbortFeed(pb::AbortFeed {
1335 exception: Some((&exception).into()),
1336 }));
1337 let Some(worker) = self.worker.as_mut() else {
1338 return Err(PoolError::Finished);
1339 };
1340 match worker.send(&abort).await {
1341 Ok(()) => {
1342 self.abort_in_flight = true;
1343 Ok(true)
1344 }
1345 Err(_) => Err(self.poison("aborting a feed").await),
1346 }
1347 }
1348
1349 /// The core protocol turn, bounded by `deadline`: when it expires the turn
1350 /// I/O is abandoned (safe — `Worker::recv` is cancel-safe, partial-frame
1351 /// state stays in the worker) and the worker is killed and discarded.
1352 ///
1353 /// Also enforces the cancellation contract (see the type docs): a caller
1354 /// that dropped a previous turn future mid-I/O left the protocol state
1355 /// unknowable, so the worker is discarded on the next call.
1356 ///
1357 /// A `Shutdown` naming what to load is resumed on a new worker (see
1358 /// [`PoolConfig::auto_resume`]); any other ends the session.
1359 async fn request_turn(
1360 &mut self,
1361 request: &pb::ParentRequest,
1362 deadline: Option<Duration>,
1363 on_print: OnPrint<'_>,
1364 ) -> Result<ControlEvent, PoolError> {
1365 self.ensure_ready()?;
1366 self.turn_in_flight = true;
1367 self.armed_deadline = deadline;
1368 let outcome = match deadline {
1369 Some(limit) => match timeout(limit, self.turn_io(request, &mut *on_print)).await {
1370 Ok(outcome) => outcome,
1371 Err(_elapsed) => Err(self.poison_timeout().await),
1372 },
1373 None => self.turn_io(request, &mut *on_print).await,
1374 };
1375 self.turn_in_flight = false;
1376 match outcome {
1377 // boxed: it recurses into this turn, and its state machine must not
1378 // enlarge every turn's future for a path taken once per shutdown.
1379 // The other arm hands the turn's result straight back: every move
1380 // of it here is a copy of the generator's state on the hot path.
1381 Err(PoolError::Shutdown { dump }) => Box::pin(self.after_shutdown(dump, request, deadline, on_print)).await,
1382 outcome => outcome,
1383 }
1384 }
1385
1386 /// Answers a `Shutdown`: resumes the session when the relay named what to
1387 /// load and auto-resume applies, else ends it. The worker is already released.
1388 async fn after_shutdown(
1389 &mut self,
1390 dump: Option<Vec<u8>>,
1391 request: &pb::ParentRequest,
1392 deadline: Option<Duration>,
1393 on_print: OnPrint<'_>,
1394 ) -> Result<ControlEvent, PoolError> {
1395 let outcome = match dump {
1396 Some(state) if self.can_resume(request) => {
1397 self.resume_after_shutdown(state, request, deadline, on_print).await
1398 }
1399 dump => Err(PoolError::Shutdown { dump }),
1400 };
1401 if matches!(outcome, Err(PoolError::Shutdown { .. })) {
1402 #[cfg(feature = "telemetry")]
1403 self.record_finish("error");
1404 self.discard_worker();
1405 }
1406 outcome
1407 }
1408
1409 /// Whether a `Shutdown` answering `request` may be resumed: auto-resume applies,
1410 /// the relay named the session, and `request` is not session setup or teardown.
1411 /// The caller also requires the shutdown to carry what to load: a relay with no
1412 /// state to load sends none, and a session cannot resume from nothing.
1413 #[cold]
1414 fn can_resume(&self, request: &pb::ParentRequest) -> bool {
1415 self.redial.is_some()
1416 && self.session_id.is_some()
1417 && !matches!(
1418 request.kind,
1419 Some(
1420 pb::parent_request::Kind::Configure(_)
1421 | pb::parent_request::Kind::Load(_)
1422 | pb::parent_request::Kind::Reset(_)
1423 | pb::parent_request::Kind::Shutdown(_)
1424 )
1425 )
1426 }
1427
1428 /// Reloads the session a relay shut down from `state`, what the relay said
1429 /// restores it, on a new worker and re-sends `request`, which the relay
1430 /// reported it did not run. Resumes at most once per request: `redial` is
1431 /// taken for the duration, so the turns this makes cannot resume again. Any
1432 /// failure reloading returns the original `Shutdown`, its cause the metric's outcome.
1433 async fn resume_after_shutdown(
1434 &mut self,
1435 state: Vec<u8>,
1436 request: &pb::ParentRequest,
1437 deadline: Option<Duration>,
1438 on_print: OnPrint<'_>,
1439 ) -> Result<ControlEvent, PoolError> {
1440 let redial = self.redial.take().expect("checked by can_resume");
1441 let reloaded = self.reload_session(&redial, &state).await;
1442 #[cfg(feature = "telemetry")]
1443 if let Some(metrics) = &self.pool.config.metrics {
1444 metrics.session_resumed(reloaded.as_ref().map_or_else(resume_failure, |()| "ok"));
1445 }
1446 let outcome = match reloaded {
1447 Ok(()) => {
1448 // as `feed_with_cwd`: the child restarts its feed clock on a
1449 // `Feed`, and the `Load` reply reported the dump's last feed
1450 if matches!(request.kind, Some(pb::parent_request::Kind::Feed(_))) {
1451 self.budget.begin_feed();
1452 }
1453 // the `Load` reply may have tightened the budget, so an
1454 // execution turn's backstop is re-derived as `expect_turn` did
1455 let deadline = if is_execution_turn(request) {
1456 min_deadline(self.pool.config.request_timeout, self.backstop_deadline())
1457 } else {
1458 deadline
1459 };
1460 self.request_turn(request, deadline, on_print).await
1461 }
1462 Err(_) => Err(PoolError::Shutdown { dump: Some(state) }),
1463 };
1464 self.redial = Some(redial);
1465 outcome
1466 }
1467
1468 /// Dials a new worker, re-creates the session and loads `state` (a session
1469 /// ID against a storing relay), which starts a new session under a new ID.
1470 /// The reply must match the state at the shutdown: `Ok` when idle, or the
1471 /// same pending suspension re-announced when mid-feed. The session's budget
1472 /// is kept, not re-adopted as `restore` does: it is the same session, so the
1473 /// reply can only tighten its limits, and a shutdown grants nothing.
1474 async fn reload_session(&mut self, redial: &Redial, state: &[u8]) -> Result<(), PoolError> {
1475 let configure = configure_request(&redial.repl);
1476 let worker = self.pool.acquire_worker(&redial.connect_headers).await?;
1477 #[cfg(feature = "telemetry")]
1478 let worker = worker.with_adapter_context(redial.telemetry.clone());
1479 self.worker = Some(worker);
1480 let expected = self.pending.as_ref().map(PendingKey::of);
1481 let timeout = self.pool.config.request_timeout;
1482 let mut no_print = on_print_sync(|_, _| {});
1483 match self.request_turn(&configure, timeout, &mut no_print).await? {
1484 ControlEvent::Ok => {}
1485 other => return Err(self.protocol_violation(format!("unexpected reply to Configure: {other:?}"))),
1486 }
1487 // a re-announced suspension counts and charges itself again on arrival,
1488 // as after `restore`: let it through on zeroed totals, then put the
1489 // session's true totals back
1490 let (suspensions_seen, sleep_used) = (self.budget.suspensions_seen, self.budget.sleep_used);
1491 self.budget.suspensions_seen = 0;
1492 self.budget.sleep_used = Duration::ZERO;
1493 let load = request(pb::parent_request::Kind::Load(pb::Load {
1494 state: state.to_vec().into(),
1495 }));
1496 let reply = self.request_turn(&load, timeout, &mut no_print).await;
1497 // the session's script name is the one it already had
1498 self.restored_script_name = None;
1499 let matches = match reply? {
1500 ControlEvent::Ok => expected.is_none(),
1501 ControlEvent::Turn(_) => expected.is_some() && expected == self.pending.as_ref().map(PendingKey::of),
1502 ControlEvent::Dump(_) => false,
1503 };
1504 self.budget.suspensions_seen = suspensions_seen;
1505 self.budget.sleep_used = sleep_used;
1506 if matches {
1507 #[cfg(feature = "telemetry")]
1508 if let Some(worker) = &mut self.worker {
1509 worker.mark_resumed();
1510 }
1511 Ok(())
1512 } else {
1513 Err(self.protocol_violation("the resumed session does not match the one shut down"))
1514 }
1515 }
1516
1517 /// Sends `request` and returns the child's turn-ending event, as protobuf —
1518 /// no conversion to [`TurnEvent`].
1519 ///
1520 /// For callers that already speak the wire (a relay bridging a remote
1521 /// client): rebuilding a `ChildEvent` frame from a [`TurnEvent`] means
1522 /// hand-inverting the whole protocol, so this hands back what the child
1523 /// actually sent. `on_event` sees each streamed `Print` before the
1524 /// turn-ender.
1525 ///
1526 /// **Bypasses this checkout's session bookkeeping** (`pending`,
1527 /// `feed_mounts`, `restored_script_name`, `session_id`) — never interleave
1528 /// with `feed`/`resume`/`restore`: the driver reads what a reply names from
1529 /// the frame it forwards. Worker lifecycle, poisoning and parent-side
1530 /// limits work as on the typed path; a raw `Load` re-adopts its budget like
1531 /// [`Checkout::restore`]. A `FatalError`
1532 /// (or WebSocket `ShutdownDump`) turn-ender is returned so the driver can
1533 /// forward it, but discards the worker first — later calls report
1534 /// [`PoolError::Finished`]; a raw driver is never auto-resumed.
1535 ///
1536 /// # Security
1537 /// `request` is typically a remote client's, so it is treated as hostile:
1538 /// `Configure`/`Reset`/`Shutdown` are refused here (a client could
1539 /// otherwise `Reset` away the operator-chosen resource limits and
1540 /// re-`Configure` its own). A `Load`'s bytes DO reach the worker's
1541 /// deserialiser — the driver must verify a dump is one it issued
1542 /// (monty-server loads only records it stored and signed) before passing
1543 /// it in.
1544 ///
1545 /// # Errors
1546 /// As [`Checkout::feed`]: a dead worker, a protocol violation, or a turn
1547 /// that outlived `request_timeout` or the remaining feed/turn budget.
1548 pub async fn turn_raw(
1549 &mut self,
1550 request: &pb::ParentRequest,
1551 on_event: OnRawEvent<'_>,
1552 ) -> Result<pb::ChildEvent, PoolError> {
1553 self.ensure_ready()?;
1554 // A raw client could otherwise `Reset` the child back to its default
1555 // (unlimited) session budget and re-`Configure` with its own limits.
1556 // Caller misuse, so the worker stays usable.
1557 if matches!(
1558 request.kind,
1559 None | Some(
1560 pb::parent_request::Kind::Configure(_)
1561 | pb::parent_request::Kind::Reset(_)
1562 | pb::parent_request::Kind::Shutdown(_)
1563 )
1564 ) {
1565 return Err(PoolError::Protocol(
1566 "lifecycle requests (Configure/Reset/Shutdown) cannot be driven through turn_raw".into(),
1567 ));
1568 }
1569 // As `restore`: forget the Configure-time budget and re-adopt the
1570 // dump's from the reply's budget fields (see `pending_load_budget`).
1571 let is_load = matches!(request.kind, Some(pb::parent_request::Kind::Load(_)));
1572 if is_load {
1573 self.begin_load();
1574 }
1575 // A raw `Feed` restarts the child's feed clock exactly as `feed` does,
1576 // so the parent's must follow it rather than carry the last feed's
1577 // total into this feed's backstop.
1578 if matches!(request.kind, Some(pb::parent_request::Kind::Feed(_))) {
1579 self.budget.begin_feed();
1580 }
1581 self.turn_in_flight = true;
1582 // as `expect_turn`: `request_timeout` alone would drop the duration
1583 // backstops, leaving a wedged child bounded by a timeout that may be unset
1584 let deadline = min_deadline(self.pool.config.request_timeout, self.backstop_deadline());
1585 self.armed_deadline = deadline;
1586 let outcome = match deadline {
1587 Some(limit) => match timeout(limit, self.turn_io_raw(request, on_event)).await {
1588 Ok(outcome) => outcome,
1589 Err(_elapsed) => Err(self.poison_timeout().await),
1590 },
1591 None => self.turn_io_raw(request, on_event).await,
1592 };
1593 self.turn_in_flight = false;
1594 if is_load {
1595 self.end_load();
1596 }
1597 outcome
1598 }
1599
1600 /// The body of [`Self::turn_raw`]: send, stream, return the turn-ender.
1601 ///
1602 /// Mirrors `turn_io`'s failure handling, but events are returned rather
1603 /// than classified — including `FatalError`/`Shutdown`, which discard the
1604 /// worker yet still hand the frame back.
1605 async fn turn_io_raw(
1606 &mut self,
1607 request: &pb::ParentRequest,
1608 on_event: OnRawEvent<'_>,
1609 ) -> Result<pb::ChildEvent, PoolError> {
1610 self.send_request(request).await?;
1611 loop {
1612 let mut event = match self.worker.as_mut().expect("checked by send_request").recv().await {
1613 Ok(event) => event,
1614 Err(FrameError::Decode(err)) => {
1615 return Err(self.protocol_violation(format!("invalid payload from worker: {err}")));
1616 }
1617 Err(_) => return Err(self.poison("waiting for a reply").await),
1618 };
1619 if self.abort_if_over_budget(&mut event).await? {
1620 continue;
1621 }
1622 // strict alternation: zero or more `Print`s, then exactly one
1623 // turn-ender — so anything that is not a print ends the turn
1624 if matches!(event.kind, Some(pb::child_event::Kind::Print(_))) {
1625 on_event(&event).await;
1626 } else if matches!(event.kind, Some(pb::child_event::Kind::FatalError(_))) {
1627 // the far end is gone (see `fatal_error`): discard the worker
1628 // as the typed path does, but still hand back the child's own
1629 // account of its death for the driver to forward
1630 self.reap_announced_exit().await;
1631 return Ok(event);
1632 } else if matches!(event.kind, Some(pb::child_event::Kind::Shutdown(_))) {
1633 return if self.pool.config.transport.is_websocket() {
1634 // the serving relay is gone: discard as `turn_io` does,
1635 // handing the frame (and its dump) back to forward
1636 self.discard_worker();
1637 Ok(event)
1638 } else {
1639 // Only a serving relay sends this, never a child — and a
1640 // raw driver's relay signs dumps on the way past, so
1641 // accepting one would have it vouch for bytes its child
1642 // minted.
1643 Err(self.protocol_violation("subprocess worker sent a ShutdownDump"))
1644 };
1645 } else if event.kind.is_none() {
1646 // every proto field is optional, so a hostile worker can send
1647 // an event with no kind set; it ends no turn and must not
1648 // reach the driver's client as one
1649 return Err(self.protocol_violation("worker sent an event with no kind"));
1650 } else {
1651 return Ok(event);
1652 }
1653 }
1654 }
1655
1656 /// Fails fast when the checkout cannot start protocol work: the worker is
1657 /// gone (`Finished`), or a previous turn / mount servicing was cancelled
1658 /// mid-flight — the worker can no longer be trusted, so it is discarded
1659 /// here. Every public operation must hit this before its own validation,
1660 /// so cancellation surfaces as the contract's error, not a state check.
1661 fn ensure_ready(&mut self) -> Result<(), PoolError> {
1662 if self.worker.is_none() {
1663 Err(PoolError::Finished)
1664 } else if self.turn_in_flight {
1665 self.discard_worker();
1666 Err(PoolError::Protocol(
1667 "a previous protocol turn was cancelled mid-flight; the worker was discarded".into(),
1668 ))
1669 } else {
1670 Ok(())
1671 }
1672 }
1673
1674 /// The innermost telemetry context for host callbacks answering this checkout.
1675 #[cfg(feature = "telemetry")]
1676 pub fn callback_context(&self) -> opentelemetry::Context {
1677 self.worker
1678 .as_ref()
1679 .map_or_else(opentelemetry::Context::new, Worker::callback_context)
1680 }
1681
1682 /// One request/reply exchange: send the request, stream prints, classify
1683 /// the turn-ending event. All failure paths discard the worker except
1684 /// `Runtime` / `Typing`, which are sandbox-level outcomes.
1685 async fn turn_io(&mut self, request: &pb::ParentRequest, on_print: OnPrint<'_>) -> Result<ControlEvent, PoolError> {
1686 self.send_request(request).await?;
1687 loop {
1688 let mut event = match self.worker.as_mut().expect("checked by send_request").recv().await {
1689 Ok(event) => event,
1690 // a decode failure means the frame arrived intact but its
1691 // payload was garbage (including values that fail semantic
1692 // validation, which happens during decode) — the worker
1693 // misbehaved, it didn't die
1694 Err(FrameError::Decode(err)) => {
1695 return Err(self.protocol_violation(format!("invalid payload from worker: {err}")));
1696 }
1697 Err(_) => return Err(self.poison("waiting for a reply").await),
1698 };
1699 // Only a storing relay sets this, on its reply to `Configure` or
1700 // `Load`. Taken before the budget check: a re-announced suspension
1701 // over budget is aborted and dropped, but the session it names lives on.
1702 if let Some(session_id) = event.session_id.take() {
1703 self.session_id = Some(session_id.into_inner());
1704 }
1705 // a suspension past `max_suspensions` is aborted here and never
1706 // reaches the caller; the abort's reply is the next event
1707 if self.abort_if_over_budget(&mut event).await? {
1708 continue;
1709 }
1710 // Only a `Load` reply carries this; it lets `restore` report the
1711 // dump's script name without parsing the opaque dump bytes.
1712 if let Some(name) = &event.restored_script_name {
1713 self.restored_script_name = Some(name.clone());
1714 }
1715 match event.kind {
1716 Some(pb::child_event::Kind::Print(print)) => {
1717 #[cfg(feature = "telemetry")]
1718 let context = self.callback_context();
1719 // One event can carry several runs: hand each to the host
1720 // in order, so the callback shape stays per-stream.
1721 for segment in &print.segments {
1722 let stream = match segment.stream() {
1723 pb::PrintStream::Stderr => PrintStream::Stderr,
1724 pb::PrintStream::Stdout | pb::PrintStream::Unspecified => PrintStream::Stdout,
1725 };
1726 let future = {
1727 #[cfg(feature = "telemetry")]
1728 let _context = context.has_active_span().then(|| context.clone().attach());
1729 on_print(stream, &segment.text)
1730 };
1731 #[cfg(feature = "telemetry")]
1732 if context.has_active_span() {
1733 future.with_context(context.clone()).await;
1734 } else {
1735 future.await;
1736 }
1737 #[cfg(not(feature = "telemetry"))]
1738 future.await;
1739 }
1740 }
1741 Some(pb::child_event::Kind::FunctionCall(mut call)) => {
1742 let position = suspension_position(call.position.take());
1743 self.pending = Some(Pending::Call {
1744 call_id: call.call_id,
1745 function_name: call.function_name.clone(),
1746 os_call: None,
1747 allow_eager_await: call.allow_eager_await,
1748 });
1749 return self.convert_turn(|| {
1750 Ok(TurnEvent::FunctionCall {
1751 function_name: call.function_name.clone(),
1752 call_id: call.call_id,
1753 object_id: call.object_id,
1754 allow_eager_await: call.allow_eager_await,
1755 args: call.into_call_args()?,
1756 position,
1757 })
1758 });
1759 }
1760 Some(pb::child_event::Kind::OsCall(mut call)) => {
1761 // Every announcement (fresh or re-announced after
1762 // `restore`) decodes into a typed `OsFunctionCall`; a
1763 // payload the child could never legitimately produce is a
1764 // protocol violation.
1765 let position = suspension_position(call.position.take());
1766 let mut allow_eager_await = call.allow_eager_await;
1767 let (call_id, function_call) = match os_call_from_proto(call) {
1768 Ok(call) => call,
1769 Err(err) => {
1770 return Err(self.protocol_violation(format!("invalid OS call payload: {err}")));
1771 }
1772 };
1773 // The caller can answer any OS call or use `resume_from_mounts`.
1774 // Retain the raw typed call for mount validation; `to_args`
1775 // normalizes only the clone presented to callbacks.
1776 let function_name = function_call.name().to_owned();
1777 // The child is untrusted: an eager bit on a call no future
1778 // may answer is dropped rather than exposed.
1779 allow_eager_await = allow_eager_await && OsFunctionCall::accepts_future(function_call.name());
1780 // Enforce the parent's ceiling even if the worker ignored its own.
1781 let system_sleep = match function_call {
1782 OsFunctionCall::SystemSleep(delay) | OsFunctionCall::AsyncSystemSleep(delay) => {
1783 Some(self.budget.cap_system_sleep(delay))
1784 }
1785 _ => None,
1786 };
1787 let args = function_call.clone().to_args();
1788 self.pending = Some(Pending::Call {
1789 call_id,
1790 function_name: function_name.clone(),
1791 os_call: Some(Box::new(function_call)),
1792 allow_eager_await,
1793 });
1794 return Ok(ControlEvent::Turn(TurnEvent::OsCall {
1795 function_name,
1796 args,
1797 call_id,
1798 allow_eager_await,
1799 system_sleep,
1800 position,
1801 }));
1802 }
1803 Some(pb::child_event::Kind::NameLookup(lookup)) => {
1804 // Frames from the child are untrusted — a malformed uuid
1805 // is a protocol violation, not a panic.
1806 let position = suspension_position(lookup.position);
1807 let object_id = match lookup.object_id {
1808 None => None,
1809 Some(uuid) => match MontyUuid::try_from_slice(&uuid.data) {
1810 Some(uuid) => Some(uuid),
1811 None => {
1812 return Err(self.protocol_violation("NameLookup.object_id is not a 16-byte uuid"));
1813 }
1814 },
1815 };
1816 self.pending = Some(Pending::NameLookup {
1817 name: lookup.name.clone(),
1818 object_id,
1819 });
1820 return Ok(ControlEvent::Turn(TurnEvent::NameLookup {
1821 name: lookup.name,
1822 object_id,
1823 position,
1824 }));
1825 }
1826 Some(pb::child_event::Kind::ResolveFutures(futures)) => {
1827 let position = suspension_position(futures.position);
1828 let pending_call_ids = futures.pending_call_ids.into_inner();
1829 self.pending = Some(Pending::Futures {
1830 pending_call_ids: pending_call_ids.clone(),
1831 });
1832 return Ok(ControlEvent::Turn(TurnEvent::ResolveFutures {
1833 pending_call_ids,
1834 position,
1835 }));
1836 }
1837 Some(pb::child_event::Kind::Complete(complete)) => {
1838 self.pending = None;
1839 // the feed is over — drop its mounts so overlay writes
1840 // cannot leak into the next feed
1841 self.feed_mounts = None;
1842 return self.convert_turn(|| Ok(TurnEvent::Complete(MontyObject::try_from(complete)?)));
1843 }
1844 Some(pb::child_event::Kind::Error(error)) => {
1845 // an error reply to `Dump` (e.g. an oversize dump) does not
1846 // end the in-flight feed — the child stays suspended and
1847 // resumable, so keep the pending call and mounts
1848 if !matches!(request.kind, Some(pb::parent_request::Kind::Dump(_))) {
1849 self.pending = None;
1850 self.feed_mounts = None;
1851 }
1852 let Some(exception) = error.exception else {
1853 return Err(self.protocol_violation("error event with no exception"));
1854 };
1855 return match MontyException::try_from(exception) {
1856 Ok(exc) => Err(PoolError::Runtime(exc)),
1857 Err(err) => Err(self.protocol_violation(format!("invalid exception payload: {err}"))),
1858 };
1859 }
1860 Some(pb::child_event::Kind::TypingError(typing)) => {
1861 // TypingError can only end a checked feed or its pending import_stubs call.
1862 // Accepting it elsewhere would falsely mark a suspended feed complete.
1863 let type_checked = match &request.kind {
1864 Some(pb::parent_request::Kind::Feed(feed)) => !feed.skip_type_check,
1865 Some(pb::parent_request::Kind::ResumeCall(resume)) => resume.call_id == IMPORT_STUBS_CALL_ID,
1866 _ => false,
1867 };
1868 if !type_checked {
1869 return Err(
1870 self.protocol_violation("TypingError reply to a request that is not a type-checked Feed")
1871 );
1872 }
1873 self.pending = None;
1874 self.feed_mounts = None;
1875 return Err(PoolError::Typing(typing.diagnostics));
1876 }
1877 Some(pb::child_event::Kind::Ok(_)) => return Ok(ControlEvent::Ok),
1878 Some(pb::child_event::Kind::DumpResult(dump)) => {
1879 return Ok(ControlEvent::Dump(dump.state.into_inner()));
1880 }
1881 Some(pb::child_event::Kind::FatalError(fatal)) => {
1882 return Err(self.fatal_error(&fatal.message).await);
1883 }
1884 Some(pb::child_event::Kind::Shutdown(shutdown)) => {
1885 // Only a serving relay sends this, never a child — so a
1886 // local subprocess claiming to shut down is a protocol
1887 // violation, not a shutdown. Accepting it would let the
1888 // child hand back a dump it minted itself, which the
1889 // caller would then restore as trusted session state.
1890 if !self.pool.config.transport.is_websocket() {
1891 return Err(self.protocol_violation("subprocess worker sent a ShutdownDump"));
1892 }
1893 // The serving side is shutting down and dropped this
1894 // request unexecuted; its worker is gone.
1895 //
1896 // NOTE(sign-dump): `dump` is server-minted and travels
1897 // back through this client into `Checkout::restore`; if
1898 // host-side dump signing lands it must pass the same
1899 // verification there as any other dump.
1900 //
1901 // The session state stays for `request_turn` to resume or discard.
1902 self.release_worker();
1903 return Err(PoolError::Shutdown {
1904 dump: shutdown.dump.map(Into::into),
1905 });
1906 }
1907 None => {
1908 return Err(self.protocol_violation("unexpected event"));
1909 }
1910 }
1911 }
1912 }
1913
1914 /// Sends `request`, recording in `request_sent` whether it reached the
1915 /// worker. An oversize frame is rejected *before* any bytes are written,
1916 /// so the worker never saw the request and is still synced — surface a
1917 /// clean, catchable error instead of discarding a healthy worker as if it
1918 /// had crashed. For a `resume*` request this also leaves `pending` set
1919 /// (nothing overwrites it on this path), so the suspension stays
1920 /// answerable with a smaller value. Every other send failure is a real
1921 /// I/O break (dead worker / closed pipe).
1922 async fn send_request(&mut self, request: &pb::ParentRequest) -> Result<(), PoolError> {
1923 self.request_sent = false;
1924 let Some(worker) = self.worker.as_mut() else {
1925 return Err(PoolError::Finished);
1926 };
1927 match worker.send(request).await {
1928 Ok(()) => {
1929 self.request_sent = true;
1930 Ok(())
1931 }
1932 Err(FrameError::FrameTooLarge { len, max }) => Err(PoolError::Runtime(MontyException::new(
1933 ExcType::RuntimeError,
1934 Some(format!(
1935 "request frame of {len} bytes exceeds the maximum of {max} bytes"
1936 )),
1937 ))),
1938 Err(_) => Err(self.poison("sending a request").await),
1939 }
1940 }
1941
1942 /// Runs a fallible payload conversion; conversion failures mean the
1943 /// worker sent garbage, which discards it.
1944 fn convert_turn(
1945 &mut self,
1946 convert: impl FnOnce() -> Result<TurnEvent, monty_proto::ProtoConvertError>,
1947 ) -> Result<ControlEvent, PoolError> {
1948 match convert() {
1949 Ok(event) => Ok(ControlEvent::Turn(event)),
1950 Err(err) => Err(self.protocol_violation(format!("invalid payload from worker: {err}"))),
1951 }
1952 }
1953
1954 /// Builds this feed's mount table, or `None` for the common mount-less
1955 /// feed. Runs inline: the specs' directories were opened when the caller
1956 /// built them, so nothing here touches the host filesystem. Fails
1957 /// (session-preserving) when two specs' host directories overlap.
1958 fn build_feed_mounts(mounts: Vec<MountSpec>) -> Result<Option<MountTable>, PoolError> {
1959 (!mounts.is_empty()).then(|| build_mount_table(mounts)).transpose()
1960 }
1961
1962 /// Runs blocking host mount work on tokio's blocking pool, so a stalled
1963 /// filesystem (NFS, FUSE) occupies a blocking thread instead of a runtime
1964 /// worker shared with other sessions' turns and timers. A blocking-task
1965 /// failure (panic / runtime shutdown) discards the worker — callers'
1966 /// error contracts promise that any non-`Runtime`/`Typing` error means
1967 /// exactly that.
1968 async fn run_blocking<T: Send + 'static>(
1969 &mut self,
1970 work: impl FnOnce() -> T + Send + 'static,
1971 ) -> Result<T, PoolError> {
1972 match spawn_blocking(work).await {
1973 Ok(value) => Ok(value),
1974 Err(err) => Err(self.protocol_violation(format!("blocking mount task failed: {err}"))),
1975 }
1976 }
1977
1978 /// Discards the worker after it violated the protocol on an intact stream
1979 /// (unexpected event kind, undecodable payload). Unlike [`Self::poison`]
1980 /// this is not a crash — the worker answered, just wrongly — so it maps
1981 /// to [`PoolError::Protocol`] rather than `Crashed`/`Timeout`.
1982 fn protocol_violation(&mut self, context: impl Into<Cow<'static, str>>) -> PoolError {
1983 self.discard_worker();
1984 PoolError::Protocol(context.into())
1985 }
1986
1987 /// Discards the worker after it announced a `FatalError`.
1988 ///
1989 /// The frame arrives on an intact stream, but it means the far end is
1990 /// *gone*: a child writes it and exits immediately (version skew, frame
1991 /// desync, panic), and a serving relay uses it to report that it could not
1992 /// start a worker at all. That is a crash with a reason attached rather
1993 /// than a protocol disagreement, so it is reported as one — a caller that
1994 /// handles crashes by starting a new session needs no extra arm, and the
1995 /// worker's own account lands in [`CrashCause::Announced`].
1996 async fn fatal_error(&mut self, message: &str) -> PoolError {
1997 let status = self.reap_announced_exit().await;
1998 self.pending = None;
1999 self.feed_mounts = None;
2000 PoolError::Crashed {
2001 status,
2002 cause: CrashCause::Announced {
2003 reason: message.to_owned(),
2004 },
2005 }
2006 }
2007
2008 /// Takes and reaps the worker behind a `FatalError` frame, returning its
2009 /// exit status when one was observed. Shared by [`Self::fatal_error`] and
2010 /// the raw path, which discards the worker but returns the frame itself.
2011 async fn reap_announced_exit(&mut self) -> Option<ExitStatus> {
2012 match self.worker.take() {
2013 // the child exits right after the frame, so give it a moment to do
2014 // so before killing: that is what surfaces e.g. the non-zero status
2015 // of a version-skew exit, which a SIGKILL would replace with the
2016 // signal and lose
2017 Some(mut worker) => {
2018 #[cfg(feature = "telemetry")]
2019 self.record_finish("error");
2020 // guard, not a trailing release: a caller dropping this future
2021 // mid-reap must still release the slot and count the death
2022 let _capacity = CapacityGuard::terminating(&self.pool, "fatal");
2023 let status = worker.reap_or_kill(FATAL_EXIT_GRACE).await;
2024 drop(worker);
2025 status
2026 }
2027 None => None,
2028 }
2029 }
2030
2031 /// Discards the worker after an I/O failure and classifies it as an
2032 /// out-of-memory kill, a crash, or — on the WebSocket transport, where the
2033 /// worker is a remote process this client cannot reap — a disconnect.
2034 /// Deadline expiry goes through [`Self::poison_timeout`] instead.
2035 async fn poison(&mut self, context: &str) -> PoolError {
2036 let Some(mut worker) = self.worker.take() else {
2037 return PoolError::Finished;
2038 };
2039 #[cfg(feature = "telemetry")]
2040 self.record_finish("error");
2041 self.pending = None;
2042 self.feed_mounts = None;
2043 let websocket = self.pool.config.transport.is_websocket();
2044 // guard, not a trailing release: a caller dropping this future
2045 // mid-reap must still release the slot and count the death. Its reason
2046 // is what the classification below would have said without an exit
2047 // status, so a cancelled reap of an out-of-memory worker is the one
2048 // case counted as a plain crash.
2049 let mut capacity = CapacityGuard::terminating(&self.pool, if websocket { "disconnected" } else { "crash" });
2050 // A worker that exits deliberately (an allocation refused, see
2051 // `OOM_EXIT_CODE`) is racing us: SIGKILLing it mid-exit would replace
2052 // its code with `signal: 9` and lose the classification. Give it the
2053 // same grace `fatal_error` does — a dead child reaps on the first poll,
2054 // and only a wedged-alive one pays for it, on an already-failed turn.
2055 // A deadline expiry never lands here (see `poison_timeout`), so nothing
2056 // is waiting on this grace that should have been killed outright.
2057 let status = worker.reap_or_kill(FATAL_EXIT_GRACE).await;
2058 drop(worker);
2059 if websocket {
2060 PoolError::Disconnected {
2061 context: context.to_owned(),
2062 }
2063 } else if status.and_then(|status| status.code()) == Some(monty_types::OOM_EXIT_CODE) {
2064 // the worker is gone, unlike every other `Runtime` error — the
2065 // checkout is already finished, so later calls report `Finished`
2066 capacity.set_reason("oom");
2067 PoolError::Runtime(MontyException::new(
2068 ExcType::MemoryError,
2069 Some("the worker exceeded its memory limit and was terminated".to_owned()),
2070 ))
2071 } else {
2072 PoolError::Crashed {
2073 status,
2074 cause: CrashCause::Vanished {
2075 context: context.to_owned(),
2076 },
2077 }
2078 }
2079 }
2080
2081 /// Discards the worker after the turn deadline expired: the turn's I/O
2082 /// future has already been dropped (safe — `Worker::recv` is cancel-safe),
2083 /// so this only kills, reaps, and classifies.
2084 async fn poison_timeout(&mut self) -> PoolError {
2085 if let Some(mut worker) = self.worker.take() {
2086 #[cfg(feature = "telemetry")]
2087 self.record_finish("error");
2088 // guard, not a trailing release: a caller dropping this future
2089 // mid-reap must still release the slot and count the death
2090 let _capacity = CapacityGuard::terminating(&self.pool, "turn_timeout");
2091 let _ = worker.kill_and_reap().await;
2092 drop(worker);
2093 }
2094 self.pending = None;
2095 self.feed_mounts = None;
2096 PoolError::Timeout {
2097 timeout: self.armed_deadline.unwrap_or(Duration::ZERO),
2098 }
2099 }
2100
2101 /// Discards the worker without crash classification, for turn-enders
2102 /// that are not crashes: protocol violations (the worker answered, just
2103 /// wrongly) and server `Shutdown` replies. Fatal errors instead go
2104 /// through [`Self::fatal_error`], which reaps and classifies.
2105 fn discard_worker(&mut self) {
2106 #[cfg(feature = "telemetry")]
2107 if self.worker.is_some() {
2108 self.record_finish("error");
2109 }
2110 self.release_worker();
2111 self.pending = None;
2112 self.feed_mounts = None;
2113 self.turn_in_flight = false;
2114 self.abort_in_flight = false;
2115 }
2116
2117 /// Drops the worker and frees its pool slot, leaving the session state to
2118 /// the caller: a session the relay shut down may yet resume on another worker.
2119 fn release_worker(&mut self) {
2120 if let Some(worker) = self.worker.take() {
2121 drop(worker);
2122 self.pool.count_termination("discarded");
2123 self.pool.release_capacity();
2124 }
2125 }
2126}
2127
2128impl Drop for Checkout {
2129 fn drop(&mut self) {
2130 // a checkout abandoned mid-session cannot be trusted back into the
2131 // pool: kill the worker and free its capacity
2132 if let Some(worker) = self.worker.take() {
2133 drop(worker);
2134 self.pool.count_termination("abandoned");
2135 self.pool.release_capacity();
2136 }
2137 #[cfg(feature = "telemetry")]
2138 self.record_finish("abandoned");
2139 }
2140}
2141
2142/// Builds the `Configure` that creates `repl`'s session on a fresh worker.
2143fn configure_request(repl: &ReplConfig) -> pb::ParentRequest {
2144 request(pb::parent_request::Kind::Configure(pb::Configure {
2145 script_name: repl.script_name.clone(),
2146 limits: repl.limits.as_ref().map(Into::into),
2147 type_check: repl.type_check,
2148 type_check_stubs: repl.type_check_stubs.clone(),
2149 type_check_format: pb::TypeCheckFormat::from(repl.type_check_config.format).into(),
2150 type_check_color: repl.type_check_config.color,
2151 assert_message_annotations: Some(repl.assert_message_annotations.max_bytes()),
2152 // What the child actually checks: it rejects a version outside the
2153 // range it serves with a `FatalError`. Relevant whenever the worker
2154 // is not the binary this crate ships — a system-packaged `monty`,
2155 // or a remote worker reached over a socket.
2156 protocol_version: PROTOCOL_VERSION,
2157 // Diagnostic only, so a rejection can report both builds.
2158 monty_version: MONTY_VERSION.to_owned(),
2159 print_flush_interval_ms: repl.print_flush_interval.map(flush_interval_ms),
2160 os_policy: Some((&repl.os_policy).into()),
2161 persistence: pb::Persistence::from(repl.persistence).into(),
2162 profile: repl.profile.clone(),
2163 }))
2164}
2165
2166/// The `monty.pool.session.resumed` outcome for a failed reload: `exhausted`
2167/// (no pool capacity), `disconnected` (the new connection failed), `refused`
2168/// (the relay rejected the load), `shutdown` (the new relay is shutting down
2169/// too), `mismatch` (the reloaded state is not the one shut down) or `timeout`.
2170#[cfg(feature = "telemetry")]
2171fn resume_failure(err: &PoolError) -> &'static str {
2172 match err {
2173 PoolError::Exhausted => "exhausted",
2174 PoolError::Disconnected { .. } | PoolError::Spawn(_) => "disconnected",
2175 PoolError::Crashed { .. } | PoolError::Runtime(_) | PoolError::Typing(_) => "refused",
2176 PoolError::Shutdown { .. } => "shutdown",
2177 PoolError::Protocol(_) => "mismatch",
2178 PoolError::Timeout { .. } => "timeout",
2179 PoolError::Finished => "error",
2180 }
2181}
2182
2183/// Encodes a print flush interval as whole milliseconds for the wire.
2184///
2185/// Zero is the explicit line-buffering sentinel, so a *positive* interval must
2186/// never round down into it — anything under a millisecond is sent as 1 ms.
2187/// Saturates at the top: an interval past `u32::MAX` milliseconds is absurd
2188/// rather than meaningful, and the turn-end flush bounds it anyway.
2189fn flush_interval_ms(interval: Duration) -> u32 {
2190 if interval.is_zero() {
2191 0
2192 } else {
2193 u32::try_from(interval.as_millis()).unwrap_or(u32::MAX).max(1)
2194 }
2195}
2196
2197/// Internal classification of a turn-ending event: real turn events for the
2198/// caller, plus the control acks (`Ok` / `DumpResult`) used by the checkout
2199/// lifecycle itself.
2200#[derive(Debug)]
2201enum ControlEvent {
2202 Turn(TurnEvent),
2203 Ok,
2204 Dump(Vec<u8>),
2205}
2206
2207/// How long a child that announced a `FatalError` is given to exit on its own
2208/// before it is killed. It writes the frame and exits immediately, so this is
2209/// a scheduling allowance rather than a real wait — and it is only ever spent
2210/// on a turn that has already failed.
2211const FATAL_EXIT_GRACE: Duration = Duration::from_millis(100);
2212
2213/// Wraps a request kind in a `ParentRequest`.
2214///
2215/// The one place the pool builds a request, so `ParentRequest`'s
2216/// message-level fields are decided here rather than at every callsite.
2217/// `trace_parent` is left unset: the pool does not propagate a tracing
2218/// context yet, and an absent one just means the child starts its own trace.
2219pub(crate) fn request(kind: pb::parent_request::Kind) -> pb::ParentRequest {
2220 pb::ParentRequest {
2221 kind: Some(kind),
2222 trace_parent: None,
2223 }
2224}
2225
2226/// Converts a shared requirement-validation failure into a session-preserving
2227/// Python `ValueError`.
2228fn invalid_requirement(message: String) -> PoolError {
2229 PoolError::Runtime(MontyException::new(ExcType::ValueError, Some(message)))
2230}
2231
2232/// The tighter of two optional deadlines (`None` means no deadline).
2233fn min_deadline(a: Option<Duration>, b: Option<Duration>) -> Option<Duration> {
2234 match (a, b) {
2235 (Some(a), Some(b)) => Some(a.min(b)),
2236 (deadline, None) | (None, deadline) => deadline,
2237 }
2238}
2239/// Checks an explicit working directory with [`validate_cwd`], raising its
2240/// message as a session-preserving `ValueError`.
2241fn checked_cwd(cwd: &str) -> Result<String, PoolError> {
2242 validate_cwd(cwd).map_err(|message| PoolError::Runtime(MontyException::new(ExcType::ValueError, Some(message))))
2243}
2244
2245/// Builds the parent-side [`MountTable`] for one feed from its (non-empty)
2246/// specs. Free of filesystem I/O: each spec already carries its opened
2247/// directory, so this only pairs those descriptors with a per-feed mode. Specs
2248/// that overlap (see [`MountTable::push_mount`]) are rejected here — as a
2249/// session-preserving [`PoolError::Runtime`], since specs are built
2250/// independently and only meet at feed time.
2251fn build_mount_table(mounts: Vec<MountSpec>) -> Result<MountTable, PoolError> {
2252 let mut table = MountTable::new();
2253 for mount in mounts {
2254 let mode = match mount.mode {
2255 MountSpecMode::ReadOnly => MountMode::ReadOnly,
2256 MountSpecMode::ReadWrite => MountMode::ReadWrite,
2257 // Overlay state is created fresh per feed: writes live only as
2258 // long as the feed and are discarded with it.
2259 MountSpecMode::Overlay => MountMode::OverlayMemory(OverlayState::new()),
2260 };
2261 // No filesystem access: the root was opened when the spec was built.
2262 let mount = monty_fs::Mount::with_root(mount.root, mode, mount.write_bytes_limit)
2263 .with_memory_usage_limit(mount.memory_usage_limit);
2264 table
2265 .push_mount(mount)
2266 .map_err(|err| PoolError::Runtime(err.into_exception()))?;
2267 }
2268 Ok(table)
2269}
2270
2271/// Decodes a suspension event's position, wire or already decoded. A child that
2272/// predates the field sends none, which reads as [`SourceRange::unknown`] rather than an error.
2273fn suspension_position(position: Option<impl Into<SourceRange>>) -> SourceRange {
2274 position.map_or_else(SourceRange::unknown, Into::into)
2275}