Skip to main content

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}