Skip to main content

protocol_droid/adapters/secondary/stdio/
child.rs

1//! The process: spawned once, drained on stderr, killed on drop.
2//!
3//! # `kill_on_drop` is the whole point of this module
4//!
5//! `tokio::process::Child` does **not** kill the process it is dropped with.
6//! That is not a detail of this crate's design; it is the default in `tokio`,
7//! and it is the correct default for a `Child` whose whole job is to be waited
8//! on. A stdio client is not that: it holds a server that has no other reason
9//! to exist, so dropping the client has to take the server with it. A client
10//! that failed halfway through a handshake and returned an error would
11//! otherwise leave a process running with nobody reading its stdout — an
12//! orphan holding a pipe, holding whatever the user asked it to do, for as
13//! long as the machine stays up.
14//!
15//! So `spawn` sets `kill_on_drop(true)` before the `Command` is ever
16//! executed, and it is the only function in the crate that spawns a child. One
17//! place to look, one flag to audit.
18//!
19//! On Unix the flag alone leaves the killed child to be reaped, and that is
20//! tokio's own business rather than this crate's: `process` compiles the
21//! `SIGCHLD` machinery for a Unix target whether or not the public `signal`
22//! feature is enabled (`cfg_signal_internal!` in tokio's `macros/cfg.rs` is
23//! `any(feature = "signal", all(unix, feature = "process"))`), so the reaper is
24//! always present here and the dependency stays at `process`.
25//!
26//! # Where the child's stderr goes
27//!
28//! A server logs to stderr because stdout is the conversation, and a client
29//! that discarded that stream would deadlock: a child writing more than a pipe
30//! buffer's worth of log output blocks on a full pipe and stops answering. So
31//! `forward_stderr` drains it continuously and hands it to
32//! [`Diagnostics`], one line at
33//! a time.
34//!
35//! One line at a time is the part worth arguing. A read from a pipe returns
36//! whatever happens to be in the buffer, so forwarding the read directly would
37//! write log lines in whatever sizes the kernel chose — a diagnostic channel
38//! that interleaves its own lines with itself. Accumulating to the next
39//! newline costs one `Vec` per child and makes the log the log rather than a
40//! stream of fragments.
41//!
42//! Nothing here writes to the protocol. There is no path from this module's
43//! stderr handling to a [`StdioSink`](crate::adapters::primary::stdio::StdioSink),
44//! and there is not going to be one.
45//!
46//! # Examples
47//!
48//! The whole lifecycle, which is what a caller of
49//! [`StdioClient::spawn`](super::StdioClient::spawn) is doing under the hood:
50//!
51//! ```no_run
52//! use protocol_droid::adapters::secondary::stdio::{ChildSpec, ClientConfig, StdioClient};
53//! use protocol_droid::adapters::primary::stdio::{Diagnostics, StdioConfig};
54//! use protocol_droid::domain::MaxFrameSize;
55//!
56//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
57//! let spec = ChildSpec::new("my-server")?;
58//! let config = ClientConfig::new(StdioConfig::new(MaxFrameSize::new(1024 * 1024)?));
59//!
60//! let client = StdioClient::spawn(&spec, config, Diagnostics::stderr()).await?;
61//! // Dropping `client` here kills the child: `kill_on_drop(true)` was set
62//! // before the process was started, so the server does not outlive it.
63//! drop(client);
64//! # Ok(())
65//! # }
66//! ```
67
68use std::process::Stdio;
69use std::sync::Arc;
70
71use tokio::io::{AsyncReadExt, AsyncWrite};
72use tokio::process::{Child, ChildStderr};
73use tokio::task::JoinHandle;
74
75use crate::adapters::primary::stdio::Diagnostics;
76
77use super::spec::ChildSpec;
78
79/// How many bytes one read of the child's stderr may ask for.
80///
81/// A read from a pipe returns whatever is in the buffer, so this bounds the
82/// copy rather than promising a size. Large enough that a chatty server is not
83/// read in slivers, small enough that no child carries a kilobyte of log
84/// buffer per connection.
85const STDERR_READ_CHUNK: usize = 4 * 1024;
86
87/// A freshly spawned child's process handle and the three pipes it was given.
88#[derive(Debug)]
89pub(crate) struct SpawnedChild {
90    /// The process. Dropping it kills the process, because [`spawn`] said so.
91    pub(crate) child: Child,
92    /// The child's stdin, for writing frames to.
93    pub(crate) stdin: Option<tokio::process::ChildStdin>,
94    /// The child's stdout, for reading frames from.
95    pub(crate) stdout: Option<tokio::process::ChildStdout>,
96    /// The child's stderr, which is never a frame.
97    pub(crate) stderr: Option<ChildStderr>,
98}
99
100/// Starts a child server, with all three of its standard streams piped.
101///
102/// The pipes are requested here rather than in [`ChildSpec::to_command`]
103/// because a spec describes a program and a client describes a conversation,
104/// and the conversation needs a stdin and a stdout to have a conversation on.
105/// Both halves are taken out of the returned [`Child`] immediately, so the
106/// process handle is left holding nothing but the handle.
107///
108/// # Errors
109///
110/// [`SpawnError::NotStarted`] when the program could not be started at all —
111/// no such file, not executable, out of processes. The pipe requests cannot
112/// fail, so this is the only way the call can.
113pub(crate) fn spawn(spec: &ChildSpec) -> Result<SpawnedChild, SpawnError> {
114    let mut command = spec.to_command();
115    command
116        .stdin(Stdio::piped())
117        .stdout(Stdio::piped())
118        .stderr(Stdio::piped())
119        // The load-bearing line. See the module docs.
120        .kill_on_drop(true);
121    // `tokio::process::Command::spawn` is synchronous — the async surface of
122    // the process API is the child's I/O and its `wait`, not the fork.
123    let mut child = command.spawn().map_err(|_| SpawnError::NotStarted)?;
124    Ok(SpawnedChild {
125        stdin: child.stdin.take(),
126        stdout: child.stdout.take(),
127        stderr: child.stderr.take(),
128        child,
129    })
130}
131
132/// Drains the child's stderr into a diagnostics channel, and keeps draining it.
133///
134/// Returns the task's handle, which completes when the pipe reaches
135/// end-of-file. That is worth having rather than discarding: end-of-file on
136/// the child's stderr is the kernel's own account of the child exiting, and a
137/// caller that needs to know the process is gone — a test proving a dropped
138/// client took its server with it, say — can await this and be told by the
139/// operating system rather than by a handle's `Drop` having run.
140///
141/// The handle is deliberately not awaited here. Nothing else has to happen
142/// before the log is written, and a forwarder that blocked the spawn until the
143/// child exited would be a child that had to exit before it could be used.
144///
145/// # Panics
146///
147/// Never. The task swallows the stream's own failures: a child's stderr that
148/// cannot be read, or a diagnostics channel that refuses a write, ends the
149/// forwarder. Neither is a fault in the conversation, and a task that could
150/// not report them would take the runtime's panic handler with it.
151pub(crate) fn forward_stderr<DW>(
152    mut stderr: ChildStderr,
153    diagnostics: Arc<Diagnostics<DW>>,
154) -> JoinHandle<()>
155where
156    DW: AsyncWrite + Unpin + Send + 'static,
157{
158    tokio::spawn(async move {
159        let mut buffer = vec![0; STDERR_READ_CHUNK];
160        let mut pending: Vec<u8> = Vec::new();
161        loop {
162            let read = match stderr.read(&mut buffer).await {
163                Ok(0) | Err(_) => break,
164                Ok(read) => read,
165            };
166            let Some(chunk) = buffer.get(..read) else {
167                break;
168            };
169            pending.extend_from_slice(chunk);
170            // Whole lines only: a diagnostic interleaved with another
171            // diagnostic is a corrupted log, and a corrupted log is the reason
172            // people turn logging off.
173            while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') {
174                let line: Vec<u8> = pending.drain(..=newline).collect();
175                if diagnostics.write_all(&line).await.is_err() {
176                    return;
177                }
178            }
179        }
180        // Whatever the child wrote after its last newline still belongs in the
181        // log. Dropping it would lose the last thing a server said before it
182        // died, which is usually the reason anybody is reading.
183        //
184        // A failed write is discarded rather than returned on: this is the
185        // forwarder's last act, so there is nothing a caller could do with the
186        // failure and nowhere left to send it.
187        if !pending.is_empty() {
188            let _ = diagnostics.write_all(&pending).await;
189        }
190    })
191}
192
193/// Why a child could not be started.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
195pub enum SpawnError {
196    /// The program could not be started.
197    ///
198    /// Carries no `io::Error`, for the reason [`StdioError`] does: the caller
199    /// in a position to tell "no such file" from "not executable" is the one
200    /// that has already failed to spawn, and it can report the program's own
201    /// refusal. A `String` here would put a path this process invented onto
202    /// the error path of every caller above it.
203    ///
204    /// [`StdioError`]: crate::adapters::primary::stdio::StdioError
205    #[error("the child process could not be started")]
206    NotStarted,
207
208    /// The child started, but the platform did not give it one of the three
209    /// pipes.
210    ///
211    /// Not a failure the spawn can detect — `Stdio::piped()` succeeding and
212    /// the handle coming back without the stream is not a thing that happens —
213    /// and reported because the alternative on the only path that can observe
214    /// it is an `unwrap`, and an `unwrap` on a path that runs for every client
215    /// a caller ever spawns is a panic waiting for a platform nobody tested.
216    #[error("the child process was started without a {stream} pipe")]
217    NoPipe {
218        /// Which of the three standard streams the platform withheld.
219        stream: &'static str,
220    },
221}
222
223#[cfg(test)]
224mod tests {
225    use assert_matches::assert_matches;
226
227    use super::SpawnError;
228
229    /// A failure this module reports is not the same failure twice, so a test
230    /// that cannot tell them apart is testing nothing.
231    #[test]
232    fn the_two_spawn_failures_are_distinguishable() {
233        assert_ne!(
234            SpawnError::NotStarted,
235            SpawnError::NoPipe { stream: "stdout" }
236        );
237    }
238
239    /// The withheld-stream variant names the stream, because "a pipe" is not
240    /// an answer a caller can act on and the three are not interchangeable.
241    #[test]
242    fn a_withheld_pipe_names_its_stream() {
243        let error = SpawnError::NoPipe { stream: "stderr" };
244
245        assert!(error.to_string().contains("stderr"), "{error}");
246        assert_matches!(error, SpawnError::NoPipe { .. });
247    }
248}