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;