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#[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 #[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 #[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 #[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 #[must_use]
76 pub fn worker_path(&self) -> &Path {
77 &self.worker_path
78 }
79
80 #[must_use]
82 pub const fn adapter(&self) -> &InProcessConfig {
83 &self.adapter
84 }
85
86 #[must_use]
88 pub fn security_digest(&self) -> RuntimeConfigDigest {
89 self.security_digest_for(&self.adapter)
90 }
91
92 #[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#[derive(Debug)]
118pub struct SubprocessMonty {
119 config: SubprocessConfig,
120 idle: Mutex<Vec<Worker>>,
121}
122
123impl SubprocessMonty {
124 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 #[must_use]
148 pub const fn config(&self) -> &SubprocessConfig {
149 &self.config
150 }
151
152 pub fn idle_workers(&self) -> Result<usize, ExecutionError> {
158 Ok(self.pool()?.len())
159 }
160
161 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 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 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}