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}