Skip to main content

protocol_droid/adapters/secondary/stdio/
client.rs

1//! The client: a running conversation with a child server.
2//!
3//! # What a client is, here
4//!
5//! [`StdioClient`] is the child process, the two ends of its conversation, and
6//! the request documents that go between them. It is not a session manager and
7//! it does not negotiate: [`Client`](crate::application::client::Client) is
8//! the thing that holds a negotiated revision and a feature profile, and this
9//! is the transport it makes its calls over. What the client adds to the
10//! transport is three things the byte layer cannot know — a program to start,
11//! a process to take down, and a deadline after which "no answer" is an
12//! answer.
13//!
14//! # A non-answer is an error, not a hang
15//!
16//! [`ClientTransport`] is a trait a caller cannot interrupt once it has
17//! entered, and a server that never answers is a real thing — it crashed on
18//! the request, it is blocked, it is a different program entirely. So
19//! [`call`](StdioClient::call) takes a deadline from the client's
20//! [`ClientConfig`] and reports [`StdioClientError::TimedOut`] when it passes.
21//! A caller that genuinely wants to wait forever says so with
22//! [`without_request_timeout`](ClientConfig::without_request_timeout), and
23//! says it on purpose.
24//!
25//! # The process dies with the client
26//!
27//! Nothing here implements `Drop` to do it. [`StdioClient`] owns a
28//! [`Child`] that was spawned with `kill_on_drop(true)`
29//! by `child::spawn`, so dropping the client kills the server — including
30//! on the path where a request failed and the caller gave up, which is the path
31//! that would otherwise leave an orphan.
32//!
33//! [`terminate`](StdioClient::terminate) is the polite version, for a caller
34//! that wants the exit status and the reap rather than the kill and the
35//! silence.
36
37use core::future::Future;
38use core::pin::Pin;
39use core::sync::atomic::{AtomicI64, Ordering};
40use core::time::Duration;
41use std::process::ExitStatus;
42use std::sync::Arc;
43
44use tokio::io::AsyncWrite;
45use tokio::process::{Child, ChildStdin, ChildStdout};
46use tokio::task::JoinHandle;
47
48use crate::adapters::primary::stdio::{
49    Diagnostics, StdioConfig, StdioError, StdioSink, StdioSource,
50};
51use crate::application::client::{ClientTransport, TransportError};
52use crate::application::jsonrpc::SourceError;
53use crate::domain::RequestId;
54use crate::domain::ir::{JsonObject, MethodName};
55
56use super::child::{self, SpawnError};
57use super::spec::ChildSpec;
58use crate::application::jsonrpc::document::RequestError;
59use crate::application::jsonrpc::document::{
60    notification_document, reply_result, request_document,
61};
62
63/// How long a request waits for its answer before the client calls it a
64/// failure.
65///
66/// A default and not a protocol requirement: the specification puts no
67/// deadline on a `tools/call`, and what a tool does is not this crate's to
68/// know. It is here because the alternative is a client that cannot be
69/// interrupted, and thirty seconds is long enough that a legitimate slow tool
70/// is not failed and short enough that a dead one is noticed while the caller
71/// is still waiting.
72const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
73
74/// The first request id this client mints.
75///
76/// One rather than zero because a caller reading a log will have seen a server
77/// reply with id `0` before and will spend a moment deciding whether it is
78/// this client's.
79const FIRST_REQUEST_ID: i64 = 1;
80
81/// How many frames a reply may sit behind before the client stops looking.
82///
83/// A frame that is not this request's answer is skipped rather than treated
84/// as a failure: a peer may send a notification, or the progress for this
85/// very request, before the result. The count is a floor, not the bound that
86/// matters — [`read_reply`](StdioClient::read_reply) carries the deadline, and
87/// a peer that never answers ends as [`StdioClientError::TimedOut`] long
88/// before this is reached. It exists so the skip loop is visibly finite even
89/// with no deadline configured.
90const FRAMES_A_REPLY_MAY_SIT_BEHIND: usize = 32;
91
92/// How a client session is built.
93///
94/// Two settings, and they are different settings: the frame ceiling is a
95/// property of the conversation — the same [`StdioConfig`] the source and the
96/// sink are built from, so a client cannot be given one ceiling for what it
97/// reads and another for what it writes — and the request timeout is a
98/// property of the caller, who knows whether their tool is fast.
99///
100/// # Examples
101///
102/// ```
103/// use std::time::Duration;
104///
105/// use protocol_droid::adapters::primary::stdio::StdioConfig;
106/// use protocol_droid::adapters::secondary::stdio::ClientConfig;
107///
108/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
109/// let config = ClientConfig::new(StdioConfig::default())
110///     .with_request_timeout(Duration::from_secs(5));
111///
112/// assert_eq!(config.request_timeout(), Some(Duration::from_secs(5)));
113/// # Ok(())
114/// # }
115/// ```
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct ClientConfig {
118    transport: StdioConfig,
119    request_timeout: Option<Duration>,
120}
121
122impl ClientConfig {
123    /// The frame ceiling, and a thirty-second request deadline.
124    #[must_use]
125    pub const fn new(transport: StdioConfig) -> Self {
126        Self {
127            transport,
128            request_timeout: Some(DEFAULT_REQUEST_TIMEOUT),
129        }
130    }
131
132    /// Replaces the request deadline.
133    #[must_use]
134    pub const fn with_request_timeout(mut self, timeout: Duration) -> Self {
135        self.request_timeout = Some(timeout);
136        self
137    }
138
139    /// Waits for a non-answer forever.
140    ///
141    /// For a caller that has its own deadline and does not want two of them —
142    /// a probe that must observe exactly how long a wedged server stays
143    /// wedged, say. Everything else wants the default.
144    #[must_use]
145    pub const fn without_request_timeout(mut self) -> Self {
146        self.request_timeout = None;
147        self
148    }
149
150    /// The settings the source and the sink are built from.
151    #[must_use]
152    pub const fn transport(&self) -> StdioConfig {
153        self.transport
154    }
155
156    /// The request deadline, when there is one.
157    #[must_use]
158    pub const fn request_timeout(&self) -> Option<Duration> {
159        self.request_timeout
160    }
161}
162
163impl Default for ClientConfig {
164    /// [`ClientConfig::new`], which is the only implementation: there is no
165    /// other set of defaults to choose between.
166    fn default() -> Self {
167        Self::new(StdioConfig::default())
168    }
169}
170
171/// A running conversation with a child MCP server.
172///
173/// Generic over the diagnostics channel for the same reason the server's
174/// [`ServeLoop`](crate::adapters::primary::stdio::ServeLoop) is: "somewhere
175/// that is not the protocol stream" is a decision the caller makes, and a test
176/// needs to be able to read it back.
177///
178/// See the module docs for what this is and is not.
179pub struct StdioClient<DW> {
180    child: Child,
181    source: StdioSource<ChildStdout>,
182    sink: StdioSink<ChildStdin>,
183    diagnostics: Arc<Diagnostics<DW>>,
184    forwarder: Option<JoinHandle<()>>,
185    config: ClientConfig,
186    next_id: AtomicI64,
187}
188
189impl<DW> core::fmt::Debug for StdioClient<DW> {
190    /// Prints the process and the settings, and nothing about the streams.
191    ///
192    /// The same reasoning as the server's `ServeLoop`: a child's buffered
193    /// bytes and a pipe's internals say nothing about which session is
194    /// misbehaving, and requiring `DW: Debug` to print the one field that
195    /// would be a bound nobody wants.
196    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
197        f.debug_struct("StdioClient")
198            .field("child_id", &self.child_id())
199            .field("config", &self.config)
200            .finish_non_exhaustive()
201    }
202}
203
204impl<DW> StdioClient<DW> {
205    /// The child's process id, while it is alive.
206    ///
207    /// For a log line. It is not proof of anything: a caller that needs to
208    /// know the process is *gone* wants
209    /// [`take_stderr_forwarder`](Self::take_stderr_forwarder), because
210    /// end-of-file on the child's own pipe is the kernel saying so and a
211    /// process id is not.
212    ///
213    /// Unbounded deliberately — reading the process id touches the `Child`
214    /// and nothing else, so putting it behind the `DW` bounds would mean
215    /// `Debug` had to carry them too.
216    #[must_use]
217    pub fn child_id(&self) -> Option<u32> {
218        self.child.id()
219    }
220}
221
222impl<DW> StdioClient<DW>
223where
224    DW: AsyncWrite + Unpin + Send + 'static,
225{
226    /// Starts a child server and opens a conversation with it.
227    ///
228    /// The child's stdout becomes a [`StdioSource`], its stdin a
229    /// [`StdioSink`], and its stderr is drained into `diagnostics` by a task
230    /// that lives as long as the child does. All three of those adapters take
231    /// the same [`StdioConfig`], which is the point: a client that could be
232    /// given one frame ceiling for what it reads and another for what it
233    /// writes would be a client whose two halves disagree about what a frame
234    /// is.
235    ///
236    /// Dropping the returned client kills the child. That is the
237    /// `kill_on_drop(true)` set in `child::spawn`, and it is the reason this
238    /// function is the only way to get a client.
239    ///
240    /// # Errors
241    ///
242    /// [`SpawnError::NotStarted`] when the program could not be started, and
243    /// [`SpawnError::NoPipe`] when it started without one of the three pipes
244    /// the conversation needs. In the second case the child is dropped on the
245    /// way out, so the failure does not leave a process behind.
246    ///
247    /// # Examples
248    ///
249    /// ```no_run
250    /// use protocol_droid::adapters::primary::stdio::{Diagnostics, StdioConfig};
251    /// use protocol_droid::adapters::secondary::stdio::{ChildSpec, ClientConfig, StdioClient};
252    ///
253    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
254    /// let spec = ChildSpec::new("my-server")?;
255    /// let config = ClientConfig::new(StdioConfig::default());
256    /// let client = StdioClient::spawn(&spec, config, Diagnostics::stderr()).await?;
257    ///
258    /// assert!(client.child_id().is_some());
259    /// # Ok(())
260    /// # }
261    /// ```
262    #[expect(
263        clippy::unused_async,
264        clippy::unused_async_trait_impl,
265        reason = "the public constructor is async on purpose: it is the caller's \
266                   first await point in a client whose every other method is \
267                   async, and dropping the `async` would change the shape of \
268                   the API this module is meant to present. The body awaits \
269                   nothing yet because the child is spawned synchronously and \
270                   the only async work it starts (the stderr forwarder) is \
271                   handed to a task."
272    )]
273    pub async fn spawn(
274        spec: &ChildSpec,
275        config: ClientConfig,
276        diagnostics: Diagnostics<DW>,
277    ) -> Result<Self, SpawnError> {
278        let spawned = child::spawn(spec)?;
279        let Some(stdin) = spawned.stdin else {
280            return Err(SpawnError::NoPipe { stream: "stdin" });
281        };
282        let Some(stdout) = spawned.stdout else {
283            return Err(SpawnError::NoPipe { stream: "stdout" });
284        };
285        let Some(stderr) = spawned.stderr else {
286            return Err(SpawnError::NoPipe { stream: "stderr" });
287        };
288        let diagnostics = Arc::new(diagnostics);
289        let forwarder = child::forward_stderr(stderr, Arc::clone(&diagnostics));
290        Ok(Self {
291            child: spawned.child,
292            source: StdioSource::new(stdout, config.transport()),
293            sink: StdioSink::new(stdin, config.transport()),
294            diagnostics,
295            forwarder: Some(forwarder),
296            config,
297            next_id: AtomicI64::new(FIRST_REQUEST_ID),
298        })
299    }
300
301    /// The settings this client was built with.
302    #[must_use]
303    pub const fn config(&self) -> ClientConfig {
304        self.config
305    }
306
307    /// The child's stderr channel.
308    ///
309    /// The child does not write to this — `child::forward_stderr` does, one
310    /// line at a time — and neither does anything else this crate knows of.
311    /// It is here so a caller can add its own, the same way the server's
312    /// diagnostics channel is.
313    #[must_use]
314    pub fn diagnostics(&self) -> &Diagnostics<DW> {
315        &self.diagnostics
316    }
317
318    /// The read half, for a caller driving the conversation itself.
319    #[must_use]
320    pub const fn source(&self) -> &StdioSource<ChildStdout> {
321        &self.source
322    }
323
324    /// The write half, for a caller driving the conversation itself.
325    #[must_use]
326    pub const fn sink(&self) -> &StdioSink<ChildStdin> {
327        &self.sink
328    }
329
330    /// Detaches the stderr forwarder and hands back its handle.
331    ///
332    /// Awaiting the handle completes when the child's stderr reaches
333    /// end-of-file, which is the point: the kernel closes a process's
334    /// descriptors when the process is torn down, so this is a caller being
335    /// told by the operating system that the child is gone — not a caller
336    /// observing that a handle was dropped.
337    ///
338    /// Taken rather than merely borrowed because the question it answers is
339    /// asked *after* the client, and a borrow of a field would keep the client
340    /// alive. Detaching the task does not stop it: it holds its own
341    /// reference to the diagnostics channel and to the child's stderr, and it
342    /// ends when the pipe does.
343    #[must_use]
344    pub const fn take_stderr_forwarder(&mut self) -> Option<JoinHandle<()>> {
345        self.forwarder.take()
346    }
347
348    /// Writes one already-encoded document to the child.
349    ///
350    /// The framing — the delimiter, the ceiling, the flush — is
351    /// [`StdioSink`]'s, which is the same code the server writes its replies
352    /// with. A caller reaching for this is assembling a document itself; a
353    /// caller with a method and some parameters wants [`call`](Self::call).
354    ///
355    /// # Errors
356    ///
357    /// [`StdioError::ReplyTooLarge`] when the document does not fit the
358    /// configured ceiling, and [`StdioError::Transport`] when the child's
359    /// stdin refused it — which, for a child that has exited, is the normal
360    /// way to find out.
361    pub async fn send_document(&self, document: &str) -> Result<(), StdioError> {
362        StdioSink::send(&self.sink, document).await
363    }
364
365    /// Reads the child's next frame.
366    ///
367    /// `Ok(None)` is the child closing, and nothing else: it is
368    /// [`StdioSource`]'s answer passed through unaltered, because the decoder
369    /// underneath it answers `Ok(None)` for the opposite reason — a line with
370    /// no terminator *yet* — and forwarding that up would turn "read more" into
371    /// "the server is gone". A caller that skips a `None` here will conclude
372    /// the child died on a request that was merely slow, and
373    /// [`call`](Self::call) is where that mistake is most expensive.
374    ///
375    /// # Errors
376    ///
377    /// [`SourceError`] — see [`StdioSource::next_frame`].
378    pub async fn next_frame(&self) -> Result<Option<String>, SourceError> {
379        StdioSource::next_frame(&self.source).await
380    }
381
382    /// A fresh request id from this client's counter.
383    ///
384    /// A counter and not a random value, for the reason everything else in
385    /// this crate that needs an identity takes one: the domain does not call
386    /// a generator, so a client's ids are as reproducible as its config, and
387    /// two requests in flight on one client can never collide.
388    #[must_use]
389    pub fn next_request_id(&self) -> RequestId {
390        RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed))
391    }
392
393    /// Sends a request and returns the peer's answer.
394    ///
395    /// The three steps — write the document, read the next frame, match its id
396    /// — are a request and a reply, and a caller that wants a different order
397    /// wants [`send_document`](Self::send_document) and
398    /// [`next_frame`](Self::next_frame) instead.
399    ///
400    /// # Errors
401    ///
402    /// [`StdioClientError::Document`] when the request document could not be
403    /// assembled, [`StdioClientError::Stdio`] and
404    /// [`StdioClientError::Source`] when the pipe refused, and
405    /// [`StdioClientError::PeerClosed`] when the child closed its stdout
406    /// before answering. A child that answers with a failure is
407    /// [`StdioClientError::Request`], because that is a result of the request
408    /// rather than a fault in the transport.
409    pub async fn call(
410        &self,
411        id: &RequestId,
412        method: &MethodName,
413        params: &JsonObject,
414    ) -> Result<JsonObject, StdioClientError> {
415        let document = request_document(id, method, params).map_err(StdioClientError::Document)?;
416        self.exchange(id, method, &document.to_string()).await
417    }
418
419    /// Writes an already-assembled document and reads the answer to it.
420    ///
421    /// Split out of [`call`](Self::call) so [`ClientTransport::send`] can
422    /// assemble its document before the future exists. The port's return
423    /// lifetime is `&self` alone, so a boxed future that borrowed the caller's
424    /// `method` and `params` would not satisfy it; the document is the one
425    /// thing built from both, so it is built first and the future carries
426    /// only the id and the method name, which it owns.
427    async fn exchange(
428        &self,
429        id: &RequestId,
430        method: &MethodName,
431        document: &str,
432    ) -> Result<JsonObject, StdioClientError> {
433        self.send_document(document).await?;
434
435        // Read until this request's answer arrives, rather than taking the
436        // first frame on the stream. A peer may legitimately send something
437        // else first — a `notifications/message`, the progress for this very
438        // request, or in this suite's case the `running 1 test` line libtest
439        // writes to a child's stdout before the helper's body runs — and a
440        // client that answered from frame zero would be reading whichever of
441        // those happened to land first.
442        //
443        // Both refusals keep reading rather than propagating: a frame that is
444        // not a JSON object at all is not this conversation's business, and a
445        // frame carrying some other id is a real answer to something else.
446        // Only the deadline in `read_reply` bounds the loop, so a peer that
447        // never answers ends as `TimedOut` instead of spinning.
448        for _ in 0..FRAMES_A_REPLY_MAY_SIT_BEHIND {
449            let frame = self.read_reply(method).await?;
450            match reply_result(&frame, id) {
451                Ok(result) => return Ok(result),
452                Err(RequestError::UnreadableFrame | RequestError::IdMismatch { .. }) => {}
453                Err(error) => return Err(StdioClientError::Request(error)),
454            }
455        }
456        Err(StdioClientError::NoAnswer {
457            skipped: FRAMES_A_REPLY_MAY_SIT_BEHIND,
458        })
459    }
460
461    /// Sends a notification, which never gets an answer.
462    ///
463    /// # Errors
464    ///
465    /// As [`send_document`](Self::send_document), plus
466    /// [`StdioClientError::Document`] if the document cannot be assembled.
467    pub async fn notify(
468        &self,
469        method: &MethodName,
470        params: &JsonObject,
471    ) -> Result<(), StdioClientError> {
472        let document = crate::application::jsonrpc::document::notification_document(method, params)
473            .map_err(StdioClientError::Document)?;
474        self.send_document(&document.to_string()).await?;
475        Ok(())
476    }
477
478    /// Kills the child and waits for it to be reaped.
479    ///
480    /// The deliberate counterpart to dropping the client, for a caller that
481    /// wants the exit status and for the reap to have happened before it
482    /// moves on. A dropped client kills the child too, and the operating
483    /// system reaps it; it just does not tell anyone how it ended.
484    ///
485    /// Calling this twice is not a second kill: the second call reports
486    /// `Ok(None)`, because there is nothing left to wait for.
487    ///
488    /// # Errors
489    ///
490    /// [`StdioClientError::Terminate`] when the child could not be waited for.
491    pub async fn terminate(&mut self) -> Result<Option<ExitStatus>, StdioClientError> {
492        let _started = self.child.start_kill();
493        self.child
494            .wait()
495            .await
496            .map(Some)
497            .map_err(|_| StdioClientError::Terminate)
498    }
499
500    /// Reads one frame for [`call`](Self::call), within the deadline.
501    ///
502    /// Split out because the deadline is the only thing here that is not
503    /// plumbing, and a reader that forgot it would be indistinguishable from
504    /// one that had it.
505    async fn read_reply(&self, method: &MethodName) -> Result<String, StdioClientError> {
506        match self.config.request_timeout() {
507            Some(limit) => match tokio::time::timeout(limit, self.next_frame()).await {
508                Ok(Ok(Some(frame))) => Ok(frame),
509                // The child closed, which is the end of the conversation and
510                // not "not yet" — see `next_frame`.
511                Ok(Ok(None)) => Err(StdioClientError::PeerClosed {
512                    method: method.clone(),
513                }),
514                Ok(Err(source)) => Err(StdioClientError::Source(source)),
515                Err(_elapsed) => Err(StdioClientError::TimedOut {
516                    method: method.clone(),
517                    after: limit,
518                }),
519            },
520            None => self.next_frame().await?.map_or_else(
521                || {
522                    Err(StdioClientError::PeerClosed {
523                        method: method.clone(),
524                    })
525                },
526                Ok,
527            ),
528        }
529    }
530}
531
532impl<DW> ClientTransport for StdioClient<DW>
533where
534    DW: AsyncWrite + Unpin + Send + 'static,
535{
536    /// Sends `method` with `params` under a fresh id and returns the result.
537    ///
538    /// The boxed future because the port asks for one: a client's transport is
539    /// chosen at run time from a configuration, so there is no monomorphisation
540    /// to win here and holding a `dyn` is the honest shape.
541    ///
542    /// The document is assembled before the future is built rather than inside
543    /// it. The port elides the return lifetime to `&self`, so a boxed future
544    /// cannot hold the caller's `method` or `params`; assembling first is what
545    /// leaves the future owning only the id and the method name.
546    ///
547    /// # Errors
548    ///
549    /// [`TransportError::Unavailable`] naming whatever the client refused. The
550    /// port is protocol-neutral and must not learn that a stdio client has a
551    /// frame ceiling, so the detail is rendered rather than kept.
552    fn send(
553        &self,
554        method: &str,
555        params: &JsonObject,
556    ) -> Pin<Box<dyn Future<Output = Result<JsonObject, TransportError>> + Send + '_>> {
557        let prepared = (|| {
558            let name = MethodName::new(method).map_err(unavailable)?;
559            let id = self.next_request_id();
560            let document = request_document(&id, &name, params)
561                .map_err(|error| unavailable(StdioClientError::Document(error)))?
562                .to_string();
563            Ok::<_, TransportError>((id, name, document))
564        })();
565
566        Box::pin(async move {
567            let (id, name, document) = prepared?;
568            self.exchange(&id, &name, &document)
569                .await
570                .map_err(unavailable)
571        })
572    }
573
574    /// Writes a notification and returns.
575    ///
576    /// Write-only by construction: there is no id to match a reply against, so
577    /// there is nothing to wait for. The document is assembled before the
578    /// future is built, for the same reason [`send`](Self::send) does it — the
579    /// port elides the return lifetime, so a boxed future cannot borrow the
580    /// caller's method name.
581    ///
582    /// # Errors
583    ///
584    /// [`TransportError::Unavailable`] if the document cannot be assembled or
585    /// the pipe refuses the write.
586    fn notify(
587        &self,
588        method: &str,
589        params: &JsonObject,
590    ) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + '_>> {
591        let prepared = (|| {
592            let name = MethodName::new(method).map_err(unavailable)?;
593            let document = notification_document(&name, params)
594                .map_err(|error| unavailable(StdioClientError::Document(error)))?
595                .to_string();
596            Ok::<_, TransportError>(document)
597        })();
598
599        Box::pin(async move {
600            let document = prepared?;
601            self.send_document(&document).await.map_err(unavailable)
602        })
603    }
604}
605
606/// Anything this client refuses, as the port's own error.
607fn unavailable(error: impl core::fmt::Display) -> TransportError {
608    TransportError::Unavailable(error.to_string())
609}
610
611/// Why a client call did not complete.
612///
613/// Deliberately not
614/// [`ClientError`](crate::application::client::ClientError), which is the
615/// layer above: that one adds "this session cannot do that", and this one
616/// cannot know. A caller that has a negotiated profile can add the fact; a
617/// caller holding a transport and a pipe can only report the pipe.
618#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
619pub enum StdioClientError {
620    /// The request document could not be assembled.
621    ///
622    /// Kept separate from [`Request`](Self::Request) because they happen at
623    /// opposite ends of the call: this one is this client's fault and happens
624    /// before a byte is written, and that one is the peer's answer arriving
625    /// wrong. A caller that logged both as "bad request" would be wrong about
626    /// one of them.
627    #[error("the request document could not be assembled: {0}")]
628    Document(crate::application::jsonrpc::document::RequestError),
629
630    /// A frame could not be written to the child.
631    #[error("the request could not be written to the child: {0}")]
632    Stdio(#[from] StdioError),
633
634    /// The child's bytes could not be read.
635    #[error("the child's answer could not be read: {0}")]
636    Source(#[from] SourceError),
637
638    /// The child closed its stdout before answering.
639    ///
640    /// Its own account of having exited, as distinct from a child that has not
641    /// answered *yet* — which is [`TimedOut`](Self::TimedOut), and is the
642    /// failure a caller is most likely to meet and least likely to recognise.
643    #[error("the child closed its stream before answering {method}")]
644    PeerClosed {
645        /// The method that went unanswered.
646        method: MethodName,
647    },
648
649    /// The child did not answer within the client's deadline.
650    #[error("the child did not answer {method} within {after:?}")]
651    TimedOut {
652        /// The method that went unanswered.
653        method: MethodName,
654        /// The deadline that passed.
655        after: Duration,
656    },
657
658    /// The peer's answer was not a result.
659    #[error("the child's answer was not a result: {0}")]
660    Request(#[from] crate::application::jsonrpc::document::RequestError),
661
662    /// A run of frames arrived that were all somebody else's.
663    ///
664    /// Separate from [`TimedOut`](Self::TimedOut) because the child is
665    /// demonstrably alive and sending: it is answering, just never this
666    /// request. A caller reading a log should not conclude the peer went away
667    /// when it is in fact answering everything except what was asked.
668    #[error("{skipped} frames went by and none of them was the answer")]
669    NoAnswer {
670        /// How many frames were read and passed over.
671        skipped: usize,
672    },
673
674    /// The child could not be waited for after being killed.
675    #[error("the child could not be shut down")]
676    Terminate,
677}
678
679#[cfg(test)]
680mod tests {
681    use std::io;
682    use std::process::Stdio;
683    use std::sync::Arc;
684    use std::time::Duration;
685
686    use assert_matches::assert_matches;
687    use serde_json::Value;
688    use tokio::task::JoinError;
689
690    use super::{ClientConfig, SpawnError, StdioClient, StdioClientError, child};
691    use crate::adapters::primary::stdio::{Diagnostics, StdioConfig, StdioError};
692    use crate::application::client::{ClientTransport, TransportError};
693    use crate::application::jsonrpc::SourceError;
694    use crate::domain::RequestId;
695    use crate::domain::ir::{JsonObject, MethodName, MethodNameError};
696
697    use super::super::spec::{ChildSpec, ChildSpecError};
698    use super::super::testing::{Capture, Role, STDERR_MARKER, SpawnFailure, child_spec};
699    use crate::application::jsonrpc::document::{RequestError, reply_result, request_document};
700
701    /// Far larger than any child needs to start, answer, or be killed.
702    ///
703    /// Every wait in this module is wrapped in it, so a regression that turns
704    /// "the peer did not answer" into a hang becomes a failed test rather than
705    /// a stuck suite. It is deliberately not a realistic budget: reaching it
706    /// means nothing woke the task at all, not that it was slow.
707    const GUARD: Duration = Duration::from_secs(20);
708
709    /// Short enough that the timeout it tests is observable, long enough that
710    /// a loaded machine does not produce it by accident.
711    const NO_ANSWER_DEADLINE: Duration = Duration::from_millis(300);
712
713    /// What a `?` in a test here can produce.
714    ///
715    /// The fallible calls span four layers — the process, the byte layer, the
716    /// frame parser, and this client's own error — and a test that unwrapped
717    /// each one would be testing its fixtures. The payloads are carried rather
718    /// than discarded so a failing test prints the layer's own account.
719    #[derive(Debug)]
720    #[expect(dead_code, reason = "the payloads are read by the derived Debug")]
721    enum Failure {
722        /// A stream under the test failed.
723        Io(io::Error),
724        /// A child could not be described.
725        ChildSpec(SpawnFailure),
726        /// A child could not be started or shaped.
727        Spawn(SpawnError),
728        /// A frame could not be written to the child.
729        Stdio(StdioError),
730        /// The child's bytes could not be read.
731        Source(SourceError),
732        /// A document this crate built did not read back.
733        Request(RequestError),
734        /// A call this client refused.
735        Client(StdioClientError),
736        /// The [`ClientTransport`] port refused or could not deliver.
737        ///
738        /// A layer of its own from [`Client`](Self::Client): that one is this
739        /// adapter's own answer and keeps its variants, while this is the
740        /// application port's deliberately opaque refusal. Collapsing them
741        /// would lose which half of the path said no.
742        Transport(TransportError),
743        /// A method name the IR would not admit.
744        Method(MethodNameError),
745        /// A spec for a child could not be built.
746        Spec(ChildSpecError),
747        /// A result this test could not read back as JSON.
748        UnreadableResult(serde_json::Error),
749        /// The stderr forwarder was still running when the guard expired.
750        ///
751        /// Which is the kill-on-drop failure, stated as a test outcome: the
752        /// child's pipe never reached end-of-file, so the process was never
753        /// killed.
754        ChildOutlivedItsHandle,
755        /// A child that was supposed to still be running had exited.
756        ExitedEarly(std::process::ExitStatus),
757        /// The child closed its stream before answering.
758        ClosedEarly,
759        /// Nothing in eight frames was a reply to the request.
760        NoReplyInEightFrames,
761    }
762
763    impl From<io::Error> for Failure {
764        fn from(error: io::Error) -> Self {
765            Self::Io(error)
766        }
767    }
768
769    impl From<SpawnFailure> for Failure {
770        fn from(error: SpawnFailure) -> Self {
771            Self::ChildSpec(error)
772        }
773    }
774
775    impl From<SpawnError> for Failure {
776        fn from(error: SpawnError) -> Self {
777            Self::Spawn(error)
778        }
779    }
780
781    impl From<StdioError> for Failure {
782        fn from(error: StdioError) -> Self {
783            Self::Stdio(error)
784        }
785    }
786
787    impl From<SourceError> for Failure {
788        fn from(error: SourceError) -> Self {
789            Self::Source(error)
790        }
791    }
792
793    impl From<RequestError> for Failure {
794        fn from(error: RequestError) -> Self {
795            Self::Request(error)
796        }
797    }
798
799    impl From<StdioClientError> for Failure {
800        fn from(error: StdioClientError) -> Self {
801            Self::Client(error)
802        }
803    }
804
805    impl From<TransportError> for Failure {
806        fn from(error: TransportError) -> Self {
807            Self::Transport(error)
808        }
809    }
810
811    impl From<MethodNameError> for Failure {
812        fn from(error: MethodNameError) -> Self {
813            Self::Method(error)
814        }
815    }
816
817    impl From<JoinError> for Failure {
818        fn from(error: JoinError) -> Self {
819            Self::Io(io::Error::other(error))
820        }
821    }
822
823    impl From<ChildSpecError> for Failure {
824        fn from(error: ChildSpecError) -> Self {
825            Self::Spec(error)
826        }
827    }
828
829    impl From<serde_json::Error> for Failure {
830        fn from(error: serde_json::Error) -> Self {
831            Self::UnreadableResult(error)
832        }
833    }
834
835    /// A result read back as JSON, so a test can name a field in it.
836    ///
837    /// `JsonObject` is opaque by design — that is the point of it — so an
838    /// assertion worth making has to parse. A substring match over `as_str`
839    /// would pass on a document carrying the right characters in the wrong
840    /// shape, which is a different bug from the one these tests are about.
841    fn fields(result: &JsonObject) -> Result<Value, Failure> {
842        Ok(serde_json::from_str(result.as_str())?)
843    }
844
845    /// The child's next real reply, with the harness's own opening line
846    /// skipped.
847    ///
848    /// libtest prints `running 1 test` to the child's stdout before the
849    /// helper's body runs, and the child's stdout *is* the protocol stream, so
850    /// frame zero is always harness output. This is the one place that
851    /// accounts for it — see the module docs on `testing` for why the adapter
852    /// itself stays strict and the accommodation lives in the test.
853    ///
854    /// Only a frame that is not JSON at all is skipped. A frame that parses
855    /// but is the wrong answer is a real failure and comes straight back, so
856    /// this cannot paper over a child that replies with the wrong id.
857    async fn next_reply(client: &StdioClient<Capture>, id: &RequestId) -> Result<String, Failure> {
858        for _ in 0..8 {
859            let Some(frame) = client.next_frame().await? else {
860                return Err(Failure::ClosedEarly);
861            };
862            match reply_result(&frame, id) {
863                Ok(_) => return Ok(frame),
864                Err(RequestError::UnreadableFrame) => {}
865                Err(error) => return Err(Failure::Request(error)),
866            }
867        }
868        Err(Failure::NoReplyInEightFrames)
869    }
870
871    /// A running client on the given role, with a deadline this test chooses.
872    async fn client_on(
873        role: Role,
874        timeout: Option<Duration>,
875    ) -> Result<(StdioClient<Capture>, Capture), Failure> {
876        let spec = child_spec(role)?;
877        let mut config = ClientConfig::new(StdioConfig::default());
878        if let Some(limit) = timeout {
879            config = config.with_request_timeout(limit);
880        } else {
881            config = config.without_request_timeout();
882        }
883        let capture = Capture::shared();
884        let client = tokio::time::timeout(
885            GUARD,
886            StdioClient::spawn(&spec, config, Diagnostics::new(capture.clone())),
887        )
888        .await
889        .map_err(|_| Failure::Spawn(SpawnError::NotStarted))??;
890        Ok((client, capture))
891    }
892
893    /// The default config is a thirty-second deadline over the default
894    /// transport, and both knobs are reachable from the outside.
895    #[test]
896    fn a_config_carries_the_transport_and_the_deadline() {
897        let config = ClientConfig::new(StdioConfig::default());
898
899        assert_eq!(config.transport(), StdioConfig::default());
900        assert_eq!(config.request_timeout(), Some(Duration::from_secs(30)));
901        assert_eq!(config, ClientConfig::default());
902
903        let quick = config.with_request_timeout(Duration::from_millis(5));
904        assert_eq!(quick.request_timeout(), Some(Duration::from_millis(5)));
905        assert_eq!(quick.transport(), StdioConfig::default());
906    }
907
908    /// A caller with its own deadline says so once, and gets no second one
909    /// underneath it.
910    #[test]
911    fn a_config_with_no_deadline_says_so() {
912        let config = ClientConfig::new(StdioConfig::default()).without_request_timeout();
913
914        assert_eq!(config.request_timeout(), None);
915    }
916
917    /// A request goes out over the sink, comes back over the source, and the
918    /// two halves are the byte layer's own — the same code the server writes
919    /// its replies with, which is the whole reason this module reuses it.
920    #[tokio::test]
921    async fn a_call_reaches_the_child_and_comes_back() -> Result<(), Failure> {
922        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
923        let id = client.next_request_id();
924        let method = MethodName::new("tools/list")?;
925
926        let document = request_document(&id, &method, &JsonObject::default())?.to_string();
927        let frame = tokio::time::timeout(GUARD, async {
928            client.send_document(&document).await?;
929            next_reply(&client, &id).await
930        })
931        .await
932        .map_err(|_| Failure::ChildOutlivedItsHandle)??;
933
934        let result = fields(&reply_result(&frame, &id)?)?;
935        assert_eq!(result.get("child"), Some(&Value::Bool(true)));
936        assert_eq!(result.get("method"), Some(&Value::from("tools/list")));
937        Ok(())
938    }
939
940    /// The convenience call assembles the same document and takes the same
941    /// answer, so a caller using it is not on a second code path.
942    #[tokio::test]
943    async fn a_convenience_call_returns_the_same_object() -> Result<(), Failure> {
944        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
945        let method = MethodName::new("tools/list")?;
946
947        let result = tokio::time::timeout(
948            GUARD,
949            client.call(&client.next_request_id(), &method, &JsonObject::default()),
950        )
951        .await
952        .map_err(|_| Failure::ChildOutlivedItsHandle)??;
953
954        assert_eq!(fields(&result)?.get("role"), Some(&Value::from("echo")));
955        Ok(())
956    }
957
958    /// A reply the child wrote in two pieces is one reply. The client reads a
959    /// stream, not a message, and this is where that is worth a test.
960    #[tokio::test]
961    async fn a_reply_split_across_two_writes_is_reassembled() -> Result<(), Failure> {
962        let (client, _capture) = client_on(Role::Split, Some(GUARD)).await?;
963        let method = MethodName::new("tools/list")?;
964
965        let result = tokio::time::timeout(
966            GUARD,
967            client.call(&client.next_request_id(), &method, &JsonObject::default()),
968        )
969        .await
970        .map_err(|_| Failure::ChildOutlivedItsHandle)??;
971
972        assert_eq!(fields(&result)?.get("role"), Some(&Value::from("split")));
973        Ok(())
974    }
975
976    /// A notification carries no id, so the child has nothing to answer and
977    /// the client waits for nothing. The next request's reply is therefore the
978    /// *next* frame, which is what makes this an assertion rather than a
979    /// smoke test.
980    #[tokio::test]
981    async fn a_notification_is_written_and_never_awaited() -> Result<(), Failure> {
982        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
983        let method = MethodName::new("notifications/initialized")?;
984
985        client.notify(&method, &JsonObject::default()).await?;
986
987        let id = client.next_request_id();
988        let listed = MethodName::new("tools/list")?;
989        let frame = tokio::time::timeout(GUARD, next_reply_after(&client, &id, &listed))
990            .await
991            .map_err(|_| Failure::ChildOutlivedItsHandle)??;
992
993        let result = fields(&reply_result(&frame, &id)?)?;
994        assert_eq!(result.get("method"), Some(&Value::from("tools/list")));
995        Ok(())
996    }
997
998    /// A child that reads the request and says nothing is a non-answer, and
999    /// the client turns it into an error rather than hanging: this is the
1000    /// module's central promise.
1001    #[tokio::test]
1002    async fn a_child_that_never_answers_is_reported_as_a_timeout() -> Result<(), Failure> {
1003        let (client, _capture) = client_on(Role::Silent, Some(NO_ANSWER_DEADLINE)).await?;
1004        let method = MethodName::new("tools/list")?;
1005
1006        let outcome = tokio::time::timeout(
1007            GUARD,
1008            client.call(&client.next_request_id(), &method, &JsonObject::default()),
1009        )
1010        .await
1011        .map_err(|_| Failure::ChildOutlivedItsHandle)?;
1012
1013        assert_matches!(
1014            outcome,
1015            Err(StdioClientError::TimedOut { method: reported, after })
1016                if reported == method && after == NO_ANSWER_DEADLINE
1017        );
1018        Ok(())
1019    }
1020
1021    /// The child's stderr is not the protocol. The marker is on stderr, the
1022    /// reply is on stdout, and the assertion is that the marker never appears
1023    /// in a frame — which is the leak the forwarding code could cause, and the
1024    /// only one a test here can honestly make.
1025    #[tokio::test]
1026    async fn the_childs_stderr_never_reaches_the_protocol_stream() -> Result<(), Failure> {
1027        let (client, capture) = client_on(Role::Stderr, Some(GUARD)).await?;
1028        let id = client.next_request_id();
1029        let method = MethodName::new("tools/list")?;
1030
1031        // The raw frame rather than the parsed result, because the claim is
1032        // about bytes: a marker the decoder happened to skip would still be a
1033        // marker on the wire.
1034        let frame = tokio::time::timeout(GUARD, next_reply_after(&client, &id, &method))
1035            .await
1036            .map_err(|_| Failure::ChildOutlivedItsHandle)??;
1037
1038        // The forwarder is a separate task, so the marker arrives when it
1039        // arrives. Polled rather than slept, and bounded well past any real
1040        // latency, so a failure is fast and a pass is not luck.
1041        let mut seen = String::new();
1042        for _ in 0..200 {
1043            seen = capture.contents();
1044            if seen.contains(STDERR_MARKER) {
1045                break;
1046            }
1047            tokio::time::sleep(Duration::from_millis(10)).await;
1048        }
1049        assert!(
1050            seen.contains(STDERR_MARKER),
1051            "the marker never reached the forwarder: {seen:?}"
1052        );
1053        assert!(
1054            !frame.contains(STDERR_MARKER),
1055            "the marker reached the protocol stream: {frame}"
1056        );
1057        Ok(())
1058    }
1059
1060    /// A caller can add its own channel beside the forwarder's, which is why
1061    /// the client hands the channel back rather than owning it privately.
1062    #[tokio::test]
1063    async fn a_caller_can_add_its_own_diagnostics_channel() -> Result<(), Failure> {
1064        let (client, capture) = client_on(Role::Echo, Some(GUARD)).await?;
1065
1066        client
1067            .diagnostics()
1068            .write_all(b"a line the caller chose\n")
1069            .await?;
1070
1071        assert!(capture.contents().contains("a line the caller chose"));
1072        Ok(())
1073    }
1074
1075    /// A program that cannot be started is refused, and the refusal says the
1076    /// program rather than leaking the operating system's error.
1077    #[tokio::test]
1078    async fn a_program_that_cannot_be_started_is_refused() -> Result<(), Failure> {
1079        let spec = ChildSpec::new("protocol-droid-no-such-program-for-the-tests")?;
1080        let config = ClientConfig::new(StdioConfig::default());
1081
1082        let outcome = StdioClient::spawn(&spec, config, Diagnostics::new(Capture::shared()))
1083            .await
1084            .map_err(Failure::Spawn);
1085
1086        assert_matches!(outcome, Err(Failure::Spawn(SpawnError::NotStarted)));
1087        Ok(())
1088    }
1089
1090    /// `terminate` is the polite version: the caller asked, so the caller is
1091    /// told how it ended, and the reap has already happened when it returns.
1092    #[tokio::test]
1093    async fn terminate_kills_the_child_and_reports_the_exit() -> Result<(), Failure> {
1094        let (mut client, _capture) = client_on(Role::Silent, Some(GUARD)).await?;
1095        let pid = client.child_id();
1096
1097        let status = tokio::time::timeout(GUARD, client.terminate())
1098            .await
1099            .map_err(|_| Failure::ChildOutlivedItsHandle)??;
1100
1101        // No exit status to assert on beyond "it is one": a killed process is
1102        // a signal on Unix and an exit code elsewhere, and the claim here is
1103        // that the wait completed and produced a status at all.
1104        let Some(_status) = status else {
1105            return Err(Failure::ClosedEarly);
1106        };
1107        assert!(pid.is_some(), "a running client has a process id");
1108        Ok(())
1109    }
1110
1111    /// Two calls on one client cannot collide on an id, which is what makes
1112    /// matching the answer to the request sound.
1113    #[tokio::test]
1114    async fn request_ids_advance_and_never_repeat() -> Result<(), Failure> {
1115        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
1116
1117        let first = client.next_request_id();
1118        let second = client.next_request_id();
1119
1120        assert_ne!(first, second);
1121        Ok(())
1122    }
1123
1124    /// The port and the convenience call are the same conversation: the port
1125    /// takes a method as a string, and everything after that is identical.
1126    #[tokio::test]
1127    async fn the_port_sends_a_request_the_same_way_the_call_does() -> Result<(), Failure> {
1128        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
1129
1130        let result = tokio::time::timeout(GUARD, client.send("tools/list", &JsonObject::default()))
1131            .await
1132            .map_err(|_| Failure::ChildOutlivedItsHandle)??;
1133
1134        assert_eq!(
1135            fields(&result)?.get("method"),
1136            Some(&Value::from("tools/list"))
1137        );
1138        Ok(())
1139    }
1140
1141    /// A method the IR would not admit is refused by the port without a byte
1142    /// being written, rather than being sent and refused by the peer.
1143    #[tokio::test]
1144    async fn the_port_refuses_a_method_the_ir_will_not_admit() -> Result<(), Failure> {
1145        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
1146
1147        let outcome = client.send("", &JsonObject::default()).await;
1148
1149        // Matched on the variant, not on the rendered text.
1150        // `TransportError::Unavailable` is a transparent newtype over its
1151        // payload, so its `Display` is the reason alone and the word
1152        // "Unavailable" never appears in it — a substring assertion here would
1153        // be testing the `#[error]` attribute rather than the refusal.
1154        assert_matches!(outcome, Err(TransportError::Unavailable(reason)) if reason.contains("empty"));
1155        Ok(())
1156    }
1157
1158    /// The client's own report of itself names the process and the settings,
1159    /// and nothing about the pipes.
1160    #[tokio::test]
1161    async fn a_client_names_its_child_and_its_configuration() -> Result<(), Failure> {
1162        let (client, _capture) = client_on(Role::Echo, Some(GUARD)).await?;
1163
1164        assert!(
1165            client.child_id().is_some(),
1166            "a running client has a process id"
1167        );
1168        assert_eq!(client.config().request_timeout(), Some(GUARD));
1169        assert!(format!("{client:?}").contains("StdioClient"));
1170
1171        // The halves are handed over rather than kept private, so a caller
1172        // driving the conversation itself gets the byte layer's own types.
1173        let _source = client.source();
1174        let _sink = client.sink();
1175        Ok(())
1176    }
1177
1178    /// The load-bearing claim in `child.rs`: dropping the process handle kills
1179    /// the process, and the proof is the kernel closing the child's own pipe.
1180    ///
1181    /// The obvious version of this test proves nothing. It drops a whole
1182    /// `StdioClient` and waits for the same forwarder — and passes either way,
1183    /// because the client owns the child's stdin, so dropping it closes the
1184    /// write end, the child reads end-of-file, and it exits without anyone
1185    /// killing it. That is a test of stdin closure wearing kill-on-drop's
1186    /// name, and it would keep passing if the flag were removed.
1187    ///
1188    /// So this one keeps the write end and drops only the handle. With stdin
1189    /// held, the child cannot reach its `Ok(0) => break`, and the kill is the
1190    /// only thing left that can end it.
1191    #[tokio::test]
1192    async fn dropping_the_child_handle_kills_the_process() -> Result<(), Failure> {
1193        let spec = child_spec(Role::Silent)?;
1194        let spawned = child::spawn(&spec)?;
1195
1196        let Some(stdin) = spawned.stdin else {
1197            return Err(Failure::Spawn(SpawnError::NoPipe { stream: "stdin" }));
1198        };
1199        let Some(_stdout) = spawned.stdout else {
1200            return Err(Failure::Spawn(SpawnError::NoPipe { stream: "stdout" }));
1201        };
1202        let Some(stderr) = spawned.stderr else {
1203            return Err(Failure::Spawn(SpawnError::NoPipe { stream: "stderr" }));
1204        };
1205        // Named and bound so the intent survives review: this handle is the
1206        // only thing keeping the child off its end-of-file path, and it must
1207        // outlive the drop below.
1208        let _stdin_held_open = stdin;
1209
1210        let forwarder =
1211            child::forward_stderr(stderr, Arc::new(Diagnostics::new(Capture::shared())));
1212
1213        // The only statement in this test that could end the child.
1214        drop(spawned.child);
1215
1216        // End-of-file on the child's own stderr is the operating system
1217        // saying the process is gone — not a handle's `Drop` having run, which
1218        // is the distinction the module docs make. The guard is far larger
1219        // than a kill needs, so reaching it means the kill never happened.
1220        tokio::time::timeout(GUARD, forwarder)
1221            .await
1222            .map_err(|_| Failure::ChildOutlivedItsHandle)??;
1223        Ok(())
1224    }
1225
1226    /// The control for the test above, and the reason it means anything.
1227    ///
1228    /// The same child, started the same way but *without* `kill_on_drop`, is
1229    /// still running half a second later. Without this, the forwarder
1230    /// completing above would be equally consistent with a child that exited
1231    /// on its own — which is exactly the false proof the test above is shaped
1232    /// to avoid, and this rules that out from the other side by showing the
1233    /// child does not exit by itself.
1234    #[tokio::test]
1235    async fn a_child_that_was_not_killed_is_still_running() -> Result<(), Failure> {
1236        let spec = child_spec(Role::Silent)?;
1237        let mut command = spec.to_command();
1238        command
1239            .stdin(Stdio::piped())
1240            .stdout(Stdio::piped())
1241            .stderr(Stdio::piped());
1242        // No `kill_on_drop` on this one, and that is the entire difference
1243        // between this process and the one the test above drops.
1244        let mut child = command.spawn()?;
1245
1246        // As above: holding stdin is what keeps the child off its EOF path.
1247        let _stdin_held_open = child.stdin.take();
1248        let _stdout = child.stdout.take();
1249        let _stderr = child.stderr.take();
1250
1251        tokio::time::sleep(Duration::from_millis(500)).await;
1252
1253        match child.try_wait() {
1254            Ok(None) => {}
1255            Ok(Some(status)) => return Err(Failure::ExitedEarly(status)),
1256            Err(error) => return Err(Failure::Io(error)),
1257        }
1258
1259        // Nothing else will kill this child but this test, so it is cleaned
1260        // up here rather than left for the machine to reap.
1261        let _killed = child.start_kill();
1262        let _reaped = child.wait().await;
1263        Ok(())
1264    }
1265
1266    /// Writes a request the long way and reads its answer, so a test can hold
1267    /// the write and the read separately.
1268    async fn next_reply_after(
1269        client: &StdioClient<Capture>,
1270        id: &RequestId,
1271        method: &MethodName,
1272    ) -> Result<String, Failure> {
1273        let document = request_document(id, method, &JsonObject::default())?.to_string();
1274        client.send_document(&document).await?;
1275        next_reply(client, id).await
1276    }
1277}