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#[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 #[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 #[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 #[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 #[must_use]
64 pub fn worker_path(&self) -> &Path {
65 &self.worker_path
66 }
67
68 #[must_use]
70 pub const fn adapter(&self) -> &InProcessConfig {
71 &self.adapter
72 }
73
74 #[must_use]
76 pub fn security_digest(&self) -> RuntimeConfigDigest {
77 self.security_digest_for(&self.adapter)
78 }
79
80 #[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#[derive(Debug)]
106pub struct SubprocessMonty {
107 config: SubprocessConfig,
108 idle: Mutex<Vec<Worker>>,
109}
110
111impl SubprocessMonty {
112 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 #[must_use]
136 pub const fn config(&self) -> &SubprocessConfig {
137 &self.config
138 }
139
140 pub fn idle_workers(&self) -> Result<usize, ExecutionError> {
146 Ok(self.pool()?.len())
147 }
148
149 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 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 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}