Skip to main content

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}