protocol_droid/adapters/secondary/stdio/bridge_peer.rs
1//! Carries envelopes over a `stdio` child.
2//!
3//! ## Why this is a wrapper
4//!
5//! The obvious impl is on [`StdioClient`] directly, and it nearly is: the
6//! client already exposes exactly the two halves a peer needs, `send_document`
7//! and `next_frame`. What it does not offer is a way to say "this frame is not
8//! the one I asked for". Reading until the id matches means walking past
9//! anything the child pushed in between, and a `stdio` server is free to push
10//! a `notifications/tools/list_changed` at any moment.
11//!
12//! So the wrapper owns that decision, and it owns it for the same reason the
13//! `2024-11-05` peer does: the crate promises that an unmodelled directive
14//! reaches the other side byte-for-byte, and a notification that landed mid-call
15//! is real traffic. It goes to `take_pending`.
16//!
17//! ## Why the client sits behind a lock
18//!
19//! A request is a write and a read, and the two have to belong to the *same*
20//! call: two peers sharing one child's stdout, each reading frames it did not
21//! ask for, is a bug the lock makes impossible rather than unlikely. The lock
22//! is held across both halves, not just the write.
23
24use serde_json::{Map, Value};
25use tokio::sync::Mutex;
26
27use crate::application::bridge::{BridgeError, BridgePeer, SendFuture, Unsolicited};
28use crate::application::jsonrpc::SourceError;
29
30use super::StdioClient;
31
32/// A `stdio` peer over one spawned child.
33#[derive(Debug)]
34pub struct StdioPeer<DW> {
35 client: Mutex<StdioClient<DW>>,
36 unsolicited: Unsolicited,
37}
38
39impl<DW> StdioPeer<DW> {
40 /// Wraps a spawned client.
41 #[must_use]
42 pub fn new(client: StdioClient<DW>) -> Self {
43 Self {
44 client: Mutex::new(client),
45 unsolicited: Unsolicited::new(),
46 }
47 }
48
49 /// Takes every frame that arrived while some other call was in flight.
50 #[must_use]
51 pub fn take_pending(&self) -> Vec<Map<String, Value>> {
52 self.unsolicited.take()
53 }
54}
55
56impl<DW> BridgePeer for StdioPeer<DW>
57where
58 DW: tokio::io::AsyncWrite + Unpin + Send + 'static,
59{
60 /// Delivers one envelope and yields the frame carrying its answer.
61 ///
62 /// # Errors
63 ///
64 /// [`BridgeError::Unreachable`] when the child's stdin refused the
65 /// document, when its stdout closed before the answer arrived, or when the
66 /// child closed rather than answering. A peer that answered with an `error`
67 /// member is *not* an error here: it comes back through `Ok`, because a
68 /// declined request is a successful exchange.
69 #[expect(
70 clippy::significant_drop_tightening,
71 reason = "the guard spans the write AND the read on purpose: a request is two halves, and a peer that released the lock between them would let a second call read frames it never asked for"
72 )]
73 fn send(
74 &self,
75 envelope: Map<String, Value>,
76 ) -> SendFuture<'_, Result<Map<String, Value>, BridgeError>> {
77 Box::pin(async move {
78 // A notification has no id, so nothing could ever match it and the
79 // read below would walk to end-of-stream. `notify` is its method.
80 let Some(id) = envelope.get("id").cloned() else {
81 return Err(BridgeError::Unreachable {
82 reason: "a notification was sent through the request path".to_owned(),
83 });
84 };
85
86 // One lock across both halves, deliberately: the read has to
87 // belong to the write that preceded it.
88 let client = self.client.lock().await;
89 client
90 .send_document(&Value::Object(envelope).to_string())
91 .await
92 .map_err(|error| unreachable(&error.to_string()))?;
93
94 loop {
95 let Some(frame) = client.next_frame().await.map_err(|error| read(&error))? else {
96 return Err(BridgeError::Unreachable {
97 reason: "the child closed before it answered".to_owned(),
98 });
99 };
100 let Some(answer) = answer_in(&frame)? else {
101 continue;
102 };
103 if answer.get("id") == Some(&id) {
104 return Ok(answer);
105 }
106 self.unsolicited.hold(answer);
107 }
108 })
109 }
110
111 /// Delivers one envelope that expects no answer.
112 ///
113 /// # Errors
114 ///
115 /// [`BridgeError::Unreachable`] when the child's stdin refused the document.
116 fn notify(&self, envelope: Map<String, Value>) -> SendFuture<'_, Result<(), BridgeError>> {
117 Box::pin(async move {
118 let client = self.client.lock().await;
119 client
120 .send_document(&Value::Object(envelope).to_string())
121 .await
122 .map_err(|error| BridgeError::Unreachable {
123 reason: error.to_string(),
124 })
125 })
126 }
127
128 /// Ends the upstream session.
129 ///
130 /// # Errors
131 ///
132 /// [`BridgeError::Unreachable`] when the child could not be reaped. Calling
133 /// this twice is not a second kill — the second call reports `Ok(None)`,
134 /// because there is nothing left to wait for — and that is not an error
135 /// either.
136 fn close(&self) -> SendFuture<'_, Result<(), BridgeError>> {
137 Box::pin(async move {
138 let mut client = self.client.lock().await;
139 let outcome = client
140 .terminate()
141 .await
142 .map_err(|error| unreachable(&error.to_string()));
143 // Nothing reads after the kill, so the guard has no reason to
144 // outlive the call — unlike `send`, where it deliberately spans
145 // both halves of the exchange.
146 drop(client);
147 outcome?;
148 Ok(())
149 })
150 }
151
152 /// The frames the child pushed while another call was in flight.
153 ///
154 /// Overrides the trait's empty answer because a `stdio` child is free to
155 /// write at any moment and this is where those writes land.
156 fn take_pending(&self) -> Vec<Map<String, Value>> {
157 Self::take_pending(self)
158 }
159
160 fn wait_pending(&self) -> SendFuture<'static, ()> {
161 // The queue is where the frames are, so waiting on the queue is
162 // waiting on the peer.
163 Box::pin(self.unsolicited.wait())
164 }
165}
166
167/// Reads one frame the child wrote, if it is a JSON-RPC message at all.
168fn answer_in(frame: &str) -> Result<Option<Map<String, Value>>, BridgeError> {
169 let value: Value = serde_json::from_str(frame).map_err(|error| BridgeError::Unreachable {
170 reason: format!("the child answered with something that is not JSON: {error}"),
171 })?;
172 match value {
173 Value::Object(object) => Ok(Some(object)),
174 other => Err(BridgeError::Unreachable {
175 reason: format!(
176 "the child answered with a JSON {} rather than a JSON-RPC message",
177 kind(&other)
178 ),
179 }),
180 }
181}
182
183/// The JSON type name, so the refusal says what arrived.
184const fn kind(value: &Value) -> &'static str {
185 match value {
186 Value::Null => "null",
187 Value::Bool(_) => "boolean",
188 Value::Number(_) => "number",
189 Value::String(_) => "string",
190 Value::Array(_) => "array",
191 Value::Object(_) => "object",
192 }
193}
194
195/// Maps a transport failure onto the bridge's vocabulary.
196fn read(error: &SourceError) -> BridgeError {
197 unreachable(&error.to_string())
198}
199
200fn unreachable(reason: &str) -> BridgeError {
201 BridgeError::Unreachable {
202 reason: reason.to_owned(),
203 }
204}
205#[cfg(test)]
206mod tests {
207 use serde_json::json;
208
209 use super::{StdioPeer, answer_in};
210 use crate::adapters::primary::stdio::Diagnostics;
211 use crate::adapters::primary::stdio::StdioConfig;
212 use crate::adapters::secondary::stdio::StdioClient;
213 use crate::adapters::secondary::stdio::client::ClientConfig;
214 use crate::adapters::secondary::stdio::testing::{Capture, Role, SpawnFailure, child_spec};
215
216 /// Every failure a test can hit, in one type, so `?` works throughout.
217 #[derive(Debug, thiserror::Error)]
218 enum Failure {
219 /// A test child could not be described. Carried rather than converted,
220 /// because `SpawnFailure` is a plain enum with no `Display` of its own
221 /// and adding one to reach a `?` would be noise in a test helper.
222 #[error("a test child could not be described: {0:?}")]
223 Spawn(SpawnFailure),
224 /// The child could not be started.
225 #[error(transparent)]
226 Client(#[from] crate::adapters::secondary::stdio::child::SpawnError),
227 /// The spec could not be built.
228 #[error(transparent)]
229 Spec(#[from] crate::adapters::secondary::stdio::spec::ChildSpecError),
230 /// Anything boxed, for the config.
231 #[error(transparent)]
232 Boxed(#[from] Box<dyn std::error::Error>),
233 }
234
235 /// The peer is constructible from a real child and starts with nothing
236 /// held.
237 ///
238 /// Construction is all this claims. Driving `send` against this harness
239 /// would test the harness: libtest writes `running 1 test` to the child's
240 /// stdout, which *is* the protocol stream, so frame zero is harness output
241 /// rather than a message. `answer_in` rightly refuses that, and a test that
242 /// taught it to skip would be teaching the adapter that garbage on a
243 /// `stdio` stream is fine.
244 #[tokio::test]
245 async fn a_fresh_peer_holds_nothing() -> Result<(), Failure> {
246 let spec = child_spec(Role::Echo).map_err(Failure::Spawn)?;
247 let client = StdioClient::spawn(
248 &spec,
249 ClientConfig::new(StdioConfig::default()),
250 Diagnostics::new(Capture::shared()),
251 )
252 .await?;
253
254 assert!(StdioPeer::new(client).take_pending().is_empty());
255 Ok(())
256 }
257
258 /// A well-formed message is the answer.
259 #[test]
260 fn a_message_is_read_as_an_answer() {
261 let read = answer_in(r#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#);
262 assert!(read.is_ok(), "a JSON-RPC object is an answer");
263 if let Ok(Some(frame)) = read {
264 assert_eq!(frame.get("id"), Some(&json!(7)));
265 }
266 }
267
268 /// Anything that will not parse is refused rather than skipped.
269 ///
270 /// A `stdio` peer that walked past unparseable frames would silently drop
271 /// whatever a misbehaving child wrote instead of saying so.
272 #[test]
273 fn a_frame_that_is_not_json_is_refused() {
274 assert!(answer_in("running 1 test").is_err());
275 }
276
277 /// The refusal says what arrived, so the caller can act on it.
278 #[test]
279 fn a_json_scalar_is_refused_naming_its_type() {
280 let read = answer_in("[1,2,3]");
281 assert!(read.is_err(), "an array is not a JSON-RPC message");
282 let Err(reason) = read else {
283 return;
284 };
285 let text = reason.to_string();
286 assert!(text.contains("array"), "{text}");
287 }
288}