Skip to main content

contextgraph_host/
stdio.rs

1//! Stdio transport: a child-process Context Graph Protocol provider spoken to over its
2//! stdin/stdout (`SPEC.md` §3 "local providers: child
3//! processes over stdio").
4//!
5//! Two layers:
6//!
7//! - [`RawStdioConnection`] — the low-level framed pipe. Public because
8//!   conformance tooling needs byte-level control (e.g. injecting a
9//!   malformed line to probe provider robustness, SPEC.md §11). It owns the child,
10//!   spawns it under the Context Graph Protocol isolation contract, and guarantees the process
11//!   group dies on drop/shutdown.
12//! - [`StdioProvider`] — a [`ContextProvider`] built on the connection: it
13//!   handshakes once, caches the provider's identity + capabilities, and then
14//!   splits the pipe into independently-lockable halves. A dedicated reader
15//!   task demultiplexes replies on their correlation `id`, and the write half
16//!   is locked only for the length of one line — so a provider that negotiated
17//!   `correlation` can have several queries in flight at once (a slow one no
18//!   longer head-of-line blocks the rest), while a non-correlating provider and
19//!   every `verify` stay strictly lock-step, behaving exactly as the original
20//!   single-mutex transport did ([ADR 0002](../../docs/adr/0002-request-correlation-and-the-json-rpc-question.md)).
21//!
22//! ## Isolation (`SPEC.md` §4 and §10, `SPEC.md` §7)
23//!
24//! The child is spawned with a **scrubbed environment** — `env_clear()` then
25//! an allowlist of only `PATH` (so the program resolves) and `HOME`. No
26//! inherited credentials, no ambient secrets: a provider sees exactly the
27//! query payload and whatever it indexed through its own declared inputs,
28//! nothing the host holds. On Unix the child leads its own process group so
29//! the whole subtree is signalled at once and can never outlive the host.
30
31use std::collections::HashMap;
32use std::process::Stdio;
33use std::sync::{Arc, Mutex as StdMutex};
34use std::time::Duration;
35
36use async_trait::async_trait;
37use contextgraph_types::{
38    Capabilities, ContextQuery, ContextQueryResult, PROTOCOL_VERSION, ProviderInfo, VerifyRequest,
39    VerifyResponse,
40};
41use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
42use tokio::process::{Child, ChildStdin, ChildStdout, Command};
43use tokio::sync::{Mutex as TokioMutex, oneshot};
44use tokio::task::JoinHandle;
45
46use crate::error::HostError;
47use crate::provider::ContextProvider;
48use crate::wire::{
49    AttesterKey, Envelope, decode_line, encode_line, envelope_kind, next_correlation_id,
50    verify_correlation, versions_compatible,
51};
52
53/// How long the handshake waits for a provider's ack before giving up —
54/// bounds the "version mismatch = never a hang" guarantee even against a
55/// provider that never answers (task deliverable 1).
56const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
57
58/// How long a graceful `shutdown` waits for the child to exit before the
59/// process-group kill backstop fires (task deliverable 2).
60const SHUTDOWN_GRACE: Duration = Duration::from_secs(2);
61
62/// Maximum bytes accepted for a single framed line before the child is treated
63/// as malformed. Guards the host against a provider that streams without ever
64/// emitting a newline (the timeouts bound time, not memory). 16 MiB is far
65/// above any legitimate framed message.
66const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
67
68/// Read one NDJSON line from a provider's stdout, or `None` at EOF, bounded to
69/// [`MAX_LINE_BYTES`] via an incremental `fill_buf`/`consume` loop so a child
70/// that streams without ever emitting a newline cannot OOM the host.
71///
72/// Shared by [`RawStdioConnection::read_raw_line`] and the [`StdioProvider`]
73/// reader task, so the memory bound has exactly one implementation.
74async fn read_framed_line(
75    stdout: &mut BufReader<ChildStdout>,
76    label: &str,
77) -> Result<Option<String>, HostError> {
78    let transport = |message: String| HostError::Transport {
79        id: label.to_string(),
80        message,
81    };
82    let mut bytes: Vec<u8> = Vec::new();
83    loop {
84        let buf = stdout
85            .fill_buf()
86            .await
87            .map_err(|e| transport(e.to_string()))?;
88        if buf.is_empty() {
89            break; // EOF — deliver any final unterminated line, else None.
90        }
91        if let Some(pos) = buf.iter().position(|&b| b == b'\n') {
92            bytes.extend_from_slice(&buf[..=pos]);
93            stdout.consume(pos + 1);
94            break;
95        }
96        bytes.extend_from_slice(buf);
97        let consumed = buf.len();
98        stdout.consume(consumed);
99        if bytes.len() > MAX_LINE_BYTES {
100            return Err(transport(format!(
101                "provider emitted a line exceeding {MAX_LINE_BYTES} bytes without a newline"
102            )));
103        }
104    }
105    if bytes.is_empty() {
106        Ok(None)
107    } else {
108        Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
109    }
110}
111
112/// Write an already-framed line to a provider's stdin, appending a trailing
113/// `\n` if missing. A closed pipe (the child is gone) surfaces as
114/// [`HostError::ProviderCrashed`] — the write-side twin of the read-side EOF —
115/// and any other IO error as [`HostError::Transport`], so both halves report a
116/// dead child the same way. Shared by [`RawStdioConnection::send_raw_line`] and
117/// the [`StdioProvider`] write path.
118async fn write_framed_line(
119    stdin: &mut ChildStdin,
120    line: &str,
121    label: &str,
122) -> Result<(), HostError> {
123    let transport = |e: std::io::Error| match e.kind() {
124        std::io::ErrorKind::BrokenPipe => HostError::ProviderCrashed {
125            id: label.to_string(),
126        },
127        _ => HostError::Transport {
128            id: label.to_string(),
129            message: e.to_string(),
130        },
131    };
132    stdin.write_all(line.as_bytes()).await.map_err(transport)?;
133    if !line.ends_with('\n') {
134        stdin.write_all(b"\n").await.map_err(transport)?;
135    }
136    stdin.flush().await.map_err(transport)?;
137    Ok(())
138}
139
140/// Encode `env` and write it as one NDJSON line to `stdin`.
141async fn write_envelope(
142    stdin: &mut ChildStdin,
143    env: &Envelope,
144    label: &str,
145) -> Result<(), HostError> {
146    let line = encode_line(env)?;
147    write_framed_line(stdin, &line, label).await
148}
149
150/// A raw, framed connection to a child-process Context Graph Protocol provider. The low-level
151/// primitive [`StdioProvider`] is built on; public so conformance tools can
152/// drive the wire directly.
153pub struct RawStdioConnection {
154    stdin: ChildStdin,
155    stdout: BufReader<ChildStdout>,
156    child: Child,
157    /// Process-group id (== the child pid, which `setsid` made a group
158    /// leader) for the backstop kill. `None` off Unix, where `kill_on_drop`
159    /// reaps the direct child only.
160    #[cfg_attr(not(unix), allow(dead_code))]
161    pgid: Option<i32>,
162    /// A stable label for error messages before the handshake names the
163    /// provider.
164    label: String,
165    /// The attester public keys the handshake declared (`SPEC.md` §6.5).
166    /// Empty until [`handshake`](Self::handshake) runs, and empty afterwards
167    /// for the many providers that sign nothing.
168    attester_keys: Vec<AttesterKey>,
169}
170
171impl RawStdioConnection {
172    /// Spawn `program` with `args` as a CGP provider child, under the
173    /// isolation contract (scrubbed env, own process group). Does **not**
174    /// handshake — call [`RawStdioConnection::handshake`] next.
175    pub async fn spawn(program: &str, args: &[String]) -> Result<Self, HostError> {
176        let mut cmd = Command::new(program);
177        cmd.args(args);
178        cmd.stdin(Stdio::piped());
179        cmd.stdout(Stdio::piped());
180        // Provider diagnostics flow to the host's own stderr — never captured
181        // as frames, never mistaken for protocol.
182        cmd.stderr(Stdio::inherit());
183        cmd.kill_on_drop(true);
184
185        // Scrub the environment: no inherited credentials. Allowlist
186        // only PATH (so `program` resolves) and HOME.
187        cmd.env_clear();
188        if let Ok(path) = std::env::var("PATH") {
189            cmd.env("PATH", path);
190        }
191        if let Ok(home) = std::env::var("HOME") {
192            cmd.env("HOME", home);
193        }
194
195        // New session/process group so the whole subtree can be signalled at
196        // once on drop/shutdown.
197        #[cfg(unix)]
198        {
199            // SAFETY: `setsid` is async-signal-safe and only reparents the
200            // child's own process-group membership in the window between fork
201            // and exec — the same narrowly-scoped OS-boundary use
202            // `stella-tools`' bash tool makes.
203            unsafe {
204                cmd.pre_exec(|| {
205                    libc::setsid();
206                    Ok(())
207                });
208            }
209        }
210
211        let mut child = cmd
212            .spawn()
213            .map_err(|e| HostError::Spawn(format!("{program}: {e}")))?;
214        let stdin = child
215            .stdin
216            .take()
217            .ok_or_else(|| HostError::Spawn(format!("{program}: child has no stdin pipe")))?;
218        let stdout = child
219            .stdout
220            .take()
221            .ok_or_else(|| HostError::Spawn(format!("{program}: child has no stdout pipe")))?;
222
223        #[cfg(unix)]
224        let pgid = child.id().map(|id| id as i32);
225        #[cfg(not(unix))]
226        let pgid = None;
227
228        Ok(Self {
229            stdin,
230            stdout: BufReader::new(stdout),
231            child,
232            pgid,
233            label: program.to_string(),
234            attester_keys: Vec::new(),
235        })
236    }
237
238    /// Override the connection's error label (defaults to the program name).
239    /// [`StdioProvider`] sets it to the provider's host-facing id.
240    pub fn with_label(mut self, label: impl Into<String>) -> Self {
241        self.label = label.into();
242        self
243    }
244
245    /// Send one envelope as an NDJSON line.
246    pub async fn send(&mut self, env: &Envelope) -> Result<(), HostError> {
247        let line = encode_line(env)?;
248        self.send_raw_line(&line).await
249    }
250
251    /// Write a raw line to the provider's stdin verbatim — the escape hatch
252    /// conformance uses to inject a malformed line (SPEC.md §11). A trailing `\n` is
253    /// appended if missing so the provider's line reader unblocks.
254    pub async fn send_raw_line(&mut self, line: &str) -> Result<(), HostError> {
255        // A write into a closed stdin means the child is gone — the write-side
256        // twin of the read-side EOF in `recv`, so both surface as
257        // ProviderCrashed. Delegated to the shared writer so the raw path and
258        // the pipelined `StdioProvider` path frame lines identically.
259        write_framed_line(&mut self.stdin, line, &self.label).await
260    }
261
262    /// Read the next raw line, or `None` at EOF (the child closed stdout).
263    ///
264    /// Bounded to [`MAX_LINE_BYTES`] via an incremental `fill_buf`/`consume`
265    /// loop: a buggy or hostile child that streams bytes without ever emitting
266    /// a newline would otherwise grow a single `String` without limit (the
267    /// handshake/query timeouts bound *time*, not *memory*) and OOM the host.
268    pub async fn read_raw_line(&mut self) -> Result<Option<String>, HostError> {
269        // Delegated to the shared reader so the `MAX_LINE_BYTES` memory bound
270        // has one implementation across the raw path and the pipelined
271        // `StdioProvider` reader task.
272        read_framed_line(&mut self.stdout, &self.label).await
273    }
274
275    /// Read the next envelope. A closed stream (the child died) is
276    /// [`HostError::ProviderCrashed`] — never a hang or a panic
277    /// (task deliverable 5).
278    pub async fn recv(&mut self) -> Result<Envelope, HostError> {
279        match self.read_raw_line().await? {
280            Some(line) => decode_line(&line),
281            None => Err(HostError::ProviderCrashed {
282                id: self.label.clone(),
283            }),
284        }
285    }
286
287    /// Perform the Context Graph Protocol handshake (SPEC.md §3): send `handshake`, expect
288    /// `handshake_ack`, and reject an incompatible protocol version with a
289    /// named error. Bounded by [`HANDSHAKE_TIMEOUT`] so a silent provider
290    /// fails cleanly rather than hanging (task deliverable 1).
291    pub async fn handshake(&mut self) -> Result<(ProviderInfo, Capabilities), HostError> {
292        self.send(&Envelope::Handshake {
293            protocol_version: PROTOCOL_VERSION.to_string(),
294        })
295        .await?;
296
297        let ack = match tokio::time::timeout(HANDSHAKE_TIMEOUT, self.recv()).await {
298            Ok(result) => result?,
299            Err(_) => {
300                return Err(HostError::Timeout {
301                    id: self.label.clone(),
302                    timeout_ms: HANDSHAKE_TIMEOUT.as_millis() as u64,
303                });
304            }
305        };
306
307        match ack {
308            Envelope::HandshakeAck {
309                protocol_version,
310                provider,
311                capabilities,
312                attester_keys,
313            } => {
314                if !versions_compatible(PROTOCOL_VERSION, &protocol_version) {
315                    return Err(HostError::VersionMismatch {
316                        host: PROTOCOL_VERSION.to_string(),
317                        provider: provider.name,
318                        provider_version: protocol_version,
319                    });
320                }
321                self.attester_keys = attester_keys;
322                Ok((provider, capabilities))
323            }
324            other => Err(HostError::UnexpectedEnvelope {
325                id: self.label.clone(),
326                expected: "handshake_ack".into(),
327                got: envelope_kind(&other).into(),
328            }),
329        }
330    }
331
332    /// The attester public keys this provider declared at the handshake
333    /// (`SPEC.md` §6.5), empty before the handshake and for a provider that
334    /// signs nothing.
335    ///
336    /// Kept on the connection rather than returned from
337    /// [`handshake`](Self::handshake) so the attestation conformance probe can
338    /// reach them without every other caller having to widen a tuple.
339    pub fn attester_keys(&self) -> &[AttesterKey] {
340        &self.attester_keys
341    }
342
343    /// Send `shutdown` and wait a bounded grace for the child to exit,
344    /// killing the process group if it overstays (task deliverable 2). A
345    /// provider that already died is not treated as a shutdown error.
346    pub async fn shutdown(&mut self) -> Result<(), HostError> {
347        let _ = self.send(&Envelope::Shutdown).await;
348        let label = self.label.clone();
349        match tokio::time::timeout(SHUTDOWN_GRACE, self.child.wait()).await {
350            Ok(Ok(_)) => Ok(()),
351            Ok(Err(e)) => Err(HostError::Transport {
352                id: label,
353                message: e.to_string(),
354            }),
355            Err(_) => {
356                self.kill_group();
357                Ok(())
358            }
359        }
360    }
361
362    /// SIGKILL the whole process group (Unix) and the direct child. Idempotent
363    /// — signalling an already-dead group is a harmless, ignored `ESRCH`.
364    fn kill_group(&mut self) {
365        #[cfg(unix)]
366        if let Some(pgid) = self.pgid {
367            // SAFETY: `-pgid` targets the process group this connection
368            // created via `setsid`; a stale/dead group returns `ESRCH`,
369            // which we ignore.
370            unsafe {
371                libc::kill(-pgid, libc::SIGKILL);
372            }
373        }
374        let _ = self.child.start_kill();
375    }
376
377    /// Decompose the connection into the independently-owned halves the
378    /// pipelined [`StdioProvider`] runs on: the write half ([`ChildStdin`]), the
379    /// read half ([`BufReader<ChildStdout>`]), and a [`StdioControl`] over the
380    /// child and its process group. Any bytes the `BufReader` buffered past the
381    /// handshake travel with the read half, so nothing is lost across the split.
382    ///
383    /// Consumes `self` **without** running [`Drop`] — its `Drop` kills the
384    /// process group, and here we are keeping the child alive to keep talking to
385    /// it. Private: this is `StdioProvider`'s internal seam, not part of the
386    /// public raw send/recv API conformance tooling drives.
387    fn into_parts(self) -> (ChildStdin, BufReader<ChildStdout>, StdioControl) {
388        // `RawStdioConnection: Drop`, so its fields cannot be moved out by an
389        // ordinary destructuring move. Suppress the destructor and read each
390        // field out exactly once instead.
391        let this = std::mem::ManuallyDrop::new(self);
392        // SAFETY: every non-`Copy` field is read out exactly once via
393        // `ptr::read`; `this` is a `ManuallyDrop`, so its destructor never runs
394        // and no field is dropped twice; and `this` is never touched again after
395        // this block. `pgid` is `Copy` and read by value.
396        unsafe {
397            let stdin = std::ptr::read(&this.stdin);
398            let stdout = std::ptr::read(&this.stdout);
399            let child = std::ptr::read(&this.child);
400            let label = std::ptr::read(&this.label);
401            let pgid = this.pgid;
402            (stdin, stdout, StdioControl { child, pgid, label })
403        }
404    }
405}
406
407impl Drop for RawStdioConnection {
408    fn drop(&mut self) {
409        // Backstop: even if a caller forgot `shutdown`, the child tree dies
410        // with the host (`SPEC.md` §8 — no orphaned children).
411        self.kill_group();
412    }
413}
414
415/// The child + its process group, held by a [`StdioProvider`] solely for
416/// teardown. Splitting it out of the connection is what lets `query`/`verify`
417/// touch only the stdin and reader halves, while `shutdown` (and `Drop`) retain
418/// exclusive control of the process — preserving the original
419/// [`SHUTDOWN_GRACE`] + `kill_group` teardown exactly.
420struct StdioControl {
421    child: Child,
422    /// Process-group id (== the child pid made a group leader by `setsid`) for
423    /// the backstop kill. `None` off Unix.
424    #[cfg_attr(not(unix), allow(dead_code))]
425    pgid: Option<i32>,
426    /// The provider's host-facing id, for teardown error messages.
427    label: String,
428}
429
430impl StdioControl {
431    /// Wait a bounded [`SHUTDOWN_GRACE`] for the child to exit, killing the
432    /// process group if it overstays. The caller sends the `shutdown` envelope
433    /// over stdin first; this is the grace-then-kill backstop, byte for byte the
434    /// tail of the original `RawStdioConnection::shutdown`.
435    async fn wait_or_kill(&mut self) -> Result<(), HostError> {
436        match tokio::time::timeout(SHUTDOWN_GRACE, self.child.wait()).await {
437            Ok(Ok(_)) => Ok(()),
438            Ok(Err(e)) => Err(HostError::Transport {
439                id: self.label.clone(),
440                message: e.to_string(),
441            }),
442            Err(_) => {
443                self.kill_group();
444                Ok(())
445            }
446        }
447    }
448
449    /// SIGKILL the whole process group (Unix) and the direct child. Idempotent —
450    /// signalling an already-dead group is a harmless, ignored `ESRCH`.
451    /// Identical to `RawStdioConnection::kill_group`.
452    fn kill_group(&mut self) {
453        #[cfg(unix)]
454        if let Some(pgid) = self.pgid {
455            // SAFETY: `-pgid` targets the process group created via `setsid`; a
456            // stale/dead group returns `ESRCH`, which we ignore.
457            unsafe {
458                libc::kill(-pgid, libc::SIGKILL);
459            }
460        }
461        let _ = self.child.start_kill();
462    }
463}
464
465impl Drop for StdioControl {
466    fn drop(&mut self) {
467        // Backstop: the child tree dies with the host even if `shutdown` was
468        // never called (`SPEC.md` §8 — no orphaned children).
469        self.kill_group();
470    }
471}
472
473/// A reply delivered to an in-flight exchange: the decoded envelope, or the
474/// error that ended the connection.
475type Reply = Result<Envelope, HostError>;
476
477/// The table of correlated exchanges awaiting their reply, keyed by the `id` the
478/// host minted. The reader task removes and fulfills the matching sender as each
479/// `frames`/`error` arrives.
480type PendingTable = Arc<StdMutex<HashMap<String, oneshot::Sender<Reply>>>>;
481
482/// The single fallback slot for an id-less reply (a non-correlating `query`, or
483/// any `verify`). Serialized by [`StdioProvider`]'s `no_id_lock`, so at most one
484/// sender is ever registered at a time.
485type NoIdSlot = Arc<StdMutex<Option<oneshot::Sender<Reply>>>>;
486
487/// Why the reader loop is terminating. `HostError` is not `Clone`, so the loop
488/// carries the *reason* and mints a fresh error of the right shape for each
489/// waiter it drains.
490enum ReaderExit {
491    /// The child closed stdout mid-exchange — it crashed or exited.
492    Crashed,
493    /// A transport error reading stdout.
494    Transport(String),
495    /// A line that would not decode into an envelope.
496    Decode(String),
497}
498
499impl ReaderExit {
500    fn error(&self, label: &str) -> HostError {
501        match self {
502            ReaderExit::Crashed => HostError::ProviderCrashed {
503                id: label.to_string(),
504            },
505            ReaderExit::Transport(message) => HostError::Transport {
506                id: label.to_string(),
507                message: message.clone(),
508            },
509            ReaderExit::Decode(message) => HostError::Wire(message.clone()),
510        }
511    }
512}
513
514/// The [`StdioProvider`] reader task. It owns the read half for the life of the
515/// connection and is the *only* reader, so replies can be demultiplexed on
516/// `id`. For each envelope it either matches a correlated waiter in `pending` or
517/// hands an id-less reply to the single `no_id_slot`. On EOF, or a decode /
518/// transport error, it drains **every** waiter with a terminal error so no
519/// in-flight `query`/`verify` can hang past the connection's death (ADR 0002 —
520/// the crash-consistency contract).
521async fn run_reader(
522    mut stdout: BufReader<ChildStdout>,
523    label: String,
524    pending: PendingTable,
525    no_id_slot: NoIdSlot,
526) {
527    loop {
528        let exit = match read_framed_line(&mut stdout, &label).await {
529            Ok(Some(line)) => match decode_line(&line) {
530                Ok(env) => {
531                    dispatch(env, &pending, &no_id_slot, &label);
532                    continue;
533                }
534                // A line we cannot attribute to any waiter: the stream is no
535                // longer trustworthy, so fail every exchange rather than let
536                // them hang on a reply that will never parse.
537                Err(err) => ReaderExit::Decode(err.to_string()),
538            },
539            // EOF: the child closed stdout. Every waiter must learn, or
540            // query()/verify() would hang forever.
541            Ok(None) => ReaderExit::Crashed,
542            Err(HostError::Transport { message, .. }) => ReaderExit::Transport(message),
543            Err(other) => ReaderExit::Transport(other.to_string()),
544        };
545        drain_waiters(&pending, &no_id_slot, &exit, &label);
546        return;
547    }
548}
549
550/// Route one decoded provider→host envelope to its waiter. A `frames`/`error`
551/// carrying an `id` is demultiplexed against `pending`; anything else (an
552/// id-less `frames`/`error`, a `verified`, or an unexpected envelope) goes to
553/// the lock-step `no_id_slot`. A reply with no matching waiter is logged to the
554/// host's stderr and dropped — never a panic (ADR 0002: an unmatched or stale
555/// id must not take the connection down).
556fn dispatch(env: Envelope, pending: &PendingTable, no_id_slot: &NoIdSlot, label: &str) {
557    let correlated = match &env {
558        Envelope::Frames { id: Some(id), .. } | Envelope::Error { id: Some(id), .. } => {
559            Some(id.clone())
560        }
561        _ => None,
562    };
563    if let Some(id) = correlated {
564        let waiter = pending.lock().expect("pending mutex poisoned").remove(&id);
565        match waiter {
566            Some(tx) => {
567                let _ = tx.send(Ok(env));
568            }
569            None => eprintln!(
570                "contextgraph-host: stdio provider `{label}` sent a reply with id `{id}` matching no in-flight query; dropping"
571            ),
572        }
573        return;
574    }
575    let waiter = no_id_slot.lock().expect("no_id_slot mutex poisoned").take();
576    match waiter {
577        Some(tx) => {
578            let _ = tx.send(Ok(env));
579        }
580        None => eprintln!(
581            "contextgraph-host: stdio provider `{label}` sent an unsolicited `{}` envelope with no in-flight lock-step exchange; dropping",
582            envelope_kind(&env)
583        ),
584    }
585}
586
587/// Deliver a terminal error to every waiter — the correlated `pending` table and
588/// the id-less slot — so a dead or garbage-emitting provider fails all its
589/// in-flight exchanges instead of hanging them (ADR 0002).
590fn drain_waiters(pending: &PendingTable, no_id_slot: &NoIdSlot, exit: &ReaderExit, label: &str) {
591    let waiters: Vec<oneshot::Sender<Reply>> = {
592        let mut map = pending.lock().expect("pending mutex poisoned");
593        map.drain().map(|(_, tx)| tx).collect()
594    };
595    for tx in waiters {
596        let _ = tx.send(Err(exit.error(label)));
597    }
598    let leftover = { no_id_slot.lock().expect("no_id_slot mutex poisoned").take() };
599    if let Some(tx) = leftover {
600        let _ = tx.send(Err(exit.error(label)));
601    }
602}
603
604/// A [`ContextProvider`] backed by a child process over stdio.
605///
606/// Handshakes once on construction, caches the negotiated identity +
607/// capabilities, then splits the connection into independently-lockable halves:
608/// the write half (`stdin`) is locked only for the length of one line write, a
609/// dedicated reader task owns the read half and demultiplexes replies on their
610/// correlation `id`, and a [`StdioControl`] holds the child for teardown. A
611/// provider that negotiated [`Capabilities::correlation`](contextgraph_types::Capabilities::correlation)
612/// can therefore have several `query`s in flight at once — a slow one no longer
613/// head-of-line blocks the rest. A provider that did **not** negotiate
614/// correlation, and every `verify` (whose envelopes carry no `id` and so cannot
615/// be demultiplexed), stay strictly lock-step via `no_id_lock`, behaving exactly
616/// as the single-mutex transport did before (ADR 0002).
617pub struct StdioProvider {
618    id: String,
619    info: ProviderInfo,
620    capabilities: Capabilities,
621    /// Write half. Locked only long enough to write one framed line, then
622    /// released — a correlated `query` holds it for a send, never a round-trip.
623    stdin: TokioMutex<ChildStdin>,
624    /// Correlated in-flight exchanges, keyed by the host-minted `id`.
625    pending: PendingTable,
626    /// The fallback slot the reader delivers id-less replies to.
627    no_id_slot: NoIdSlot,
628    /// Serializes the id-less / non-correlating exchanges (a non-correlating
629    /// `query`, all `verify`) into lock-step, so at most one id-less reply is
630    /// outstanding and a non-correlating provider is provably unchanged.
631    no_id_lock: TokioMutex<()>,
632    /// Child + process group, touched only by `shutdown`/`Drop`.
633    control: TokioMutex<StdioControl>,
634    /// The reader task; aborted on `Drop` as a backstop (the child's death
635    /// already ends it via EOF).
636    reader: JoinHandle<()>,
637}
638
639impl StdioProvider {
640    /// Spawn a child-process provider, complete the handshake, and cache its
641    /// declared identity + capabilities. `id` is the host-facing routing and
642    /// consent key. Fails cleanly (killing the child) on a bad or incompatible
643    /// handshake. On success the connection is split and the reader task
644    /// launched, so replies can be demultiplexed from here on.
645    pub async fn spawn(
646        id: impl Into<String>,
647        program: &str,
648        args: &[String],
649    ) -> Result<Self, HostError> {
650        let id = id.into();
651        let mut conn = RawStdioConnection::spawn(program, args)
652            .await?
653            .with_label(id.clone());
654        let (info, capabilities) = conn.handshake().await?;
655
656        // Handshake done: split the connection. The `BufReader` carries any
657        // bytes it buffered past the ack, so nothing is lost across the move.
658        let (stdin, stdout, control) = conn.into_parts();
659
660        let pending: PendingTable = Arc::new(StdMutex::new(HashMap::new()));
661        let no_id_slot: NoIdSlot = Arc::new(StdMutex::new(None));
662        let reader = tokio::spawn(run_reader(
663            stdout,
664            id.clone(),
665            Arc::clone(&pending),
666            Arc::clone(&no_id_slot),
667        ));
668
669        Ok(Self {
670            id,
671            info,
672            capabilities,
673            stdin: TokioMutex::new(stdin),
674            pending,
675            no_id_slot,
676            no_id_lock: TokioMutex::new(()),
677            control: TokioMutex::new(control),
678            reader,
679        })
680    }
681
682    /// Lock-step exchange for an id-less request (a non-correlating `query`, or
683    /// any `verify`): hold `no_id_lock` across the whole round-trip so exactly
684    /// one id-less reply is outstanding, register the fallback slot, write the
685    /// request, and await the reader's delivery. Byte-for-byte the behaviour of
686    /// the old single-mutex path, so a non-correlating provider is unchanged.
687    async fn exchange_lockstep(&self, request: Envelope) -> Result<Envelope, HostError> {
688        let _lockstep = self.no_id_lock.lock().await;
689        let (tx, rx) = oneshot::channel();
690        // Safe to overwrite: `no_id_lock` guarantees the slot is empty here.
691        *self.no_id_slot.lock().expect("no_id_slot mutex poisoned") = Some(tx);
692
693        let sent = {
694            let mut stdin = self.stdin.lock().await;
695            write_envelope(&mut stdin, &request, &self.id).await
696        };
697        if let Err(e) = sent {
698            // Undo the registration so a failed write cannot leak the slot.
699            self.no_id_slot
700                .lock()
701                .expect("no_id_slot mutex poisoned")
702                .take();
703            return Err(e);
704        }
705
706        match rx.await {
707            Ok(reply) => reply,
708            // The reader drains on exit, so a canceled receiver means the task
709            // is gone without having delivered — a crash, never a hang.
710            Err(_) => Err(HostError::ProviderCrashed {
711                id: self.id.clone(),
712            }),
713        }
714    }
715}
716
717#[async_trait]
718impl ContextProvider for StdioProvider {
719    fn id(&self) -> &str {
720        &self.id
721    }
722
723    fn info(&self) -> &ProviderInfo {
724        &self.info
725    }
726
727    fn capabilities(&self) -> &Capabilities {
728        &self.capabilities
729    }
730
731    async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
732        if !self.capabilities.correlation {
733            // Non-correlating provider: lock-step, provably identical to before.
734            return match self
735                .exchange_lockstep(Envelope::Query {
736                    id: None,
737                    query: query.clone(),
738                })
739                .await?
740            {
741                Envelope::Frames { result, .. } => Ok(result),
742                Envelope::Error { message, code, .. } => Err(HostError::Provider {
743                    id: self.id.clone(),
744                    code,
745                    message,
746                }),
747                other => Err(HostError::UnexpectedEnvelope {
748                    id: self.id.clone(),
749                    expected: "frames".into(),
750                    got: envelope_kind(&other).into(),
751                }),
752            };
753        }
754
755        // Correlated: register the waiter keyed by a fresh id BEFORE sending, so
756        // the reader can never deliver a reply we have not yet recorded. Then
757        // lock stdin only long enough to write the line, and await the reply
758        // with NO lock held — this is what lets two queries interleave.
759        let sent_id = next_correlation_id();
760        let (tx, rx) = oneshot::channel();
761        self.pending
762            .lock()
763            .expect("pending mutex poisoned")
764            .insert(sent_id.clone(), tx);
765
766        let sent = {
767            let mut stdin = self.stdin.lock().await;
768            write_envelope(
769                &mut stdin,
770                &Envelope::Query {
771                    id: Some(sent_id.clone()),
772                    query: query.clone(),
773                },
774                &self.id,
775            )
776            .await
777        };
778        if let Err(e) = sent {
779            // Undo the registration so a failed write cannot leak a waiter.
780            self.pending
781                .lock()
782                .expect("pending mutex poisoned")
783                .remove(&sent_id);
784            return Err(e);
785        }
786
787        let reply = match rx.await {
788            Ok(reply) => reply?,
789            // The reader drains on exit, so a canceled receiver means the task
790            // ended without delivering — a crash, never a hang.
791            Err(_) => {
792                return Err(HostError::ProviderCrashed {
793                    id: self.id.clone(),
794                });
795            }
796        };
797        match reply {
798            Envelope::Frames {
799                id: echoed, result, ..
800            } => {
801                // The reader matched this reply to us by id, so the echo already
802                // agrees; verifying keeps the §H4 guarantee explicit and local.
803                verify_correlation(&self.id, Some(sent_id.as_str()), echoed.as_deref())?;
804                Ok(result)
805            }
806            Envelope::Error { message, code, .. } => Err(HostError::Provider {
807                id: self.id.clone(),
808                code,
809                message,
810            }),
811            other => Err(HostError::UnexpectedEnvelope {
812                id: self.id.clone(),
813                expected: "frames".into(),
814                got: envelope_kind(&other).into(),
815            }),
816        }
817    }
818
819    async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
820        // `verify`/`verified` carry no id (they correlate by echoing the frame
821        // identity in full), so they cannot be demultiplexed — they stay
822        // lock-step, exactly as ADR 0002 scopes them.
823        match self
824            .exchange_lockstep(Envelope::Verify {
825                request: request.clone(),
826            })
827            .await?
828        {
829            Envelope::Verified { response } => Ok(response),
830            Envelope::Error { message, code, .. } => Err(HostError::Provider {
831                id: self.id.clone(),
832                code,
833                message,
834            }),
835            other => Err(HostError::UnexpectedEnvelope {
836                id: self.id.clone(),
837                expected: "verified".into(),
838                got: envelope_kind(&other).into(),
839            }),
840        }
841    }
842
843    async fn shutdown(&self) -> Result<(), HostError> {
844        // Best-effort `shutdown` envelope over the write half, then the bounded
845        // grace + process-group kill backstop — the original teardown, intact.
846        {
847            let mut stdin = self.stdin.lock().await;
848            let _ = write_envelope(&mut stdin, &Envelope::Shutdown, &self.id).await;
849        }
850        let mut control = self.control.lock().await;
851        control.wait_or_kill().await
852    }
853}
854
855impl Drop for StdioProvider {
856    fn drop(&mut self) {
857        // The reader task ends on its own when the child's stdout closes, but
858        // abort it eagerly so a wedged pipe cannot keep the task alive after the
859        // provider is gone. `control`'s own `Drop` kills the child.
860        self.reader.abort();
861    }
862}
863
864#[cfg(all(test, unix))]
865mod tests {
866    use super::*;
867    use contextgraph_types::{ContextFrame, FrameKind};
868
869    /// Build a one-shot bash "provider" that emits `script` lines. Bash's
870    /// `read`/`printf` are builtins, so it works under the scrubbed env
871    /// (only PATH/HOME forwarded).
872    fn bash_provider(script: &str) -> (String, Vec<String>) {
873        (
874            "bash".to_string(),
875            vec!["-c".to_string(), script.to_string()],
876        )
877    }
878
879    fn ack_line(version: &str) -> String {
880        // A minimal, well-formed handshake_ack the bash provider can echo.
881        let ack = Envelope::HandshakeAck {
882            protocol_version: version.to_string(),
883            provider: ProviderInfo {
884                name: "bash-fixture".into(),
885                version: "0.0.1".into(),
886                data_flow: contextgraph_types::DataFlow {
887                    reads: true,
888                    writes: false,
889                    egress: false,
890                    egress_scopes: vec![],
891                },
892            },
893            capabilities: Capabilities {
894                query: contextgraph_types::capability::QueryCapability {
895                    kinds: vec!["doc".into()],
896                },
897                ..Capabilities::default()
898            },
899            attester_keys: vec![],
900        };
901        serde_json::to_string(&ack).unwrap()
902    }
903
904    fn frames_line() -> String {
905        let frame = ContextFrame {
906            id: "frm_1".into(),
907            kind: FrameKind::Doc,
908            title: "README".into(),
909            content: Some("hello from a stdio provider".into()),
910            content_digest: None,
911            uri: Some("file:///README.md".into()),
912            representation: Default::default(),
913            content_fidelity: None,
914            canonical_content_hash: None,
915            content_ref: None,
916            transform: None,
917            minimum_content_fidelity: None,
918            inline_content_requirement: None,
919            score: 0.7,
920            token_cost: 12,
921            canonical_token_cost: None,
922            tokenizer_ref: None,
923            valid_from: None,
924            valid_to: None,
925            recorded_at: None,
926            provenance: vec![],
927            citation_label: Some("README.md".into()),
928            embedding: None,
929            relations: vec![],
930        };
931        let env = Envelope::Frames {
932            id: None,
933            result: ContextQueryResult {
934                frames: vec![frame],
935                truncated: false,
936                dropped_estimate: None,
937                ..Default::default()
938            },
939        };
940        serde_json::to_string(&env).unwrap()
941    }
942
943    /// A handshake ack that negotiates `correlation`, so a `StdioProvider` built
944    /// on it takes the pipelined (demux-on-id) `query` path.
945    fn ack_line_correlating(version: &str) -> String {
946        let ack = Envelope::HandshakeAck {
947            protocol_version: version.to_string(),
948            provider: ProviderInfo {
949                name: "bash-fixture".into(),
950                version: "0.0.1".into(),
951                data_flow: contextgraph_types::DataFlow {
952                    reads: true,
953                    writes: false,
954                    egress: false,
955                    egress_scopes: vec![],
956                },
957            },
958            capabilities: Capabilities {
959                query: contextgraph_types::capability::QueryCapability {
960                    kinds: vec!["doc".into()],
961                },
962                correlation: true,
963                ..Capabilities::default()
964            },
965            attester_keys: vec![],
966        };
967        serde_json::to_string(&ack).unwrap()
968    }
969
970    /// A `frames` envelope with `__ID__` (the correlation id) and `__CONTENT__`
971    /// (the frame's content + title) as substitution placeholders, so the bash
972    /// witness fixture can echo each query's own id and goal back verbatim.
973    fn frames_template() -> String {
974        let frame = ContextFrame {
975            id: "frm_1".into(),
976            kind: FrameKind::Doc,
977            title: "__CONTENT__".into(),
978            content: Some("__CONTENT__".into()),
979            content_digest: None,
980            uri: Some("file:///README.md".into()),
981            representation: Default::default(),
982            content_fidelity: None,
983            canonical_content_hash: None,
984            content_ref: None,
985            transform: None,
986            minimum_content_fidelity: None,
987            inline_content_requirement: None,
988            score: 0.7,
989            token_cost: 12,
990            canonical_token_cost: None,
991            tokenizer_ref: None,
992            valid_from: None,
993            valid_to: None,
994            recorded_at: None,
995            provenance: vec![],
996            citation_label: Some("README.md".into()),
997            embedding: None,
998            relations: vec![],
999        };
1000        let env = Envelope::Frames {
1001            id: Some("__ID__".into()),
1002            result: ContextQueryResult {
1003                frames: vec![frame],
1004                truncated: false,
1005                dropped_estimate: None,
1006                ..Default::default()
1007            },
1008        };
1009        serde_json::to_string(&env).unwrap()
1010    }
1011
1012    /// A bash "provider" that reads BOTH queries before answering either, then
1013    /// answers the second-received query FIRST. `@ACK@` / `@FRAMES_TMPL@` are
1014    /// substituted in from Rust; the fixture pulls each query's own `id` and
1015    /// `goal` off the wire and pairs them into the reply it emits for that id.
1016    ///
1017    /// Reading two queries before replying is the crux: a lock-step transport
1018    /// would not send the second query until the first's reply was consumed, so
1019    /// the fixture's second `read` would block and the whole exchange would
1020    /// deadlock. Only id-demultiplexing lets both queries be in flight at once,
1021    /// which is exactly what this witnesses.
1022    const OUT_OF_ORDER_WITNESS_SCRIPT: &str = r#"
1023read -r handshake
1024printf '%s\n' '@ACK@'
1025read -r q1
1026read -r q2
1027tmpl='@FRAMES_TMPL@'
1028idre='"id":"([^"]+)"'
1029goalre='"goal":"([^"]+)"'
1030[[ $q1 =~ $idre ]]; id1=${BASH_REMATCH[1]}
1031[[ $q1 =~ $goalre ]]; g1=${BASH_REMATCH[1]}
1032[[ $q2 =~ $idre ]]; id2=${BASH_REMATCH[1]}
1033[[ $q2 =~ $goalre ]]; g2=${BASH_REMATCH[1]}
1034r1=${tmpl//__ID__/$id1}; r1=${r1//__CONTENT__/$g1}
1035r2=${tmpl//__ID__/$id2}; r2=${r2//__CONTENT__/$g2}
1036printf '%s\n' "$r2"
1037printf '%s\n' "$r1"
1038"#;
1039
1040    fn sample_query() -> ContextQuery {
1041        ContextQuery {
1042            goal: "g".into(),
1043            query_text: None,
1044            embedding: None,
1045            kinds: vec![],
1046            anchors: vec![],
1047            max_frames: 5,
1048            max_tokens: 4000,
1049            as_of: None,
1050            representation_preferences: vec![],
1051        }
1052    }
1053
1054    #[tokio::test]
1055    async fn full_handshake_and_query_round_trip_over_stdio() {
1056        // Reads the handshake, acks; reads the query, replies with frames.
1057        let script = format!(
1058            "read h; printf '%s\\n' '{}'; read q; printf '%s\\n' '{}'",
1059            ack_line(PROTOCOL_VERSION),
1060            frames_line()
1061        );
1062        let (program, args) = bash_provider(&script);
1063        let provider = StdioProvider::spawn("docs", &program, &args)
1064            .await
1065            .expect("handshake should succeed");
1066        assert_eq!(provider.id(), "docs");
1067        assert_eq!(provider.info().name, "bash-fixture");
1068        assert!(provider.capabilities().query.kinds.contains(&"doc".into()));
1069
1070        let result = provider.query(&sample_query()).await.expect("query ok");
1071        assert_eq!(result.frames.len(), 1);
1072        assert_eq!(result.frames[0].title, "README");
1073    }
1074
1075    /// ADR 0002's witness: two concurrent correlated `query`s over one stdio
1076    /// connection, answered **out of order**, must demultiplex back to their own
1077    /// callers — proving the transport pipelines on `id` rather than serializing
1078    /// on a single mutex.
1079    ///
1080    /// The fixture reads both queries before answering either and answers the
1081    /// second-received one first (see [`OUT_OF_ORDER_WITNESS_SCRIPT`]). Under the
1082    /// old lock-step transport this deadlocks, because the host would not send
1083    /// the second query until the first's reply was consumed; only demux
1084    /// completes both. It pairs each reply's `id` with that query's own `goal`,
1085    /// so the assertions catch mis-routing, not merely liveness.
1086    #[tokio::test]
1087    async fn two_correlated_queries_answered_out_of_order_demux_to_their_own_callers() {
1088        let script = OUT_OF_ORDER_WITNESS_SCRIPT
1089            .replace("@ACK@", &ack_line_correlating(PROTOCOL_VERSION))
1090            .replace("@FRAMES_TMPL@", &frames_template());
1091        let (program, args) = bash_provider(&script);
1092
1093        let provider = StdioProvider::spawn("docs", &program, &args)
1094            .await
1095            .expect("handshake should succeed");
1096        assert!(
1097            provider.capabilities().correlation,
1098            "fixture must negotiate correlation for the pipelined path"
1099        );
1100
1101        let mut query_alpha = sample_query();
1102        query_alpha.goal = "alpha".into();
1103        let mut query_bravo = sample_query();
1104        query_bravo.goal = "bravo".into();
1105
1106        // Fire both concurrently. A bounded timeout turns a demux regression
1107        // (which manifests as a hang) into a visible failure instead of a wedged
1108        // suite — the hang is the bug, this just surfaces it.
1109        let (result_alpha, result_bravo) = tokio::time::timeout(Duration::from_secs(10), async {
1110            tokio::join!(provider.query(&query_alpha), provider.query(&query_bravo))
1111        })
1112        .await
1113        .expect("two concurrent correlated queries must not hang — demux, not lock-step");
1114
1115        let result_alpha = result_alpha.expect("alpha query ok");
1116        let result_bravo = result_bravo.expect("bravo query ok");
1117
1118        assert_eq!(result_alpha.frames.len(), 1);
1119        assert_eq!(result_bravo.frames.len(), 1);
1120        // Each caller received the frames the fixture built for ITS id, despite
1121        // the replies arriving in the opposite order — the demux-by-id witness.
1122        assert_eq!(
1123            result_alpha.frames[0].content.as_deref(),
1124            Some("alpha"),
1125            "the alpha caller must receive alpha's frames, never bravo's"
1126        );
1127        assert_eq!(
1128            result_bravo.frames[0].content.as_deref(),
1129            Some("bravo"),
1130            "the bravo caller must receive bravo's frames, never alpha's"
1131        );
1132    }
1133
1134    #[tokio::test]
1135    async fn an_incompatible_protocol_version_is_a_named_error_not_a_hang() {
1136        let script = format!("read h; printf '%s\\n' '{}'", ack_line("contextgraph/2.0"));
1137        let (program, args) = bash_provider(&script);
1138        let err = match StdioProvider::spawn("docs", &program, &args).await {
1139            Ok(_) => panic!("a version mismatch must reject the provider"),
1140            Err(e) => e,
1141        };
1142        match err {
1143            HostError::VersionMismatch {
1144                provider_version, ..
1145            } => assert_eq!(provider_version, "contextgraph/2.0"),
1146            other => panic!("expected VersionMismatch, got {other}"),
1147        }
1148    }
1149
1150    #[tokio::test]
1151    async fn a_child_dying_after_handshake_surfaces_as_provider_crashed() {
1152        // Acks the handshake, then exits before the query — the crash path.
1153        let script = format!(
1154            "read h; printf '%s\\n' '{}'; exit 0",
1155            ack_line(PROTOCOL_VERSION)
1156        );
1157        let (program, args) = bash_provider(&script);
1158        let provider = StdioProvider::spawn("docs", &program, &args)
1159            .await
1160            .expect("handshake ok");
1161        let err = provider
1162            .query(&sample_query())
1163            .await
1164            .expect_err("a dead child must error, not hang");
1165        assert!(
1166            matches!(err, HostError::ProviderCrashed { .. }),
1167            "expected ProviderCrashed, got {err}"
1168        );
1169    }
1170
1171    #[tokio::test]
1172    async fn the_child_is_spawned_with_a_scrubbed_environment() {
1173        // Pick a variable the parent test process has that scrubbing must
1174        // strip — anything but the PATH/HOME allowlist and bash's own
1175        // re-injected names. `cargo test` always sets CARGO_* vars, so one
1176        // exists.
1177        let injected = ["PWD", "SHLVL", "_", "HOME", "PATH", "OLDPWD"];
1178        let leaked = std::env::vars()
1179            .map(|(k, _)| k)
1180            .find(|k| !injected.contains(&k.as_str()) && !k.is_empty())
1181            .expect("the test process has at least one non-allowlisted env var");
1182
1183        // A raw connection running `env`; read its environment dump.
1184        let mut conn = RawStdioConnection::spawn("bash", &["-c".into(), "env".into()])
1185            .await
1186            .expect("spawn env");
1187        let mut child_keys = Vec::new();
1188        while let Some(line) = conn.read_raw_line().await.expect("read env line") {
1189            if let Some((key, _)) = line.trim_end().split_once('=') {
1190                child_keys.push(key.to_string());
1191            }
1192        }
1193        assert!(
1194            !child_keys.contains(&leaked),
1195            "scrubbed child leaked parent env var `{leaked}` — credentials must not cross"
1196        );
1197    }
1198}