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