Skip to main content

running_process/broker/
session_pump.rs

1//! Phase 3 proxy pump (soldr#2365, slice 2b): drive a child process as a
2//! broker-proxied compile session.
3//!
4//! [`run_child_session`] owns a spawned child with piped stdio and bridges it
5//! to a pair of [`SessionFrame`] channels: it streams the child's stdout/stderr
6//! out as `Stdout`/`Stderr` frames, applies inbound `Stdin`/`StdinEof` frames to
7//! the child's stdin, and finishes by sending a terminal `Exit` frame carrying
8//! the child's exit code (or signal on Unix).
9//!
10//! It is deliberately **transport-agnostic** — it speaks in-memory
11//! [`std::sync::mpsc`] channels of decoded `SessionFrame`s, not the broker
12//! socket. A later Phase 3 slice wraps these channels in the `Frame` envelope on
13//! the [`SESSION_PAYLOAD_PROTOCOL`](crate::broker::protocol::SESSION_PAYLOAD_PROTOCOL)
14//! lane so the same pump runs across the broker. Keeping the byte-transparency
15//! logic here — verified against the direct-execution oracle
16//! (`stdio_fidelity_oracle_test`) — means the transport slice only has to prove
17//! framing, not fidelity.
18
19use std::io::{Read, Write};
20use std::process::{Child, ExitStatus};
21use std::sync::mpsc::{Receiver, Sender, SyncSender};
22use std::thread;
23
24use crate::broker::protocol_v2::{session_frame, SessionExit, SessionFrame};
25
26/// A sink the pump writes outbound `SessionFrame`s to.
27///
28/// `send` is **blocking**: a bounded implementation stalls the pump's reader
29/// thread when the consumer is slow, which lets the child's OS pipe fill and
30/// backpressures the child. That is what keeps a fast producer (rustc) within
31/// **bounded per-channel memory** while staying byte-exact — the sink never
32/// drops output, it slows the producer (soldr#2365: "bounded per-channel memory
33/// asserted", byte-exact fidelity). `Err(())` means the receiver is gone and the
34/// pump should stop.
35///
36/// Implemented for the unbounded [`Sender`] (the handoff / test path, where the
37/// consumer drains eagerly) and the bounded [`SyncSender`] (the daemon path,
38/// where backpressure is required). The daemon adds an impl for a bounded async
39/// channel sender so the same pump feeds an async transport.
40pub trait FrameSink: Clone + Send + 'static {
41    /// Send one frame, blocking if the sink is bounded and full. On failure the
42    /// receiver is gone; the un-sent frame is returned (as `std`'s channels do
43    /// via `SendError`) so the caller can stop pumping.
44    fn send(&self, frame: SessionFrame) -> Result<(), SessionFrame>;
45}
46
47impl FrameSink for Sender<SessionFrame> {
48    fn send(&self, frame: SessionFrame) -> Result<(), SessionFrame> {
49        Sender::send(self, frame).map_err(|e| e.0)
50    }
51}
52
53impl FrameSink for SyncSender<SessionFrame> {
54    fn send(&self, frame: SessionFrame) -> Result<(), SessionFrame> {
55        // Blocks when the bounded channel is full → backpressure to the child.
56        SyncSender::send(self, frame).map_err(|e| e.0)
57    }
58}
59
60/// A spawned child the session pump can drive: the three raw stdio pipes plus a
61/// blocking wait that yields a [`SessionExit`].
62///
63/// Implemented for a plain [`std::process::Child`] (the reference/client path,
64/// exercised by the pump's own tests) and, in [`super::session_server`], for the
65/// sanitized contained [`crate::spawn::SpawnedChild`] so the daemon can proxy a
66/// child confined to its own Job Object / process group. Keeping the pump
67/// generic over this trait is what lets one byte-transparent implementation
68/// serve both paths without the daemon side re-deriving the fidelity logic.
69///
70/// The stdio associated types are `Send + 'static` because the pump moves each
71/// into its own reader/writer thread.
72pub trait SessionChild {
73    /// Parent-side writer for the child's stdin.
74    type Stdin: Write + Send + 'static;
75    /// Parent-side reader for the child's stdout.
76    type Stdout: Read + Send + 'static;
77    /// Parent-side reader for the child's stderr.
78    type Stderr: Read + Send + 'static;
79
80    /// Take the stdin writer. `None` if stdin was not piped or already taken.
81    fn take_stdin(&mut self) -> Option<Self::Stdin>;
82    /// Take the stdout reader. `None` if stdout was not piped or already taken.
83    fn take_stdout(&mut self) -> Option<Self::Stdout>;
84    /// Take the stderr reader. `None` if stderr was not piped or already taken.
85    fn take_stderr(&mut self) -> Option<Self::Stderr>;
86    /// Block until the child exits and report its [`SessionExit`].
87    fn wait_session(&mut self) -> std::io::Result<SessionExit>;
88}
89
90impl SessionChild for Child {
91    type Stdin = std::process::ChildStdin;
92    type Stdout = std::process::ChildStdout;
93    type Stderr = std::process::ChildStderr;
94
95    fn take_stdin(&mut self) -> Option<Self::Stdin> {
96        self.stdin.take()
97    }
98    fn take_stdout(&mut self) -> Option<Self::Stdout> {
99        self.stdout.take()
100    }
101    fn take_stderr(&mut self) -> Option<Self::Stderr> {
102        self.stderr.take()
103    }
104    fn wait_session(&mut self) -> std::io::Result<SessionExit> {
105        Ok(session_exit_from_status(&self.wait()?))
106    }
107}
108
109/// Read `stream` to EOF, emitting each chunk as a `SessionFrame` built by
110/// `wrap`. Stops on EOF, a send error (receiver gone), or a read error.
111fn pump_output_stream<S, F, K>(stream: Option<S>, wrap: F, out: &K)
112where
113    S: Read,
114    F: Fn(Vec<u8>) -> session_frame::Kind,
115    K: FrameSink,
116{
117    let Some(mut stream) = stream else {
118        return;
119    };
120    let mut buf = [0u8; 8192];
121    loop {
122        match stream.read(&mut buf) {
123            Ok(0) => break,
124            Ok(n) => {
125                let frame = SessionFrame {
126                    kind: Some(wrap(buf[..n].to_vec())),
127                };
128                if out.send(frame).is_err() {
129                    break;
130                }
131            }
132            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
133            Err(_) => break,
134        }
135    }
136}
137
138/// Map a finished child's status to a [`SessionExit`]. On Unix a signal death
139/// carries the signal number; on Windows `signal` is always 0.
140fn session_exit_from_status(status: &ExitStatus) -> SessionExit {
141    #[cfg(unix)]
142    {
143        use std::os::unix::process::ExitStatusExt;
144        SessionExit {
145            code: status.code().unwrap_or(-1),
146            signal: status.signal().unwrap_or(0),
147            metadata: Default::default(),
148        }
149    }
150    #[cfg(windows)]
151    {
152        SessionExit {
153            code: status.code().unwrap_or(-1),
154            signal: 0,
155            metadata: Default::default(),
156        }
157    }
158}
159
160/// Drive `child` as a proxied session.
161///
162/// - stdout/stderr are streamed out as `SessionFrame::Stdout`/`Stderr` on `out`,
163///   byte-for-byte and on their own streams (never crossed).
164/// - inbound `SessionFrame::Stdin(bytes)` are written to the child's stdin;
165///   `SessionFrame::StdinEof` closes it (dropping the pipe handle). Other inbound
166///   kinds are ignored — only client→daemon frames are meaningful here.
167/// - when the child exits, a terminal `SessionFrame::Exit` is sent on `out` and
168///   the same [`SessionExit`] is returned.
169///
170/// `child` must be spawned with all three stdio streams piped. Returns the
171/// child's [`SessionExit`]; an `Err` only reflects a failure to reap the child,
172/// never stdio content.
173pub fn run_child_session<C: SessionChild, K: FrameSink>(
174    mut child: C,
175    out: K,
176    stdin_rx: Receiver<SessionFrame>,
177) -> std::io::Result<SessionExit> {
178    let child_stdout = child.take_stdout();
179    let child_stderr = child.take_stderr();
180    let mut child_stdin = child.take_stdin();
181
182    // Apply inbound stdin frames on their own thread so a child that interleaves
183    // reads and writes never deadlocks against the output pumps.
184    let stdin_handle = thread::spawn(move || {
185        for frame in stdin_rx {
186            match frame.kind {
187                Some(session_frame::Kind::Stdin(bytes)) => {
188                    if let Some(writer) = child_stdin.as_mut() {
189                        if writer
190                            .write_all(&bytes)
191                            .and_then(|()| writer.flush())
192                            .is_err()
193                        {
194                            // Child closed its stdin; stop trying to feed it but
195                            // keep draining the channel so senders don't block.
196                            child_stdin = None;
197                        }
198                    }
199                }
200                Some(session_frame::Kind::StdinEof(_)) => {
201                    // Drop the write handle → the child sees EOF on stdin.
202                    child_stdin = None;
203                }
204                _ => {}
205            }
206        }
207        // Channel closed (client hung up) without an explicit EOF: still close
208        // the child's stdin so a child blocked on read can make progress.
209        drop(child_stdin);
210    });
211
212    let out_for_stdout = out.clone();
213    let stdout_handle = thread::spawn(move || {
214        pump_output_stream(child_stdout, session_frame::Kind::Stdout, &out_for_stdout);
215    });
216    let out_for_stderr = out.clone();
217    let stderr_handle = thread::spawn(move || {
218        pump_output_stream(child_stderr, session_frame::Kind::Stderr, &out_for_stderr);
219    });
220
221    // The output pumps end when the child closes stdout/stderr, i.e. on exit.
222    let _ = stdout_handle.join();
223    let _ = stderr_handle.join();
224    let exit = child.wait_session()?;
225    let _ = stdin_handle.join();
226
227    // Clone for the terminal frame and return the original: `SessionExit` is no
228    // longer `Copy` (it carries an opaque `metadata` map since soldr#2365 Q3).
229    let _ = out.send(SessionFrame {
230        kind: Some(session_frame::Kind::Exit(exit.clone())),
231    });
232    Ok(exit)
233}
234
235#[cfg(test)]
236mod tests;