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}