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}