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