Skip to main content

vsh_monty/
worker.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3use std::process::{Child, ChildStdin, Command, Stdio};
4use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
5use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
6use std::sync::{Arc, Mutex, MutexGuard};
7use std::thread::{self, JoinHandle};
8use std::time::{Duration, Instant};
9
10use monty_proto::{MAX_FRAME_LEN, PROTOCOL_VERSION, decode_frame, pb, write_frame};
11use monty_types::MontyException;
12use vsh_types::RuntimeConfigDigest;
13use vsh_vfs::{EffectOrigin, VirtualFs};
14
15use super::{
16    Budget, DeniedAccess, ExecutionError, ExecutionLimitExceeded, ExecutionOutcome,
17    InProcessConfig, MontyFailurePhase, WorkerFailure, WorkerFailureKind, call_result,
18    dispatch_call, encode_string, exception_bytes, limit_error, measure_result, tools,
19};
20
21const EXPECTED_MONTY_VERSION: &str = "0.0.22";
22const MAX_WORKER_DIAGNOSTIC_BYTES: usize = 4 * 1024;
23const VERSION_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
24const FRAME_OVERHEAD_BYTES: usize = 64 * 1024;
25const MAX_WORKER_EVENTS_PER_EXECUTION: u64 = 16_384;
26
27fn monty_tool_inputs() -> Vec<pb::NamedValue> {
28    let (names, values) = tools::inputs();
29    names
30        .into_iter()
31        .zip(values)
32        .map(|(name, value)| pb::NamedValue {
33            name,
34            value: Some(value.into()),
35        })
36        .collect()
37}
38
39/// Configuration for the crash-isolated, typed Monty subprocess adapter.
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct SubprocessConfig {
42    pub(super) adapter: InProcessConfig,
43    worker_path: PathBuf,
44    wall_timeout_override: Option<Duration>,
45    max_idle_workers: usize,
46}
47
48impl SubprocessConfig {
49    /// Bind the adapter to an exact `vsh-monty-worker` 0.0.22 executable.
50    #[must_use]
51    pub fn new(worker_path: impl Into<PathBuf>, adapter: InProcessConfig) -> Self {
52        Self {
53            adapter,
54            worker_path: worker_path.into(),
55            wall_timeout_override: None,
56            max_idle_workers: 4,
57        }
58    }
59
60    /// Replace the parent wall-clock watchdog for one complete execution.
61    #[must_use]
62    pub const fn with_wall_timeout(mut self, wall_timeout: Duration) -> Self {
63        self.wall_timeout_override = Some(wall_timeout);
64        self
65    }
66
67    /// Bound clean reset workers retained for warm reuse. Zero disables pooling.
68    #[must_use]
69    pub const fn with_max_idle_workers(mut self, max_idle_workers: usize) -> Self {
70        self.max_idle_workers = max_idle_workers;
71        self
72    }
73
74    /// Return the exact configured worker executable path.
75    #[must_use]
76    pub fn worker_path(&self) -> &Path {
77        &self.worker_path
78    }
79
80    /// Return the shared typed-call and sandbox configuration.
81    #[must_use]
82    pub const fn adapter(&self) -> &InProcessConfig {
83        &self.adapter
84    }
85
86    /// Hash every behavior-affecting worker setting for transaction binding.
87    #[must_use]
88    pub fn security_digest(&self) -> RuntimeConfigDigest {
89        self.security_digest_for(&self.adapter)
90    }
91
92    /// Hash the worker boundary together with one request's typed adapter settings.
93    #[must_use]
94    pub fn security_digest_for(&self, adapter: &InProcessConfig) -> RuntimeConfigDigest {
95        let mut canonical = Vec::new();
96        encode_string("vsh-monty-subprocess-v1", &mut canonical);
97        canonical.extend_from_slice(adapter.security_digest().as_bytes());
98        encode_string("monty-runtime-0.0.22", &mut canonical);
99        canonical.extend_from_slice(&self.wall_timeout(adapter).as_nanos().to_le_bytes());
100        RuntimeConfigDigest::digest_canonical(&canonical)
101    }
102
103    fn wall_timeout(&self, adapter: &InProcessConfig) -> Duration {
104        self.wall_timeout_override.unwrap_or_else(|| {
105            adapter
106                .limits
107                .max_duration
108                .saturating_add(Duration::from_secs(1))
109        })
110    }
111}
112
113/// A short-lock worker pool backed by Monty's official typed subprocess protocol.
114///
115/// The pool mutex is held only while popping or returning an idle process. Monty
116/// bytecode, typed VFS calls, result conversion, and reset never execute under it.
117#[derive(Debug)]
118pub struct SubprocessMonty {
119    config: SubprocessConfig,
120    idle: Mutex<Vec<Worker>>,
121}
122
123impl SubprocessMonty {
124    /// Validate the configured binary version before accepting any hostile code.
125    ///
126    /// # Errors
127    ///
128    /// Returns a worker spawn error unless the executable reports exact Monty 0.0.22.
129    pub fn new(config: SubprocessConfig) -> Result<Self, ExecutionError> {
130        if config
131            .wall_timeout_override
132            .is_some_and(|timeout| timeout.is_zero())
133        {
134            return Err(worker_error(
135                WorkerFailureKind::Spawn,
136                "worker wall timeout must be greater than zero",
137            ));
138        }
139        verify_worker_version(&config.worker_path)?;
140        Ok(Self {
141            config,
142            idle: Mutex::new(Vec::new()),
143        })
144    }
145
146    /// Return the complete worker configuration.
147    #[must_use]
148    pub const fn config(&self) -> &SubprocessConfig {
149        &self.config
150    }
151
152    /// Number of clean reset workers currently retained for warm reuse.
153    ///
154    /// # Errors
155    ///
156    /// Returns a worker failure if a previous panic poisoned the short pool lock.
157    pub fn idle_workers(&self) -> Result<usize, ExecutionError> {
158        Ok(self.pool()?.len())
159    }
160
161    /// Execute source in a supervised child against a caller-owned virtual transaction.
162    ///
163    /// # Errors
164    ///
165    /// Returns typed Monty, host budget, VFS integrity, protocol, crash, or watchdog
166    /// failures. A worker is reused only after a successful clean reset; resource and
167    /// infrastructure failures always discard it.
168    pub fn execute(
169        &self,
170        code: impl Into<String>,
171        filesystem: &mut VirtualFs,
172    ) -> Result<ExecutionOutcome, ExecutionError> {
173        self.execute_with_config(code, filesystem, &self.config.adapter)
174    }
175
176    /// Execute with request-specific limits and policy while reusing this worker pool.
177    ///
178    /// # Errors
179    ///
180    /// Returns the same typed failures as [`Self::execute`].
181    pub fn execute_with_config(
182        &self,
183        code: impl Into<String>,
184        filesystem: &mut VirtualFs,
185        adapter: &InProcessConfig,
186    ) -> Result<ExecutionOutcome, ExecutionError> {
187        let code = code.into();
188        let program_bytes = u64::try_from(code.len()).unwrap_or(u64::MAX);
189        let max_program_bytes = u64::try_from(adapter.limits.max_program_bytes).unwrap_or(u64::MAX);
190        if program_bytes > max_program_bytes {
191            return Err(limit_error(ExecutionLimitExceeded::ProgramBytes {
192                limit: max_program_bytes,
193                attempted: program_bytes,
194            }));
195        }
196
197        let mut worker = self.checkout()?;
198        let wall_timeout = self.config.wall_timeout(adapter);
199        let result = worker.execute(&code, filesystem, adapter, wall_timeout);
200        let session_is_reusable = matches!(
201            result,
202            Ok(_)
203                | Err(ExecutionError::Monty { .. } | ExecutionError::UnsupportedSuspension { .. })
204        );
205        if session_is_reusable && worker.reset(wall_timeout).is_ok() {
206            self.checkin(worker)?;
207        }
208        result
209    }
210
211    fn pool(&self) -> Result<MutexGuard<'_, Vec<Worker>>, ExecutionError> {
212        self.idle
213            .lock()
214            .map_err(|_| worker_error(WorkerFailureKind::Crashed, "worker pool lock was poisoned"))
215    }
216
217    fn checkout(&self) -> Result<Worker, ExecutionError> {
218        if let Some(worker) = self.pool()?.pop() {
219            Ok(worker)
220        } else {
221            Worker::spawn(&self.config.worker_path)
222        }
223    }
224
225    fn checkin(&self, worker: Worker) -> Result<(), ExecutionError> {
226        let mut pool = self.pool()?;
227        if pool.len() < self.config.max_idle_workers {
228            pool.push(worker);
229        }
230        Ok(())
231    }
232}
233
234#[derive(Debug)]
235#[expect(
236    clippy::large_enum_variant,
237    reason = "boxing every decoded worker event would add an allocation to the suspension hot path"
238)]
239enum WorkerMessage {
240    Event(pb::ChildEvent),
241    Eof,
242    ReadError(String),
243    FrameLimit {
244        kind: Option<u32>,
245        length: u32,
246        limit: u32,
247    },
248}
249
250#[derive(Debug)]
251enum WorkerReadError {
252    Detail(String),
253    FrameLimit {
254        kind: Option<u32>,
255        length: u32,
256        limit: u32,
257    },
258}
259
260impl From<String> for WorkerReadError {
261    fn from(detail: String) -> Self {
262        Self::Detail(detail)
263    }
264}
265
266#[derive(Debug)]
267struct WorkerFrameLimits {
268    hard: AtomicU32,
269    output: AtomicU32,
270    call: AtomicU32,
271    result: AtomicU32,
272    exception: AtomicU32,
273    control: AtomicU32,
274    output_bytes: AtomicU64,
275    result_bytes: AtomicU64,
276    exception_bytes: AtomicU64,
277}
278
279impl WorkerFrameLimits {
280    fn new() -> Self {
281        let initial = frame_cap(FRAME_OVERHEAD_BYTES);
282        Self {
283            hard: AtomicU32::new(initial),
284            output: AtomicU32::new(initial),
285            call: AtomicU32::new(initial),
286            result: AtomicU32::new(initial),
287            exception: AtomicU32::new(initial),
288            control: AtomicU32::new(initial),
289            output_bytes: AtomicU64::new(0),
290            result_bytes: AtomicU64::new(0),
291            exception_bytes: AtomicU64::new(0),
292        }
293    }
294
295    fn configure(&self, adapter: &InProcessConfig) {
296        let limits = adapter.limits;
297        let output = frame_cap(limits.max_output_bytes);
298        let call = frame_cap(
299            limits
300                .max_io_call_bytes
301                .saturating_add(limits.max_path_bytes.saturating_mul(2)),
302        );
303        let result = frame_cap(limits.max_result_bytes);
304        let exception = frame_cap(limits.max_exception_bytes);
305        let control = frame_cap(limits.max_program_bytes.max(limits.max_path_bytes));
306        self.output.store(output, Ordering::Relaxed);
307        self.call.store(call, Ordering::Relaxed);
308        self.result.store(result, Ordering::Relaxed);
309        self.exception.store(exception, Ordering::Relaxed);
310        self.control.store(control, Ordering::Relaxed);
311        self.output_bytes.store(
312            u64::try_from(limits.max_output_bytes).unwrap_or(u64::MAX),
313            Ordering::Relaxed,
314        );
315        self.result_bytes.store(
316            u64::try_from(limits.max_result_bytes).unwrap_or(u64::MAX),
317            Ordering::Relaxed,
318        );
319        self.exception_bytes.store(
320            u64::try_from(limits.max_exception_bytes).unwrap_or(u64::MAX),
321            Ordering::Relaxed,
322        );
323        self.hard.store(
324            output.max(call).max(result).max(exception).max(control),
325            Ordering::Release,
326        );
327    }
328
329    fn hard(&self) -> u32 {
330        self.hard.load(Ordering::Acquire)
331    }
332
333    fn for_kind(&self, kind: Option<u32>) -> u32 {
334        let limit = match kind {
335            Some(1) => &self.output,
336            Some(2 | 3) => &self.call,
337            Some(6) => &self.result,
338            Some(7) => &self.exception,
339            _ => &self.control,
340        };
341        limit.load(Ordering::Relaxed)
342    }
343
344    fn semantic_limit(&self, kind: Option<u32>) -> Option<u64> {
345        let limit = match kind {
346            Some(1) => &self.output_bytes,
347            Some(6) => &self.result_bytes,
348            Some(7) => &self.exception_bytes,
349            _ => return None,
350        };
351        Some(limit.load(Ordering::Relaxed))
352    }
353}
354
355#[derive(Debug)]
356struct Worker {
357    process: Child,
358    stdin: ChildStdin,
359    events: Option<Receiver<WorkerMessage>>,
360    reader: Option<JoinHandle<()>>,
361    frame_limits: Arc<WorkerFrameLimits>,
362}
363
364impl Worker {
365    fn spawn(path: &Path) -> Result<Self, ExecutionError> {
366        let mut process = Command::new(path)
367            .arg("subprocess")
368            .env_clear()
369            .stdin(Stdio::piped())
370            .stdout(Stdio::piped())
371            .stderr(Stdio::null())
372            .spawn()
373            .map_err(|source| {
374                worker_error(
375                    WorkerFailureKind::Spawn,
376                    format!("cannot start {}: {source}", path.display()),
377                )
378            })?;
379        let Some(stdin) = process.stdin.take() else {
380            terminate_spawn(&mut process);
381            return Err(worker_error(
382                WorkerFailureKind::Spawn,
383                "spawned worker has no stdin pipe",
384            ));
385        };
386        let Some(stdout) = process.stdout.take() else {
387            terminate_spawn(&mut process);
388            return Err(worker_error(
389                WorkerFailureKind::Spawn,
390                "spawned worker has no stdout pipe",
391            ));
392        };
393        let (sender, events) = mpsc::sync_channel(64);
394        let frame_limits = Arc::new(WorkerFrameLimits::new());
395        let reader_limits = Arc::clone(&frame_limits);
396        let reader = thread::Builder::new()
397            .name("vsh-monty-worker-reader".to_owned())
398            .spawn(move || {
399                let mut stdout = stdout;
400                loop {
401                    let message = match read_worker_event(&mut stdout, &reader_limits) {
402                        Ok(Some(event)) => WorkerMessage::Event(event),
403                        Ok(None) => WorkerMessage::Eof,
404                        Err(WorkerReadError::Detail(source)) => WorkerMessage::ReadError(source),
405                        Err(WorkerReadError::FrameLimit {
406                            kind,
407                            length,
408                            limit,
409                        }) => WorkerMessage::FrameLimit {
410                            kind,
411                            length,
412                            limit,
413                        },
414                    };
415                    let terminal = !matches!(message, WorkerMessage::Event(_));
416                    if sender.send(message).is_err() || terminal {
417                        break;
418                    }
419                }
420            });
421        let reader = match reader {
422            Ok(reader) => reader,
423            Err(source) => {
424                terminate_spawn(&mut process);
425                return Err(worker_error(
426                    WorkerFailureKind::Spawn,
427                    format!("cannot start worker reader: {source}"),
428                ));
429            }
430        };
431        Ok(Self {
432            process,
433            stdin,
434            events: Some(events),
435            reader: Some(reader),
436            frame_limits,
437        })
438    }
439
440    #[expect(
441        clippy::too_many_lines,
442        reason = "one exhaustive protocol turn loop keeps every terminal worker event fail-closed"
443    )]
444    fn execute(
445        &mut self,
446        code: &str,
447        filesystem: &mut VirtualFs,
448        adapter: &InProcessConfig,
449        wall_timeout: Duration,
450    ) -> Result<ExecutionOutcome, ExecutionError> {
451        self.frame_limits.configure(adapter);
452        let deadline = Instant::now()
453            .checked_add(wall_timeout)
454            .ok_or_else(|| worker_error(WorkerFailureKind::Timeout, "worker deadline overflow"))?;
455        self.configure(adapter, deadline)?;
456        self.send(pb::parent_request::Kind::Feed(pb::Feed {
457            code: code.to_owned(),
458            inputs: monty_tool_inputs(),
459            skip_type_check: true,
460        }))?;
461
462        let mut stdout = String::new();
463        let mut budget = Budget::new(adapter.limits);
464        let mut denied_accesses = Vec::new();
465        let mut worker_events = 0_u64;
466        loop {
467            let event = self.receive(deadline)?;
468            worker_events = worker_events.saturating_add(1);
469            if worker_events > MAX_WORKER_EVENTS_PER_EXECUTION {
470                return Err(worker_error(
471                    WorkerFailureKind::Protocol,
472                    "worker event limit exceeded",
473                ));
474            }
475            let kind = event.kind.ok_or_else(|| {
476                worker_error(WorkerFailureKind::Protocol, "child event has no kind")
477            })?;
478            match kind {
479                pb::child_event::Kind::Print(output) => {
480                    append_output(&mut stdout, &output, adapter.limits.max_output_bytes)?;
481                }
482                pb::child_event::Kind::OsCall(call) => {
483                    self.resume_os_call(
484                        call,
485                        filesystem,
486                        adapter,
487                        &mut budget,
488                        &mut denied_accesses,
489                    )?;
490                }
491                pb::child_event::Kind::NameLookup(_) => {
492                    self.send(pb::parent_request::Kind::ResumeNameLookup(
493                        pb::ResumeNameLookup {
494                            kind: Some(pb::resume_name_lookup::Kind::Undefined(pb::Unit {})),
495                        },
496                    ))?;
497                }
498                pb::child_event::Kind::Complete(complete) => {
499                    let value = complete
500                        .value
501                        .ok_or_else(|| {
502                            worker_error(WorkerFailureKind::Protocol, "complete event has no value")
503                        })?
504                        .into_object()
505                        .map_err(|source| {
506                            worker_error(WorkerFailureKind::Protocol, source.to_string())
507                        })?;
508                    let mut stats = budget.stats;
509                    stats.output_bytes = stdout.len();
510                    stats.result_bytes = measure_result(&value, adapter.limits.max_result_bytes)
511                        .map_err(limit_error)?;
512                    return Ok(ExecutionOutcome {
513                        value,
514                        stdout,
515                        stats,
516                        denied_accesses,
517                    });
518                }
519                pb::child_event::Kind::Error(error) => {
520                    return Err(worker_monty_error(
521                        error,
522                        adapter.limits.max_exception_bytes,
523                    ));
524                }
525                pb::child_event::Kind::FunctionCall(call) => {
526                    self.resume_tool_call(
527                        call,
528                        filesystem,
529                        adapter,
530                        &mut budget,
531                        &mut denied_accesses,
532                    )?;
533                }
534                pb::child_event::Kind::ResolveFutures(_) => {
535                    return Err(ExecutionError::UnsupportedSuspension {
536                        kind: "future resolution",
537                        name: None,
538                    });
539                }
540                pb::child_event::Kind::FatalError(error) => {
541                    return Err(worker_error(WorkerFailureKind::Crashed, error.message));
542                }
543                pb::child_event::Kind::TypingError(_)
544                | pb::child_event::Kind::DumpResult(_)
545                | pb::child_event::Kind::Ok(_)
546                | pb::child_event::Kind::Shutdown(_) => {
547                    return Err(worker_error(
548                        WorkerFailureKind::Protocol,
549                        "unexpected child event during execution",
550                    ));
551                }
552            }
553        }
554    }
555
556    fn configure(
557        &mut self,
558        adapter: &InProcessConfig,
559        deadline: Instant,
560    ) -> Result<(), ExecutionError> {
561        self.send(pb::parent_request::Kind::Configure(pb::Configure {
562            script_name: adapter.script_name.clone(),
563            limits: Some(pb::ResourceLimits {
564                max_duration_micros: Some(duration_micros(adapter.limits.max_duration)),
565                max_memory_bytes: Some(
566                    u64::try_from(adapter.limits.max_memory_bytes).unwrap_or(u64::MAX),
567                ),
568                gc_interval: None,
569                max_recursion_depth: Some(
570                    u64::try_from(adapter.limits.max_recursion_depth).unwrap_or(u64::MAX),
571                ),
572                max_suspensions: Some(adapter.limits.max_os_calls),
573            }),
574            type_check: false,
575            type_check_stubs: None,
576            monty_version: EXPECTED_MONTY_VERSION.to_owned(),
577            assert_message_annotations: None,
578            type_check_format: pb::TypeCheckFormat::Unspecified as i32,
579            type_check_color: false,
580            protocol_version: PROTOCOL_VERSION,
581        }))?;
582        self.expect_ok(deadline, "configure")
583    }
584
585    fn resume_os_call(
586        &mut self,
587        call: pb::OsCall,
588        filesystem: &mut VirtualFs,
589        config: &InProcessConfig,
590        budget: &mut Budget,
591        denied_accesses: &mut Vec<DeniedAccess>,
592    ) -> Result<(), ExecutionError> {
593        let typed_call = call
594            .call
595            .ok_or_else(|| worker_error(WorkerFailureKind::Protocol, "OS call has no typed arm"))?
596            .try_into()
597            .map_err(|source: monty_proto::ProtoConvertError| {
598                worker_error(WorkerFailureKind::Protocol, source.to_string())
599            })?;
600        budget.charge_os_call().map_err(limit_error)?;
601        let result = filesystem.with_effect_origin(EffectOrigin::MontyOsCall, |filesystem| {
602            dispatch_call(&typed_call, filesystem, config, budget)
603        });
604        let result = call_result(result, budget, denied_accesses)?;
605        self.send(pb::parent_request::Kind::ResumeCall(pb::ResumeCall {
606            call_id: call.call_id,
607            result: Some(result.into()),
608        }))
609    }
610
611    fn resume_tool_call(
612        &mut self,
613        call: monty_proto::WireFunctionCall,
614        filesystem: &mut VirtualFs,
615        config: &InProcessConfig,
616        budget: &mut Budget,
617        denied_accesses: &mut Vec<DeniedAccess>,
618    ) -> Result<(), ExecutionError> {
619        if call.object_id.is_some() || !tools::is_tool(&call.function_name) {
620            return Err(ExecutionError::UnsupportedSuspension {
621                kind: "external function call",
622                name: Some(call.function_name),
623            });
624        }
625        budget.charge_os_call().map_err(limit_error)?;
626        let result = filesystem.with_effect_origin(EffectOrigin::MontyToolCall, |filesystem| {
627            tools::dispatch(
628                &call.function_name,
629                &call.args,
630                &call.kwargs,
631                filesystem,
632                config,
633                budget,
634            )
635        });
636        let result = call_result(result, budget, denied_accesses)?;
637        self.send(pb::parent_request::Kind::ResumeCall(pb::ResumeCall {
638            call_id: call.call_id,
639            result: Some(result.into()),
640        }))
641    }
642
643    fn reset(&mut self, timeout: Duration) -> Result<(), ExecutionError> {
644        let deadline = Instant::now()
645            .checked_add(timeout)
646            .ok_or_else(|| worker_error(WorkerFailureKind::Timeout, "reset deadline overflow"))?;
647        self.send(pb::parent_request::Kind::Reset(pb::Reset {}))?;
648        self.expect_ok(deadline, "reset")
649    }
650
651    fn expect_ok(
652        &mut self,
653        deadline: Instant,
654        operation: &'static str,
655    ) -> Result<(), ExecutionError> {
656        let event = self.receive(deadline)?;
657        match event.kind {
658            Some(pb::child_event::Kind::Ok(_)) => Ok(()),
659            Some(pb::child_event::Kind::FatalError(error)) => {
660                Err(worker_error(WorkerFailureKind::Crashed, error.message))
661            }
662            _ => Err(worker_error(
663                WorkerFailureKind::Protocol,
664                format!("unexpected child response to {operation}"),
665            )),
666        }
667    }
668
669    fn send(&mut self, kind: pb::parent_request::Kind) -> Result<(), ExecutionError> {
670        write_frame(
671            &mut self.stdin,
672            &pb::ParentRequest {
673                trace_parent: None,
674                kind: Some(kind),
675            },
676        )
677        .map_err(|source| worker_error(WorkerFailureKind::Transport, source.to_string()))
678    }
679
680    fn receive(&mut self, deadline: Instant) -> Result<pb::ChildEvent, ExecutionError> {
681        let remaining = deadline.saturating_duration_since(Instant::now());
682        if remaining.is_zero() {
683            let _ = self.process.kill();
684            return Err(worker_error(
685                WorkerFailureKind::Timeout,
686                "worker wall-clock deadline exceeded",
687            ));
688        }
689        let events = self.events.as_ref().ok_or_else(|| {
690            worker_error(WorkerFailureKind::Crashed, "worker reader is unavailable")
691        })?;
692        match events.recv_timeout(remaining) {
693            Ok(WorkerMessage::Event(event)) => Ok(event),
694            Ok(WorkerMessage::Eof) => Err(worker_error(
695                WorkerFailureKind::Crashed,
696                "worker exited before a turn-ending event",
697            )),
698            Ok(WorkerMessage::ReadError(detail)) => {
699                Err(worker_error(WorkerFailureKind::Transport, detail))
700            }
701            Ok(WorkerMessage::FrameLimit {
702                kind,
703                length,
704                limit,
705            }) => {
706                let attempted = u64::from(length);
707                match (kind, self.frame_limits.semantic_limit(kind)) {
708                    (Some(1), Some(limit)) => {
709                        Err(limit_error(ExecutionLimitExceeded::OutputBytes {
710                            limit,
711                            attempted,
712                        }))
713                    }
714                    (Some(6), Some(limit)) => {
715                        Err(limit_error(ExecutionLimitExceeded::ResultBytes {
716                            limit,
717                            attempted,
718                        }))
719                    }
720                    (Some(7), Some(limit)) => {
721                        Err(limit_error(ExecutionLimitExceeded::ExceptionBytes {
722                            limit,
723                            attempted,
724                        }))
725                    }
726                    _ => Err(worker_error(
727                        WorkerFailureKind::Transport,
728                        format!(
729                            "worker event kind {kind:?} has {length} wire bytes; request maximum is {limit}"
730                        ),
731                    )),
732                }
733            }
734            Err(RecvTimeoutError::Timeout) => {
735                let _ = self.process.kill();
736                Err(worker_error(
737                    WorkerFailureKind::Timeout,
738                    "worker wall-clock deadline exceeded",
739                ))
740            }
741            Err(RecvTimeoutError::Disconnected) => Err(worker_error(
742                WorkerFailureKind::Crashed,
743                "worker reader disconnected",
744            )),
745        }
746    }
747}
748
749fn frame_cap(payload_bytes: usize) -> u32 {
750    u32::try_from(payload_bytes.saturating_add(FRAME_OVERHEAD_BYTES))
751        .unwrap_or(u32::MAX)
752        .min(MAX_FRAME_LEN)
753}
754
755fn read_worker_event(
756    reader: &mut impl Read,
757    limits: &WorkerFrameLimits,
758) -> Result<Option<pb::ChildEvent>, WorkerReadError> {
759    let mut length = [0_u8; 4];
760    loop {
761        match reader.read(&mut length[..1]) {
762            Ok(0) => return Ok(None),
763            Ok(1) => break,
764            Ok(_) => unreachable!("one-byte read returned more than one byte"),
765            Err(source) if source.kind() == std::io::ErrorKind::Interrupted => {}
766            Err(source) => return Err(format!("frame prefix read failed: {source}").into()),
767        }
768    }
769    reader
770        .read_exact(&mut length[1..])
771        .map_err(|source| format!("worker exited during frame prefix: {source}"))?;
772    let length = u32::from_le_bytes(length);
773    let hard_limit = limits.hard();
774    if length > hard_limit {
775        return Err(WorkerReadError::Detail(format!(
776            "worker frame of {length} bytes exceeds request maximum of {hard_limit} bytes"
777        )));
778    }
779    let length =
780        usize::try_from(length).map_err(|_| "frame length does not fit host".to_owned())?;
781    let mut body = Vec::new();
782    body.try_reserve_exact(length)
783        .map_err(|_| "cannot reserve bounded worker frame".to_owned())?;
784    body.resize(length, 0);
785    reader
786        .read_exact(&mut body)
787        .map_err(|source| format!("worker exited during frame body: {source}"))?;
788
789    let kind = child_event_kind_tag(&body)?;
790    let kind_limit = limits.for_kind(kind);
791    if u32::try_from(length).unwrap_or(u32::MAX) > kind_limit {
792        return Err(WorkerReadError::FrameLimit {
793            kind,
794            length: u32::try_from(length).unwrap_or(u32::MAX),
795            limit: kind_limit,
796        });
797    }
798    decode_frame(&body)
799        .map(Some)
800        .map_err(|source| WorkerReadError::Detail(format!("worker frame decode failed: {source}")))
801}
802
803fn child_event_kind_tag(bytes: &[u8]) -> Result<Option<u32>, String> {
804    let mut offset = 0_usize;
805    let mut kind = None;
806    while offset < bytes.len() {
807        let key = read_varint(bytes, &mut offset)?;
808        if key == 0 {
809            return Err("worker protobuf contains field key zero".to_owned());
810        }
811        let field = u32::try_from(key >> 3)
812            .map_err(|_| "worker protobuf field number overflows u32".to_owned())?;
813        let wire_type = u8::try_from(key & 0b111).expect("three bits fit u8");
814        if (1..=12).contains(&field) {
815            if wire_type != 2 {
816                return Err(format!(
817                    "worker event kind field {field} has invalid wire type {wire_type}"
818                ));
819            }
820            if kind.replace(field).is_some() {
821                return Err("worker protobuf contains multiple event kind fields".to_owned());
822            }
823        }
824        skip_protobuf_value(bytes, &mut offset, wire_type)?;
825    }
826    Ok(kind)
827}
828
829fn read_varint(bytes: &[u8], offset: &mut usize) -> Result<u64, String> {
830    let mut value = 0_u64;
831    for shift in (0..=63).step_by(7) {
832        let byte = *bytes
833            .get(*offset)
834            .ok_or_else(|| "truncated worker protobuf varint".to_owned())?;
835        *offset = (*offset).saturating_add(1);
836        if shift == 63 && byte > 1 {
837            return Err("worker protobuf varint overflows u64".to_owned());
838        }
839        value |= u64::from(byte & 0x7f) << shift;
840        if byte & 0x80 == 0 {
841            return Ok(value);
842        }
843    }
844    Err("worker protobuf varint is too long".to_owned())
845}
846
847fn skip_protobuf_value(bytes: &[u8], offset: &mut usize, wire_type: u8) -> Result<(), String> {
848    let length = match wire_type {
849        0 => {
850            let _ = read_varint(bytes, offset)?;
851            return Ok(());
852        }
853        1 => 8,
854        2 => usize::try_from(read_varint(bytes, offset)?)
855            .map_err(|_| "worker protobuf length does not fit host".to_owned())?,
856        5 => 4,
857        _ => return Err(format!("unsupported worker protobuf wire type {wire_type}")),
858    };
859    let end = offset
860        .checked_add(length)
861        .filter(|end| *end <= bytes.len())
862        .ok_or_else(|| "truncated worker protobuf field".to_owned())?;
863    *offset = end;
864    Ok(())
865}
866
867fn terminate_spawn(process: &mut Child) {
868    let _ = process.kill();
869    let _ = process.wait();
870}
871
872impl Drop for Worker {
873    fn drop(&mut self) {
874        self.events.take();
875        let _ = self.process.kill();
876        let _ = self.process.wait();
877        if let Some(reader) = self.reader.take() {
878            let _ = reader.join();
879        }
880    }
881}
882
883fn verify_worker_version(path: &Path) -> Result<(), ExecutionError> {
884    let mut process = Command::new(path)
885        .arg("--version")
886        .env_clear()
887        .stdin(Stdio::null())
888        .stdout(Stdio::piped())
889        .stderr(Stdio::null())
890        .spawn()
891        .map_err(|source| {
892            worker_error(
893                WorkerFailureKind::Spawn,
894                format!("cannot inspect {}: {source}", path.display()),
895            )
896        })?;
897    let deadline = Instant::now()
898        .checked_add(VERSION_CHECK_TIMEOUT)
899        .ok_or_else(|| worker_error(WorkerFailureKind::Spawn, "version deadline overflow"))?;
900    let status = loop {
901        match process.try_wait() {
902            Ok(Some(status)) => break status,
903            Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(5)),
904            Ok(None) => {
905                terminate_spawn(&mut process);
906                return Err(worker_error(
907                    WorkerFailureKind::Spawn,
908                    format!(
909                        "worker version check exceeded {} seconds",
910                        VERSION_CHECK_TIMEOUT.as_secs()
911                    ),
912                ));
913            }
914            Err(source) => {
915                terminate_spawn(&mut process);
916                return Err(worker_error(
917                    WorkerFailureKind::Spawn,
918                    format!("cannot wait for worker version: {source}"),
919                ));
920            }
921        }
922    };
923    let stdout = process.stdout.take().ok_or_else(|| {
924        worker_error(
925            WorkerFailureKind::Spawn,
926            "version worker has no stdout pipe",
927        )
928    })?;
929    let mut bytes = Vec::new();
930    stdout
931        .take(u64::try_from(MAX_WORKER_DIAGNOSTIC_BYTES + 1).unwrap_or(u64::MAX))
932        .read_to_end(&mut bytes)
933        .map_err(|source| {
934            worker_error(
935                WorkerFailureKind::Spawn,
936                format!("cannot read worker version: {source}"),
937            )
938        })?;
939    if bytes.len() > MAX_WORKER_DIAGNOSTIC_BYTES {
940        return Err(worker_error(
941            WorkerFailureKind::Spawn,
942            "worker version output exceeds 4096 bytes",
943        ));
944    }
945    let stdout = String::from_utf8(bytes).map_err(|_| {
946        worker_error(
947            WorkerFailureKind::Spawn,
948            "worker version output is not UTF-8",
949        )
950    })?;
951    let version = stdout.split_whitespace().last();
952    if !status.success() || version != Some(EXPECTED_MONTY_VERSION) {
953        return Err(worker_error(
954            WorkerFailureKind::Spawn,
955            format!(
956                "worker must report exact Monty {EXPECTED_MONTY_VERSION}; got {:?}",
957                stdout.trim()
958            ),
959        ));
960    }
961    Ok(())
962}
963
964fn append_output(
965    stdout: &mut String,
966    output: &pb::Print,
967    maximum: usize,
968) -> Result<(), ExecutionError> {
969    if pb::PrintStream::try_from(output.stream).ok() != Some(pb::PrintStream::Stdout) {
970        return Err(worker_error(
971            WorkerFailureKind::Protocol,
972            "child emitted an unsupported print stream",
973        ));
974    }
975    let attempted = stdout.len().saturating_add(output.text.len());
976    if attempted > maximum {
977        return Err(limit_error(ExecutionLimitExceeded::OutputBytes {
978            limit: u64::try_from(maximum).unwrap_or(u64::MAX),
979            attempted: u64::try_from(attempted).unwrap_or(u64::MAX),
980        }));
981    }
982    stdout.push_str(&output.text);
983    Ok(())
984}
985
986fn worker_monty_error(error: pb::Error, maximum: usize) -> ExecutionError {
987    let exception = match error.exception {
988        Some(exception) => MontyException::try_from(exception).map_err(|source| source.to_string()),
989        None => Err("error event has no exception".to_owned()),
990    };
991    let source = match exception {
992        Ok(source) => source,
993        Err(detail) => return worker_error(WorkerFailureKind::Protocol, detail),
994    };
995    let attempted = exception_bytes(&source);
996    let limit = u64::try_from(maximum).unwrap_or(u64::MAX);
997    if attempted > limit {
998        limit_error(ExecutionLimitExceeded::ExceptionBytes { limit, attempted })
999    } else {
1000        ExecutionError::Monty {
1001            phase: MontyFailurePhase::Runtime,
1002            source: Box::new(source),
1003        }
1004    }
1005}
1006
1007fn duration_micros(duration: Duration) -> u64 {
1008    u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
1009}
1010
1011fn worker_error(kind: WorkerFailureKind, detail: impl Into<String>) -> ExecutionError {
1012    ExecutionError::Worker(Box::new(WorkerFailure {
1013        kind,
1014        detail: bounded_detail(detail.into()),
1015    }))
1016}
1017
1018fn bounded_detail(mut detail: String) -> String {
1019    if detail.len() <= MAX_WORKER_DIAGNOSTIC_BYTES {
1020        return detail;
1021    }
1022    let mut end = MAX_WORKER_DIAGNOSTIC_BYTES;
1023    while !detail.is_char_boundary(end) {
1024        end -= 1;
1025    }
1026    detail.truncate(end);
1027    detail.push('…');
1028    detail
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    #[test]
1036    fn subprocess_configuration_is_bound_and_rejects_invalid_launches() {
1037        let adapter = InProcessConfig::default();
1038        let default = SubprocessConfig::new("worker", adapter.clone());
1039        let configured = default
1040            .clone()
1041            .with_wall_timeout(Duration::from_millis(25))
1042            .with_max_idle_workers(0);
1043        assert_eq!(configured.worker_path(), Path::new("worker"));
1044        assert_eq!(configured.adapter(), &adapter);
1045        assert_ne!(configured.security_digest(), default.security_digest());
1046        assert_eq!(
1047            configured.security_digest_for(&adapter),
1048            configured.security_digest()
1049        );
1050
1051        let zero_timeout =
1052            SubprocessConfig::new("unused", adapter.clone()).with_wall_timeout(Duration::ZERO);
1053        let error = SubprocessMonty::new(zero_timeout).unwrap_err();
1054        assert!(matches!(
1055            error,
1056            ExecutionError::Worker(source)
1057                if source.kind == WorkerFailureKind::Spawn
1058                    && source.detail.contains("greater than zero")
1059        ));
1060
1061        let missing =
1062            std::env::temp_dir().join(format!("vsh-worker-does-not-exist-{}", std::process::id()));
1063        let error = SubprocessMonty::new(SubprocessConfig::new(missing, adapter)).unwrap_err();
1064        assert!(matches!(
1065            error,
1066            ExecutionError::Worker(source) if source.kind == WorkerFailureKind::Spawn
1067        ));
1068    }
1069
1070    #[test]
1071    fn frame_limits_are_kind_specific_and_saturating() {
1072        let adapter = InProcessConfig::default().with_limits(super::super::ExecutionLimits {
1073            max_program_bytes: 5,
1074            max_io_call_bytes: 7,
1075            max_path_bytes: 11,
1076            max_output_bytes: 13,
1077            max_result_bytes: 17,
1078            max_exception_bytes: 19,
1079            ..super::super::ExecutionLimits::default()
1080        });
1081        let limits = WorkerFrameLimits::new();
1082        limits.configure(&adapter);
1083
1084        assert_eq!(limits.for_kind(Some(1)), frame_cap(13));
1085        assert_eq!(limits.for_kind(Some(2)), frame_cap(29));
1086        assert_eq!(limits.for_kind(Some(3)), frame_cap(29));
1087        assert_eq!(limits.for_kind(Some(6)), frame_cap(17));
1088        assert_eq!(limits.for_kind(Some(7)), frame_cap(19));
1089        assert_eq!(limits.for_kind(None), frame_cap(11));
1090        assert_eq!(limits.semantic_limit(Some(1)), Some(13));
1091        assert_eq!(limits.semantic_limit(Some(6)), Some(17));
1092        assert_eq!(limits.semantic_limit(Some(7)), Some(19));
1093        assert_eq!(limits.semantic_limit(Some(3)), None);
1094        assert_eq!(limits.hard(), frame_cap(29));
1095        assert_eq!(frame_cap(usize::MAX), MAX_FRAME_LEN);
1096    }
1097
1098    #[test]
1099    fn framed_reader_rejects_truncation_and_kind_specific_oversize() {
1100        let limits = WorkerFrameLimits::new();
1101        assert!(matches!(
1102            read_worker_event(&mut [].as_slice(), &limits),
1103            Ok(None)
1104        ));
1105        assert!(matches!(
1106            read_worker_event(&mut [1_u8].as_slice(), &limits),
1107            Err(WorkerReadError::Detail(_))
1108        ));
1109        assert!(matches!(
1110            read_worker_event(&mut [2_u8, 0, 0, 0, 0].as_slice(), &limits),
1111            Err(WorkerReadError::Detail(_))
1112        ));
1113
1114        let event = pb::ChildEvent {
1115            kind: Some(pb::child_event::Kind::Ok(pb::Ok {})),
1116            ..pb::ChildEvent::default()
1117        };
1118        let mut encoded = Vec::new();
1119        write_frame(&mut encoded, &event).unwrap();
1120        assert!(matches!(
1121            read_worker_event(&mut encoded.as_slice(), &limits),
1122            Ok(Some(decoded)) if matches!(decoded.kind, Some(pb::child_event::Kind::Ok(_)))
1123        ));
1124
1125        let adapter = InProcessConfig::default().with_limits(super::super::ExecutionLimits {
1126            max_output_bytes: 0,
1127            ..super::super::ExecutionLimits::default()
1128        });
1129        limits.configure(&adapter);
1130        let event = pb::ChildEvent {
1131            kind: Some(pb::child_event::Kind::Print(pb::Print {
1132                stream: pb::PrintStream::Stdout.into(),
1133                text: "x".repeat(FRAME_OVERHEAD_BYTES + 1),
1134            })),
1135            ..pb::ChildEvent::default()
1136        };
1137        let mut encoded = Vec::new();
1138        write_frame(&mut encoded, &event).unwrap();
1139        assert!(matches!(
1140            read_worker_event(&mut encoded.as_slice(), &limits),
1141            Err(WorkerReadError::FrameLimit { kind: Some(1), .. })
1142        ));
1143    }
1144
1145    #[test]
1146    fn protobuf_scanner_and_output_diagnostics_are_strictly_bounded() {
1147        let mut offset = 0;
1148        assert!(read_varint(&[], &mut offset).is_err());
1149        let mut offset = 0;
1150        assert!(
1151            read_varint(
1152                &[0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 2],
1153                &mut offset
1154            )
1155            .is_err()
1156        );
1157
1158        for (bytes, wire_type) in [
1159            (&[0_u8][..], 0),
1160            (&[0_u8; 8][..], 1),
1161            (&[0_u8][..], 2),
1162            (&[0_u8; 4][..], 5),
1163        ] {
1164            let mut offset = 0;
1165            assert!(skip_protobuf_value(bytes, &mut offset, wire_type).is_ok());
1166        }
1167        let mut offset = 0;
1168        assert!(skip_protobuf_value(&[], &mut offset, 3).is_err());
1169        let mut offset = 0;
1170        assert!(skip_protobuf_value(&[8], &mut offset, 1).is_err());
1171
1172        let mut stdout = String::new();
1173        let print = pb::Print {
1174            stream: pb::PrintStream::Stdout.into(),
1175            text: "ok".to_owned(),
1176        };
1177        append_output(&mut stdout, &print, 2).unwrap();
1178        assert_eq!(stdout, "ok");
1179        assert!(matches!(
1180            append_output(&mut stdout, &print, 3),
1181            Err(ExecutionError::Limit(_))
1182        ));
1183        let invalid_stream = pb::Print {
1184            stream: i32::MAX,
1185            text: String::new(),
1186        };
1187        assert!(matches!(
1188            append_output(&mut stdout, &invalid_stream, usize::MAX),
1189            Err(ExecutionError::Worker(source)) if source.kind == WorkerFailureKind::Protocol
1190        ));
1191
1192        assert_eq!(bounded_detail("short".to_owned()), "short");
1193        let bounded = bounded_detail(format!("{}é", "x".repeat(MAX_WORKER_DIAGNOSTIC_BYTES)));
1194        assert!(bounded.ends_with('…'));
1195        assert!(bounded.len() <= MAX_WORKER_DIAGNOSTIC_BYTES + '…'.len_utf8());
1196        assert_eq!(duration_micros(Duration::from_micros(7)), 7);
1197        assert!(matches!(
1198            worker_monty_error(
1199                pb::Error {
1200                    exception: None,
1201                },
1202                1,
1203            ),
1204            ExecutionError::Worker(source) if source.kind == WorkerFailureKind::Protocol
1205        ));
1206    }
1207
1208    #[test]
1209    fn event_classifier_skips_metadata_without_decoding_nested_values() {
1210        // field 20 varint metadata, followed by field 10's empty length-delimited Ok arm.
1211        assert_eq!(
1212            child_event_kind_tag(&[0xa0, 0x01, 0x01, 0x52, 0x00]).unwrap(),
1213            Some(10)
1214        );
1215        assert!(child_event_kind_tag(&[0xa0]).is_err());
1216        assert!(child_event_kind_tag(&[0x50, 0x00]).is_err());
1217        assert!(child_event_kind_tag(&[0x52, 0x00, 0x32, 0x00]).is_err());
1218    }
1219}