Skip to main content

lifeloop/router/
subprocess.rs

1//! Subprocess-backed callback invocation (issue #8).
2//!
3//! Fills the [`CallbackInvoker`] seam declared in `src/router/seams.rs`
4//! (issue #7) for the **process-boundary** transport. This module is
5//! the load-bearing proof that Lifeloop can drive an external client
6//! command without taking a Rust dependency on it: the only contract
7//! between Lifeloop and the client is JSON over stdio.
8//!
9//! # Boundary
10//!
11//! Owns:
12//! * [`SubprocessInvokerConfig`] — configurable command/args/timeout
13//!   used to spawn the external client per invocation;
14//! * [`SubprocessCallbackInvoker`] — concrete [`CallbackInvoker`]
15//!   implementation that spawns a fresh child for each event, pipes a
16//!   [`crate::CallbackRequest`] JSON document to its stdin, reads a
17//!   [`CallbackResponse`] JSON document from its stdout, and validates
18//!   it;
19//! * [`SubprocessInvokerError`] — typed error variants carrying enough
20//!   detail for [`super::LifeloopFailureMapper`] to map onto the
21//!   shared [`crate::FailureClass`] vocabulary;
22//! * [`failure_class_for_subprocess_error`] / `From<&SubprocessInvokerError>
23//!   for FailureClass` — deterministic mapping into the existing
24//!   failure-class vocabulary. No new variants are introduced.
25//!
26//! Does **not** own:
27//! * receipt synthesis (that is [`super::receipts`]);
28//! * wire schema (the request/response types come from the lifeloop
29//!   crate root and are unchanged by this module).
30//!
31//! # Receipt-emitted guard
32//!
33//! `receipt.emitted` is a notification event and must not be invoked
34//! downstream. The subprocess invoker rejects such plans **before** it
35//! spawns a child — same rule as the in-memory path, enforced via
36//! [`super::validate_receipt_eligible`]. The rejection surfaces as
37//! [`SubprocessInvokerError::ReceiptEmittedRejected`] which maps to
38//! [`crate::FailureClass::InvalidRequest`].
39
40use 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// ===========================================================================
59// SubprocessInvokerConfig
60// ===========================================================================
61
62/// Configuration for [`SubprocessCallbackInvoker`].
63///
64/// `program` is the external client command (an absolute path to the
65/// client's callback binary). `args` are appended on every spawn.
66/// `timeout` bounds the total round trip — write request, read
67/// response, child exit. A child still running at deadline is killed
68/// and the invocation fails with [`SubprocessInvokerError::Timeout`].
69#[derive(Debug, Clone)]
70pub struct SubprocessInvokerConfig {
71    program: PathBuf,
72    args: Vec<OsString>,
73    timeout: Duration,
74}
75
76impl SubprocessInvokerConfig {
77    /// Construct a config from an explicit program path and timeout.
78    /// The default timeout is intentionally not provided — the caller
79    /// must pick a value that matches its lifecycle SLOs.
80    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    /// Append a single argument. Returns `self` for chaining.
89    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
90        self.args.push(arg.into());
91        self
92    }
93
94    /// Append a sequence of arguments. Returns `self` for chaining.
95    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// ===========================================================================
114// SubprocessInvokerError
115// ===========================================================================
116
117/// Failure variants surfaced by [`SubprocessCallbackInvoker::invoke`].
118///
119/// Every variant maps deterministically onto a single
120/// [`FailureClass`] from the shared vocabulary — no new failure
121/// classes are introduced by the subprocess transport.
122#[derive(Debug)]
123pub enum SubprocessInvokerError {
124    /// Plan rejected before spawn because the event is
125    /// `receipt.emitted` (a notification event that must not produce
126    /// downstream invocations). Maps to
127    /// [`FailureClass::InvalidRequest`].
128    ReceiptEmittedRejected(RouteError),
129    /// Failed to spawn the child (e.g. binary not found, permission
130    /// denied). Maps to [`FailureClass::TransportError`].
131    Spawn(std::io::Error),
132    /// Failed to write the request JSON to the child's stdin. Maps to
133    /// [`FailureClass::TransportError`].
134    WriteRequest(std::io::Error),
135    /// Failed to serialize the [`crate::CallbackRequest`] before writing.
136    /// Treated as an internal-class failure — the bug is on the
137    /// Lifeloop side, not the transport.
138    SerializeRequest(serde_json::Error),
139    /// Failed to read the child's stdout. Maps to
140    /// [`FailureClass::TransportError`].
141    ReadResponse(std::io::Error),
142    /// Child wrote bytes that did not parse as JSON. Maps to
143    /// [`FailureClass::InvalidRequest`] — the response is malformed
144    /// at the wire layer.
145    ParseResponse(serde_json::Error),
146    /// JSON parsed cleanly but the response failed
147    /// [`CallbackResponse::validate`]. Maps to
148    /// [`FailureClass::InvalidRequest`].
149    InvalidResponse(ValidationError),
150    /// Child exited non-zero. Maps to
151    /// [`FailureClass::TransportError`] — the transport returned a
152    /// failure signal we cannot interpret further.
153    NonZeroExit { code: Option<i32>, stderr: String },
154    /// Round trip exceeded the configured timeout; child was killed.
155    /// Maps to [`FailureClass::Timeout`].
156    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
188/// Map a [`SubprocessInvokerError`] onto the shared
189/// [`FailureClass`] vocabulary.
190///
191/// Pure function: same variant always maps to the same class so a
192/// receipt ledger replays consistently. Stays aligned with the
193/// in-process [`TransportError`] mapping in
194/// `super::failure_mapping` (private).
195pub 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
214/// Convert a [`SubprocessInvokerError`] into the shared
215/// [`TransportError`] enum so callers that already speak in
216/// [`super::LifeloopFailureMapper::map_transport_error`] terms can
217/// route subprocess failures through the same path. `None` for
218/// variants that are not transport-class (validation, parse,
219/// receipt-emitted rejection).
220pub 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
236// ===========================================================================
237// SubprocessCallbackInvoker
238// ===========================================================================
239
240const MAX_STDOUT_BYTES: u64 = 16 * 1024 * 1024;
241const MAX_STDERR_BYTES: u64 = 256 * 1024;
242
243/// Subprocess-backed [`CallbackInvoker`].
244///
245/// Spawns a fresh child per invocation. The protocol is intentionally
246/// minimal: stdin receives a single JSON-serialized
247/// [`DispatchEnvelope`] (carrying the [`crate::CallbackRequest`] and
248/// any opaque [`PayloadEnvelope`] bodies); stdout returns a single
249/// JSON-serialized [`CallbackResponse`]. The child must exit zero on
250/// success. Anything outside that contract surfaces as a typed
251/// [`SubprocessInvokerError`].
252///
253/// # Payload bodies
254///
255/// Payload bodies flow through the subprocess channel inside the
256/// [`DispatchEnvelope`] (issue #22). Bodies are transported verbatim:
257/// Lifeloop never parses `body` or dereferences `body_ref`. Clients
258/// receive the same envelopes a caller passed to
259/// [`CallbackInvoker::invoke`].
260#[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    /// Internal: build the [`crate::CallbackRequest`], spawn, write, read,
275    /// validate. Factored out so [`CallbackInvoker::invoke`] stays a
276    /// thin adapter.
277    fn invoke_inner(
278        &self,
279        plan: &RoutingPlan,
280        payloads: &[PayloadEnvelope],
281    ) -> Result<CallbackResponse, SubprocessInvokerError> {
282        // Pre-spawn guard: receipt.emitted must never produce a
283        // downstream invocation. Same rule as the receipt emitter,
284        // enforced before any IO so a misuse cannot leak side
285        // effects (spawned process, stderr, exit-code observability).
286        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        // Hand stdin/stdout/stderr to helper threads so the main
306        // thread can honor the timeout independently of how the
307        // child consumes input. Each thread reports its result over
308        // an mpsc channel so the main thread can bound every join by
309        // the configured deadline — a direct `JoinHandle::join()`
310        // would block indefinitely if a descendant of the child
311        // inherited a pipe handle and held it open after the direct
312        // child exited.
313        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                // stdin dropped on scope exit, signaling EOF.
325            }
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        // Reap helper threads via channels with deadline-bounded
356        // recvs. After child exit the OS closes the child-side pipe
357        // ends, so on a well-behaved client all three threads finish
358        // immediately. If a descendant inherited stdout/stderr and is
359        // still holding a writer end, the read threads block — and
360        // we treat that as a transport timeout rather than hanging
361        // the invoker. The configured `timeout` therefore bounds the
362        // *total* round trip (write + child run + drain), not just
363        // child exit.
364        //
365        // A small grace window covers the common case where the
366        // child has just exited and the threads have not yet noticed
367        // EOF; without it, a deadline that already elapsed would
368        // spuriously time out a successful run.
369        let grace = Duration::from_millis(100);
370        let join_timeout = remaining(deadline).max(grace);
371
372        // Writer first: a stdin write failure means the request was
373        // not delivered intact, so we MUST surface it even when the
374        // child later exits zero. A misbehaving client that ignores
375        // stdin and fabricates a response is exactly the false-
376        // success path the transport contract forbids.
377        //
378        // Exception: EPIPE on stdin almost always means the child
379        // already closed its read end — i.e. it has exited. The exit
380        // code, not the BrokenPipe, is the truthful diagnosis:
381        //   * non-zero exit -> surface the exit code; the BrokenPipe is
382        //     its downstream symptom, and surfacing it would mask the
383        //     real failure (and make the error scheduling-dependent,
384        //     since a slower runner observes EPIPE before the writer
385        //     finishes);
386        //   * zero exit -> the child may have read the prefix it needed,
387        //     written a valid response, and exited cleanly without
388        //     draining the rest of stdin (realistic once the payload
389        //     exceeds the OS pipe buffer). In that case the BrokenPipe
390        //     is benign and we must NOT discard the captured response.
391        //     We defer the decision: parse stdout first, return the
392        //     response when present, and fall back to surfacing the
393        //     BrokenPipe write error only if no response was produced.
394        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                    // Zero exit + BrokenPipe: defer, decide after parse.
408                    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                // A deferred benign-pipe write error only matters when
459                // the child produced no usable response: prefer the
460                // truthful transport diagnosis (the request was not
461                // delivered intact) over a misleading parse error.
462                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
535// ===========================================================================
536// wait_with_deadline
537// ===========================================================================
538
539enum WaitError {
540    Timeout,
541    Io(std::io::Error),
542}
543
544/// Poll-based deadline wait. `std::process::Child` has no portable
545/// timed-wait; we poll `try_wait` with bounded sleeps. The poll
546/// interval is short relative to typical lifecycle SLOs (10ms) and
547/// scales with the remaining deadline so a 5-second timeout does not
548/// burn CPU.
549/// Time remaining until `deadline`. Saturates at zero so callers can
550/// use it as a `recv_timeout` argument without underflowing.
551fn 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            // One last try_wait to avoid losing a race where the
570            // child exited between the previous check and now.
571            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        // A client that emits a valid CallbackResponse on stdout and
707        // exits zero WITHOUT draining stdin. With a payload larger than
708        // the OS pipe buffer, the parent's stdin write hits EPIPE after
709        // the child has already exited cleanly. The invoker must still
710        // parse the captured response rather than reporting a transport
711        // write error.
712        let response = CallbackResponse::ok(ReceiptStatus::Delivered);
713        let response_json = serde_json::to_string(&response).expect("serialize response");
714        // `printf` the response, then exit 0; stdin is never read.
715        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        // A payload body comfortably larger than any common pipe
765        // buffer (64 KiB on Linux, 16 KiB on macOS) so the unread
766        // stdin write provokes EPIPE.
767        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}