1use std::ffi::OsString;
41use std::io::{Read, Write};
42use std::path::PathBuf;
43use std::process::{Child, Command, Stdio};
44use std::sync::mpsc;
45use std::thread;
46use std::time::{Duration, Instant};
47
48#[cfg(unix)]
49use std::os::unix::process::CommandExt;
50
51use crate::{CallbackResponse, DispatchEnvelope, FailureClass, PayloadEnvelope, ValidationError};
52
53use super::failure_mapping::{TransportError, validate_receipt_eligible};
54use super::plan::RoutingPlan;
55use super::seams::CallbackInvoker;
56use super::validation::RouteError;
57
58#[derive(Debug, Clone)]
70pub struct SubprocessInvokerConfig {
71 program: PathBuf,
72 args: Vec<OsString>,
73 timeout: Duration,
74}
75
76impl SubprocessInvokerConfig {
77 pub fn new(program: impl Into<PathBuf>, timeout: Duration) -> Self {
81 Self {
82 program: program.into(),
83 args: Vec::new(),
84 timeout,
85 }
86 }
87
88 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
90 self.args.push(arg.into());
91 self
92 }
93
94 pub fn args<I, A>(mut self, args: I) -> Self
96 where
97 I: IntoIterator<Item = A>,
98 A: Into<OsString>,
99 {
100 self.args.extend(args.into_iter().map(Into::into));
101 self
102 }
103
104 pub fn program(&self) -> &PathBuf {
105 &self.program
106 }
107
108 pub fn timeout(&self) -> Duration {
109 self.timeout
110 }
111}
112
113#[derive(Debug)]
123pub enum SubprocessInvokerError {
124 ReceiptEmittedRejected(RouteError),
129 Spawn(std::io::Error),
132 WriteRequest(std::io::Error),
135 SerializeRequest(serde_json::Error),
139 ReadResponse(std::io::Error),
142 ParseResponse(serde_json::Error),
146 InvalidResponse(ValidationError),
150 NonZeroExit { code: Option<i32>, stderr: String },
154 Timeout,
157}
158
159impl std::fmt::Display for SubprocessInvokerError {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 Self::ReceiptEmittedRejected(e) => {
163 write!(f, "subprocess invoker refused receipt.emitted plan: {e}")
164 }
165 Self::Spawn(e) => write!(f, "failed to spawn callback subprocess: {e}"),
166 Self::WriteRequest(e) => write!(f, "failed to write request to subprocess stdin: {e}"),
167 Self::SerializeRequest(e) => write!(f, "failed to serialize CallbackRequest: {e}"),
168 Self::ReadResponse(e) => write!(f, "failed to read subprocess stdout: {e}"),
169 Self::ParseResponse(e) => write!(
170 f,
171 "subprocess stdout was not a valid JSON CallbackResponse: {e}"
172 ),
173 Self::InvalidResponse(e) => write!(
174 f,
175 "subprocess returned a CallbackResponse that failed validation: {e}"
176 ),
177 Self::NonZeroExit { code, stderr } => match code {
178 Some(c) => write!(f, "callback subprocess exited with code {c}: {stderr}"),
179 None => write!(f, "callback subprocess terminated by signal: {stderr}"),
180 },
181 Self::Timeout => f.write_str("callback subprocess exceeded configured timeout"),
182 }
183 }
184}
185
186impl std::error::Error for SubprocessInvokerError {}
187
188pub fn failure_class_for_subprocess_error(err: &SubprocessInvokerError) -> FailureClass {
196 use SubprocessInvokerError as E;
197 match err {
198 E::ReceiptEmittedRejected(_) => FailureClass::InvalidRequest,
199 E::Spawn(_) | E::WriteRequest(_) | E::ReadResponse(_) | E::NonZeroExit { .. } => {
200 FailureClass::TransportError
201 }
202 E::SerializeRequest(_) => FailureClass::InternalError,
203 E::ParseResponse(_) | E::InvalidResponse(_) => FailureClass::InvalidRequest,
204 E::Timeout => FailureClass::Timeout,
205 }
206}
207
208impl From<&SubprocessInvokerError> for FailureClass {
209 fn from(err: &SubprocessInvokerError) -> Self {
210 failure_class_for_subprocess_error(err)
211 }
212}
213
214pub fn transport_error_for(err: &SubprocessInvokerError) -> Option<TransportError> {
221 use SubprocessInvokerError as E;
222 match err {
223 E::Spawn(e) | E::WriteRequest(e) | E::ReadResponse(e) => {
224 Some(TransportError::Io(e.to_string()))
225 }
226 E::NonZeroExit { code, stderr } => Some(TransportError::Io(match code {
227 Some(c) => format!("exit code {c}: {stderr}"),
228 None => format!("terminated by signal: {stderr}"),
229 })),
230 E::Timeout => Some(TransportError::Timeout),
231 E::SerializeRequest(e) => Some(TransportError::Internal(e.to_string())),
232 E::ReceiptEmittedRejected(_) | E::ParseResponse(_) | E::InvalidResponse(_) => None,
233 }
234}
235
236const MAX_STDOUT_BYTES: u64 = 16 * 1024 * 1024;
241const MAX_STDERR_BYTES: u64 = 256 * 1024;
242
243#[derive(Debug, Clone)]
261pub struct SubprocessCallbackInvoker {
262 config: SubprocessInvokerConfig,
263}
264
265impl SubprocessCallbackInvoker {
266 pub fn new(config: SubprocessInvokerConfig) -> Self {
267 Self { config }
268 }
269
270 pub fn config(&self) -> &SubprocessInvokerConfig {
271 &self.config
272 }
273
274 fn invoke_inner(
278 &self,
279 plan: &RoutingPlan,
280 payloads: &[PayloadEnvelope],
281 ) -> Result<CallbackResponse, SubprocessInvokerError> {
282 validate_receipt_eligible(plan).map_err(SubprocessInvokerError::ReceiptEmittedRejected)?;
287
288 let request = super::callbacks::synthesize_request(plan);
289 let envelope = DispatchEnvelope::new(request, payloads.to_vec());
290 let request_bytes =
291 serde_json::to_vec(&envelope).map_err(SubprocessInvokerError::SerializeRequest)?;
292
293 let mut command = Command::new(&self.config.program);
294 command
295 .args(&self.config.args)
296 .stdin(Stdio::piped())
297 .stdout(Stdio::piped())
298 .stderr(Stdio::piped());
299 #[cfg(unix)]
300 {
301 command.process_group(0);
302 }
303 let mut child = command.spawn().map_err(SubprocessInvokerError::Spawn)?;
304
305 let stdin = child.stdin.take().expect("stdin piped");
314 let stdout = child.stdout.take().expect("stdout piped");
315 let stderr = child.stderr.take().expect("stderr piped");
316
317 let (writer_tx, writer_rx) = mpsc::channel::<std::io::Result<()>>();
318 thread::spawn({
319 let bytes = request_bytes;
320 move || {
321 let mut stdin = stdin;
322 let result = stdin.write_all(&bytes).and_then(|()| stdin.flush());
323 let _ = writer_tx.send(result);
324 }
326 });
327
328 let (stdout_tx, stdout_rx) = mpsc::channel::<std::io::Result<Vec<u8>>>();
329 thread::spawn(move || {
330 let mut s = stdout;
331 let result = read_to_end_limited(&mut s, MAX_STDOUT_BYTES, "stdout");
332 let _ = stdout_tx.send(result);
333 });
334
335 let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>();
336 thread::spawn(move || {
337 let mut s = stderr;
338 let buf = read_to_end_truncated(&mut s, MAX_STDERR_BYTES).unwrap_or_default();
339 let _ = stderr_tx.send(buf);
340 });
341
342 let deadline = Instant::now() + self.config.timeout;
343 let exit_status = match wait_with_deadline(&mut child, deadline) {
344 Ok(status) => status,
345 Err(WaitError::Timeout) => {
346 terminate_child_tree(&mut child);
347 return Err(SubprocessInvokerError::Timeout);
348 }
349 Err(WaitError::Io(e)) => {
350 terminate_child_tree(&mut child);
351 return Err(SubprocessInvokerError::ReadResponse(e));
352 }
353 };
354
355 let grace = Duration::from_millis(100);
370 let join_timeout = remaining(deadline).max(grace);
371
372 let mut deferred_pipe_write_err: Option<std::io::Error> = None;
395 match writer_rx.recv_timeout(join_timeout) {
396 Ok(Ok(())) => {}
397 Ok(Err(e)) => {
398 if e.kind() == std::io::ErrorKind::BrokenPipe {
399 if !exit_status.success() {
400 let stderr_bytes = stderr_rx.recv_timeout(join_timeout).unwrap_or_default();
401 let stderr_text = String::from_utf8_lossy(&stderr_bytes).into_owned();
402 return Err(SubprocessInvokerError::NonZeroExit {
403 code: exit_status.code(),
404 stderr: stderr_text,
405 });
406 }
407 deferred_pipe_write_err = Some(e);
409 } else {
410 return Err(SubprocessInvokerError::WriteRequest(e));
411 }
412 }
413 Err(mpsc::RecvTimeoutError::Timeout) => {
414 terminate_child_tree(&mut child);
415 return Err(SubprocessInvokerError::Timeout);
416 }
417 Err(mpsc::RecvTimeoutError::Disconnected) => {
418 return Err(SubprocessInvokerError::WriteRequest(std::io::Error::other(
419 "writer thread disconnected before reporting result",
420 )));
421 }
422 }
423
424 let stdout_bytes = match stdout_rx.recv_timeout(join_timeout) {
425 Ok(Ok(buf)) => buf,
426 Ok(Err(e)) => return Err(SubprocessInvokerError::ReadResponse(e)),
427 Err(mpsc::RecvTimeoutError::Timeout) => {
428 terminate_child_tree(&mut child);
429 return Err(SubprocessInvokerError::Timeout);
430 }
431 Err(mpsc::RecvTimeoutError::Disconnected) => {
432 return Err(SubprocessInvokerError::ReadResponse(std::io::Error::other(
433 "stdout reader thread disconnected before reporting result",
434 )));
435 }
436 };
437
438 let stderr_bytes = match stderr_rx.recv_timeout(join_timeout) {
439 Ok(buf) => buf,
440 Err(mpsc::RecvTimeoutError::Timeout) => {
441 terminate_child_tree(&mut child);
442 Vec::new()
443 }
444 Err(mpsc::RecvTimeoutError::Disconnected) => Vec::new(),
445 };
446 let stderr_text = String::from_utf8_lossy(&stderr_bytes).into_owned();
447
448 if !exit_status.success() {
449 return Err(SubprocessInvokerError::NonZeroExit {
450 code: exit_status.code(),
451 stderr: stderr_text,
452 });
453 }
454
455 let response: CallbackResponse = match serde_json::from_slice(&stdout_bytes) {
456 Ok(response) => response,
457 Err(parse_err) => {
458 if let Some(write_err) = deferred_pipe_write_err {
463 return Err(SubprocessInvokerError::WriteRequest(write_err));
464 }
465 return Err(SubprocessInvokerError::ParseResponse(parse_err));
466 }
467 };
468 response
469 .validate()
470 .map_err(SubprocessInvokerError::InvalidResponse)?;
471 Ok(response)
472 }
473}
474
475impl CallbackInvoker for SubprocessCallbackInvoker {
476 type Error = SubprocessInvokerError;
477
478 fn invoke(
479 &self,
480 plan: &RoutingPlan,
481 payloads: &[PayloadEnvelope],
482 ) -> Result<CallbackResponse, Self::Error> {
483 self.invoke_inner(plan, payloads)
484 }
485}
486
487fn read_to_end_limited<R: Read>(
488 reader: &mut R,
489 max_bytes: u64,
490 stream_name: &'static str,
491) -> std::io::Result<Vec<u8>> {
492 let mut buf = Vec::new();
493 reader.take(max_bytes + 1).read_to_end(&mut buf)?;
494 if buf.len() as u64 > max_bytes {
495 return Err(std::io::Error::other(format!(
496 "subprocess {stream_name} exceeded {max_bytes} bytes"
497 )));
498 }
499 Ok(buf)
500}
501
502fn read_to_end_truncated<R: Read>(reader: &mut R, max_bytes: u64) -> std::io::Result<Vec<u8>> {
503 let mut buf = Vec::new();
504 reader.take(max_bytes + 1).read_to_end(&mut buf)?;
505 if buf.len() as u64 > max_bytes {
506 buf.truncate(max_bytes as usize);
507 buf.extend_from_slice(b"\n[stderr truncated]\n");
508 }
509 Ok(buf)
510}
511
512fn terminate_child_tree(child: &mut Child) {
513 #[cfg(unix)]
514 terminate_process_group(child.id());
515 let _ = child.kill();
516 let _ = child.wait();
517}
518
519#[cfg(unix)]
520fn terminate_process_group(child_pid: u32) {
521 let pgid = format!("-{child_pid}");
522 let _ = Command::new("kill")
523 .args(["-TERM", "--", &pgid])
524 .stdout(Stdio::null())
525 .stderr(Stdio::null())
526 .status();
527 thread::sleep(Duration::from_millis(20));
528 let _ = Command::new("kill")
529 .args(["-KILL", "--", &pgid])
530 .stdout(Stdio::null())
531 .stderr(Stdio::null())
532 .status();
533}
534
535enum WaitError {
540 Timeout,
541 Io(std::io::Error),
542}
543
544fn remaining(deadline: Instant) -> Duration {
552 deadline.saturating_duration_since(Instant::now())
553}
554
555fn wait_with_deadline(
556 child: &mut Child,
557 deadline: Instant,
558) -> Result<std::process::ExitStatus, WaitError> {
559 let mut interval = Duration::from_millis(2);
560 let cap = Duration::from_millis(50);
561 loop {
562 match child.try_wait() {
563 Ok(Some(status)) => return Ok(status),
564 Ok(None) => {}
565 Err(e) => return Err(WaitError::Io(e)),
566 }
567 let now = Instant::now();
568 if now >= deadline {
569 match child.try_wait() {
572 Ok(Some(status)) => return Ok(status),
573 Ok(None) => return Err(WaitError::Timeout),
574 Err(e) => return Err(WaitError::Io(e)),
575 }
576 }
577 let remaining = deadline.saturating_duration_since(now);
578 thread::sleep(interval.min(remaining));
579 if interval < cap {
580 interval = (interval * 2).min(cap);
581 }
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use std::io::Cursor;
589
590 #[test]
591 fn failure_class_mapping_is_deterministic() {
592 let cases: Vec<(SubprocessInvokerError, FailureClass)> = vec![
593 (
594 SubprocessInvokerError::ReceiptEmittedRejected(RouteError::InvalidEventEnvelope {
595 detail: "x".into(),
596 }),
597 FailureClass::InvalidRequest,
598 ),
599 (
600 SubprocessInvokerError::Spawn(std::io::Error::other("nope")),
601 FailureClass::TransportError,
602 ),
603 (
604 SubprocessInvokerError::WriteRequest(std::io::Error::other("epipe")),
605 FailureClass::TransportError,
606 ),
607 (
608 SubprocessInvokerError::ReadResponse(std::io::Error::other("eof")),
609 FailureClass::TransportError,
610 ),
611 (
612 SubprocessInvokerError::NonZeroExit {
613 code: Some(1),
614 stderr: "bang".into(),
615 },
616 FailureClass::TransportError,
617 ),
618 (SubprocessInvokerError::Timeout, FailureClass::Timeout),
619 (
620 SubprocessInvokerError::ParseResponse(
621 serde_json::from_str::<serde_json::Value>("not json").unwrap_err(),
622 ),
623 FailureClass::InvalidRequest,
624 ),
625 ];
626 for (err, expected) in cases {
627 let fc = failure_class_for_subprocess_error(&err);
628 assert_eq!(fc, expected, "subprocess err -> failure class: {err}");
629 let via_from: FailureClass = (&err).into();
630 assert_eq!(via_from, fc);
631 }
632 }
633
634 #[test]
635 fn transport_error_for_distinguishes_retryable_shapes() {
636 assert!(matches!(
637 transport_error_for(&SubprocessInvokerError::Timeout),
638 Some(TransportError::Timeout)
639 ));
640 assert!(matches!(
641 transport_error_for(&SubprocessInvokerError::Spawn(std::io::Error::other("x"))),
642 Some(TransportError::Io(_))
643 ));
644 assert!(
645 transport_error_for(&SubprocessInvokerError::ReceiptEmittedRejected(
646 RouteError::InvalidEventEnvelope { detail: "x".into() }
647 ))
648 .is_none()
649 );
650 }
651
652 #[test]
653 fn read_to_end_limited_allows_exact_limit_and_rejects_overflow() {
654 let mut exact = Cursor::new(b"abcd".to_vec());
655 assert_eq!(
656 read_to_end_limited(&mut exact, 4, "stdout").unwrap(),
657 b"abcd"
658 );
659
660 let mut over = Cursor::new(b"abcde".to_vec());
661 let err = read_to_end_limited(&mut over, 4, "stdout").unwrap_err();
662 assert!(
663 err.to_string()
664 .contains("subprocess stdout exceeded 4 bytes")
665 );
666 }
667
668 #[test]
669 fn read_to_end_truncated_marks_only_over_limit_stderr() {
670 let mut exact = Cursor::new(b"abcd".to_vec());
671 assert_eq!(read_to_end_truncated(&mut exact, 4).unwrap(), b"abcd");
672
673 let mut over = Cursor::new(b"abcde".to_vec());
674 let mut expected = b"abcd".to_vec();
675 expected.extend_from_slice(b"\n[stderr truncated]\n");
676 assert_eq!(read_to_end_truncated(&mut over, 4).unwrap(), expected);
677 }
678
679 #[test]
680 fn remaining_reports_future_duration_and_saturates_past_deadline() {
681 let future = Instant::now() + Duration::from_secs(60);
682 assert!(remaining(future) > Duration::from_secs(59));
683
684 let past = Instant::now() - Duration::from_millis(1);
685 assert_eq!(remaining(past), Duration::ZERO);
686 }
687
688 #[test]
689 fn config_is_chainable() {
690 let cfg = SubprocessInvokerConfig::new("/bin/cat", Duration::from_secs(1))
691 .arg("-")
692 .args(["--flag"]);
693 assert_eq!(cfg.program(), &PathBuf::from("/bin/cat"));
694 assert_eq!(cfg.timeout(), Duration::from_secs(1));
695 }
696
697 #[cfg(unix)]
698 #[test]
699 fn zero_exit_child_that_ignores_stdin_yields_its_response_despite_epipe() {
700 use crate::{
701 AdapterManifest, AdapterRole, IntegrationMode, LifecycleEventKind,
702 ManifestContextPressure, ManifestReceipts, ReceiptStatus, SCHEMA_VERSION, SupportState,
703 };
704 use std::collections::BTreeMap;
705
706 let response = CallbackResponse::ok(ReceiptStatus::Delivered);
713 let response_json = serde_json::to_string(&response).expect("serialize response");
714 let script = format!("printf '%s' '{response_json}'; exit 0");
716
717 let cfg = SubprocessInvokerConfig::new("/bin/sh", Duration::from_secs(5))
718 .arg("-c")
719 .arg(script);
720 let invoker = SubprocessCallbackInvoker::new(cfg);
721
722 let manifest = AdapterManifest {
723 contract_version: SCHEMA_VERSION.to_string(),
724 adapter_id: "fake-adapter".into(),
725 adapter_version: "0.0.1".into(),
726 display_name: "Fake".into(),
727 role: AdapterRole::PrimaryWorker,
728 integration_modes: vec![IntegrationMode::NativeHook],
729 lifecycle_events: BTreeMap::new(),
730 placement: BTreeMap::new(),
731 context_pressure: ManifestContextPressure {
732 support: SupportState::Native,
733 evidence: None,
734 },
735 receipts: ManifestReceipts {
736 native: false,
737 lifeloop_synthesized: true,
738 receipt_ledger: SupportState::Unavailable,
739 },
740 session_identity: None,
741 session_rename: None,
742 renewal: None,
743 approval_surface: None,
744 failure_modes: Vec::new(),
745 telemetry_sources: Vec::new(),
746 };
747 let plan = RoutingPlan {
748 event: LifecycleEventKind::SessionStarting,
749 event_id: "evt_1".into(),
750 invocation_id: "inv_1".into(),
751 adapter: manifest,
752 integration_mode: IntegrationMode::NativeHook,
753 harness_session_id: None,
754 harness_run_id: None,
755 harness_task_id: None,
756 frame_context: None,
757 payload_refs: Vec::new(),
758 capability_snapshot_ref: None,
759 sequence: None,
760 idempotency_key: None,
761 metadata: serde_json::Map::new(),
762 };
763
764 let big_body = "x".repeat(1024 * 1024);
768 let payload = PayloadEnvelope {
769 schema_version: SCHEMA_VERSION.to_string(),
770 payload_id: "p_big".into(),
771 client_id: "test-client".into(),
772 payload_kind: "instruction_frame".into(),
773 format: "client-defined".into(),
774 content_encoding: "utf8".into(),
775 body: Some(big_body),
776 body_ref: None,
777 byte_size: 1024 * 1024,
778 content_digest: None,
779 acceptable_placements: Vec::new(),
780 idempotency_key: None,
781 expires_at_epoch_s: None,
782 metadata: serde_json::Map::new(),
783 };
784
785 let got = invoker
786 .invoke(&plan, &[payload])
787 .expect("zero-exit response must be returned despite stdin EPIPE");
788 assert_eq!(got.status, ReceiptStatus::Delivered);
789 }
790}