Skip to main content

protocol_droid/adapters/secondary/acp/
stream.rs

1//! `Stream` wrappers for the peer's notification and agent-request channels.
2//!
3//! # Why a wrapper
4//!
5//! The peer exposes its notification and agent-request receivers as
6//! `&self`-borrowing `recv_*` methods that hold a `tokio::sync::Mutex`
7//! across an `await`. A consumer's main loop is, in practice, a
8//! `tokio::select!` between "the next streamed notification", "the next
9//! agent-originating request", and "the reply to my in-flight request";
10//! that shape wants `Stream` combinators (`map`, `filter`,
11//! `take_while`, `for_each`, …) rather than three ad-hoc `recv` calls.
12//!
13//! The wrappers here turn the two `Mutex<Receiver>` channels into
14//! `Stream`s. The implementation is a three-state machine: `Idle` (no
15//! lock held yet), `Locking` (waiting on the mutex), and `Ready` (the
16//! lock guard is held and the inner receiver is the one to poll). On
17//! every `poll_next` the state machine either advances a step or polls
18//! the receiver. The lock guard is held across `Pending` polls of the
19//! inner `recv`, so a waker does not lose its place; the guard is
20//! released between successive values, which mirrors what
21//! `recv_notification` does today (the guard goes out of scope at the
22//! end of the function).
23//!
24//! # Why a `Stream` and not a `BroadcastStream` or `WatchStream`
25//!
26//! The existing channel is an `mpsc` with one consumer, which is the
27//! shape `Stream` describes directly. `tokio_stream::wrappers` provides
28//! `ReceiverStream` for an *owned* receiver; the receiver here is
29//! behind an `Arc<Mutex>` so two consumers (`recv_notification` and the
30//! new stream) can share it, and `ReceiverStream` does not compose
31//! with that. A bespoke state machine is the only way to keep the
32//! existing `recv_*` API working alongside a `Stream` API.
33
34use core::future::Future;
35use core::marker::PhantomData;
36use core::pin::Pin;
37use core::task::{Context, Poll};
38use std::sync::Arc;
39
40use futures_core::Stream;
41use tokio::sync::{Mutex, OwnedMutexGuard, mpsc};
42
43use crate::domain::acp::notification::SessionNotification;
44
45use super::agent::AgentRequest;
46
47/// A `Stream` of [`SessionNotification`]s the agent streams to the
48/// client.
49///
50/// Returned by [`AcpStdioPeer::notifications`](super::peer::AcpStdioPeer::notifications).
51/// The stream owns a clone of the peer's notification-receiver mutex
52/// and re-acquires it on every poll; the lock guard is held across
53/// `Pending` polls so another task cannot race in and reorder
54/// notifications.
55///
56/// `&self` (not `&mut self`) and `+ '_` so the consumer can hold the
57/// peer by `Arc` and call `notifications()` from any task without
58/// re-entering a `&mut self` borrow — the same shape
59/// [`recv_notification`](super::peer::AcpStdioPeer::recv_notification)
60/// supports, but as a `Stream`.
61///
62/// # Cancel safety
63///
64/// `Stream::poll_next` is cancel-safe in the same way `Receiver::recv`
65/// is: a `select!` that polls this stream and another branch is
66/// guaranteed to either return the notification or hand the lock back
67/// unchanged, never a partially-consumed channel.
68///
69/// # Examples
70///
71/// ```ignore
72/// use futures_util::StreamExt;
73/// let mut stream = peer.notifications();
74/// while let Some(notification) = stream.next().await {
75///     render(notification);
76/// }
77/// ```
78pub struct NotificationStream<'a> {
79    /// The shared lock that guards the receiver. Cloned from the peer
80    /// at construction; the peer's own `recv_notification` method
81    /// holds the same `Arc`, so a stream and the `recv_*` method are
82    /// mutually exclusive inside one task.
83    rx: Arc<Mutex<mpsc::Receiver<SessionNotification>>>,
84    /// The state machine. The three states are listed in the module
85    /// docs.
86    state: NotificationState,
87    /// The peer's lifetime. The stream holds a clone of the peer's
88    /// `Arc<Mutex>`, not a borrow of the peer, so the lifetime is
89    /// structural — the marker is for the documentation claim and
90    /// for any future borrow-style refactor.
91    _marker: PhantomData<&'a ()>,
92}
93
94/// The three states a [`NotificationStream`] can be in.
95///
96/// Variants are `Box`-pinned where they hold a future, because the
97/// future inside `Locking` is `dyn Future + Send` and the boxed pin
98/// gives it a stable address for the waker to register against.
99enum NotificationState {
100    /// The stream has not yet acquired the lock, or has just dropped
101    /// its guard because the receiver returned `None`. The first
102    /// `poll_next` enters `Locking`.
103    Idle,
104    /// Waiting to acquire the mutex. The future is owned (not
105    /// borrowed) so the stream can move it across the `Idle →
106    /// Locking → Ready` cycle.
107    Locking(
108        Pin<Box<dyn Future<Output = OwnedMutexGuard<mpsc::Receiver<SessionNotification>>> + Send>>,
109    ),
110    /// Lock held; the receiver is the one to poll.
111    Ready(OwnedMutexGuard<mpsc::Receiver<SessionNotification>>),
112}
113
114impl NotificationStream<'_> {
115    /// Build a stream over the peer's notification channel. The
116    /// `Arc<Mutex<Receiver>>` is cloned, not moved, so the peer keeps
117    /// its own `recv_notification` method working in parallel.
118    pub(crate) const fn new(rx: Arc<Mutex<mpsc::Receiver<SessionNotification>>>) -> Self {
119        Self {
120            rx,
121            state: NotificationState::Idle,
122            _marker: PhantomData,
123        }
124    }
125}
126
127impl core::fmt::Debug for NotificationStream<'_> {
128    /// The type only — neither the receiver nor the lock state is
129    /// worth showing. A `Debug` for a stream that prints its
130    /// internal `Pin<Box<dyn Future>>` would format the future's
131    /// internal waker and pointer, which is a leak of the executor's
132    /// representation into a log line that should be the consumer's
133    /// view of the stream.
134    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
135        formatter
136            .debug_struct("NotificationStream")
137            .finish_non_exhaustive()
138    }
139}
140
141impl core::fmt::Debug for AgentRequestStream<'_> {
142    /// See [`NotificationStream`]'s `Debug` — the same reasoning
143    /// applies to the agent-request stream.
144    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
145        formatter
146            .debug_struct("AgentRequestStream")
147            .finish_non_exhaustive()
148    }
149}
150
151impl Stream for NotificationStream<'_> {
152    type Item = SessionNotification;
153
154    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
155        // The state machine is `Idle → Locking → Ready` and back to
156        // the top of the loop. Each pass through the body holds a
157        // `&mut self` borrow only for the duration of the `match`
158        // arm — when an arm falls through to the next iteration the
159        // previous borrow has already been released, so the next
160        // `self.as_mut().get_mut()` reborrows from scratch. That is
161        // what keeps `significant_drop_tightening` happy: no single
162        // `let this = self.get_mut()` lives for the whole loop.
163        loop {
164            match self.as_mut().get_mut().step(cx) {
165                PollStep::Progress => {}
166                PollStep::Pending => return Poll::Pending,
167                PollStep::Ready(item) => return Poll::Ready(item),
168            }
169        }
170    }
171}
172
173/// The advance signal returned by a per-state `step` helper. A
174/// loop-driver turns `Progress` into another iteration, `Pending`
175/// into `Poll::Pending`, and `Ready` into the corresponding
176/// `Poll::Ready`.
177enum PollStep<T> {
178    /// The state machine advanced (e.g. `Idle` → `Locking` or
179    /// `Locking` → `Ready`); loop again.
180    Progress,
181    /// A future registered the waker and is not yet ready; return
182    /// `Poll::Pending`.
183    Pending,
184    /// The receiver returned a value or signalled closure.
185    Ready(Option<T>),
186}
187
188impl NotificationStream<'_> {
189    /// One step of the state machine. The stream's
190    /// `poll_next` calls this in a loop so each `&mut self` borrow
191    /// only lives for the duration of one `match` arm.
192    fn step(&mut self, cx: &mut Context<'_>) -> PollStep<SessionNotification> {
193        match &mut self.state {
194            NotificationState::Idle => {
195                let fut = self.rx.clone().lock_owned();
196                self.state = NotificationState::Locking(Box::pin(fut));
197                PollStep::Progress
198            }
199            NotificationState::Locking(fut) => match fut.as_mut().poll(cx) {
200                Poll::Ready(guard) => {
201                    self.state = NotificationState::Ready(guard);
202                    PollStep::Progress
203                }
204                Poll::Pending => PollStep::Pending,
205            },
206            NotificationState::Ready(guard) => match guard.poll_recv(cx) {
207                Poll::Ready(item) => {
208                    // Drop the guard before the next iteration so the
209                    // next `Idle` arm re-acquires the lock cleanly.
210                    // Releasing between values matches what
211                    // `recv_notification` does today: the guard goes
212                    // out of scope at the end of that function.
213                    self.state = NotificationState::Idle;
214                    PollStep::Ready(item)
215                }
216                Poll::Pending => PollStep::Pending,
217            },
218        }
219    }
220}
221
222/// A `Stream` of [`AgentRequest`]s the agent sends to the client.
223///
224/// Returned by
225/// [`AcpStdioPeer::agent_requests`](super::peer::AcpStdioPeer::agent_requests).
226/// The shape is the same as [`NotificationStream`], with the
227/// agent-request channel in place of the notification channel. The
228/// agent's `fs/read_text_file`, `fs/write_text_file`,
229/// `terminal/create`, `terminal/output`, `terminal/release`,
230/// `terminal/wait_for_exit`, `terminal/kill`, and
231/// `session/request_permission` requests flow through this stream.
232pub struct AgentRequestStream<'a> {
233    rx: Arc<Mutex<mpsc::Receiver<AgentRequest>>>,
234    state: AgentRequestState,
235    _marker: PhantomData<&'a ()>,
236}
237
238enum AgentRequestState {
239    Idle,
240    Locking(Pin<Box<dyn Future<Output = OwnedMutexGuard<mpsc::Receiver<AgentRequest>>> + Send>>),
241    Ready(OwnedMutexGuard<mpsc::Receiver<AgentRequest>>),
242}
243
244impl AgentRequestStream<'_> {
245    /// Build a stream over the peer's agent-request channel. See
246    /// [`NotificationStream::new`] for the lifetime / ownership
247    /// contract — the two are structurally identical.
248    pub(crate) const fn new(rx: Arc<Mutex<mpsc::Receiver<AgentRequest>>>) -> Self {
249        Self {
250            rx,
251            state: AgentRequestState::Idle,
252            _marker: PhantomData,
253        }
254    }
255}
256
257impl Stream for AgentRequestStream<'_> {
258    type Item = AgentRequest;
259
260    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
261        // See [`NotificationStream::poll_next`] — the same shape and
262        // the same `significant_drop_tightening` reason. Each pass
263        // through the body holds a `&mut self` borrow only for the
264        // duration of one `match` arm.
265        loop {
266            match self.as_mut().get_mut().step(cx) {
267                PollStep::Progress => {}
268                PollStep::Pending => return Poll::Pending,
269                PollStep::Ready(item) => return Poll::Ready(item),
270            }
271        }
272    }
273}
274
275impl AgentRequestStream<'_> {
276    /// One step of the state machine. See
277    /// [`NotificationStream::step`] for the rationale — the two
278    /// streams are structurally identical.
279    fn step(&mut self, cx: &mut Context<'_>) -> PollStep<AgentRequest> {
280        match &mut self.state {
281            AgentRequestState::Idle => {
282                let fut = self.rx.clone().lock_owned();
283                self.state = AgentRequestState::Locking(Box::pin(fut));
284                PollStep::Progress
285            }
286            AgentRequestState::Locking(fut) => match fut.as_mut().poll(cx) {
287                Poll::Ready(guard) => {
288                    self.state = AgentRequestState::Ready(guard);
289                    PollStep::Progress
290                }
291                Poll::Pending => PollStep::Pending,
292            },
293            AgentRequestState::Ready(guard) => match guard.poll_recv(cx) {
294                Poll::Ready(item) => {
295                    self.state = AgentRequestState::Idle;
296                    PollStep::Ready(item)
297                }
298                Poll::Pending => PollStep::Pending,
299            },
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    //! The state machine is straightforward enough that the unit
307    //! tests live next to the type and exercise the three
308    //! observable shapes: the lock future advances under contention,
309    //! the stream yields values that were already in the channel,
310    //! and the stream returns `None` when the sender is dropped.
311    //!
312    //! The integration test in `tests/acp_stdio_interop.rs` covers
313    //! the cross-process shape (a real agent on the other end of
314    //! the pipe). What is here is the in-process state-machine
315    //! correctness: the lock guard survives across `Pending` polls,
316    //! the channel closes when the sender is dropped, and a second
317    //! stream over the same `Arc` does not deadlock against itself.
318    use super::*;
319    use crate::domain::acp::content::{ContentBlock, TextContent};
320    use crate::domain::acp::notification::SessionUpdate;
321    use crate::domain::acp::session::SessionId;
322    use futures_util::StreamExt;
323    use std::collections::BTreeMap;
324
325    fn text_chunk(text: &str) -> SessionUpdate {
326        SessionUpdate::UserMessageChunk(crate::domain::acp::notification::ContentChunk {
327            content: ContentBlock::Text(TextContent {
328                text: text.to_owned(),
329                annotations: None,
330                meta: BTreeMap::new(),
331            }),
332            message_id: None,
333            meta: BTreeMap::new(),
334        })
335    }
336
337    fn notification(text: &str) -> SessionNotification {
338        SessionNotification {
339            session_id: SessionId::new("test-session"),
340            update: text_chunk(text),
341        }
342    }
343
344    #[tokio::test]
345    async fn yields_values_already_in_the_channel() {
346        // The simplest path: a sender drops after a single value;
347        // the stream returns `Some(value)` then `None`.
348        let (tx, rx) = mpsc::channel::<SessionNotification>(4);
349        let rx = Arc::new(Mutex::new(rx));
350        let send_result = tx.send(notification("hello")).await;
351        assert!(
352            send_result.is_ok(),
353            "the channel is open at the time of send"
354        );
355        drop(tx);
356
357        let mut stream = NotificationStream::new(rx);
358        let first = stream.next().await;
359        assert!(first.is_some(), "the queued value must surface");
360        let second = stream.next().await;
361        assert!(
362            second.is_none(),
363            "the channel is closed after the sender drops"
364        );
365    }
366
367    #[tokio::test]
368    async fn returns_none_when_sender_already_dropped() {
369        // The empty-channel-closed path: drop the sender before
370        // polling. The stream must not block.
371        let (tx, rx) = mpsc::channel::<SessionNotification>(4);
372        drop(tx);
373        let rx = Arc::new(Mutex::new(rx));
374        let mut stream = NotificationStream::new(rx);
375        assert!(stream.next().await.is_none());
376    }
377
378    #[tokio::test]
379    async fn a_second_stream_does_not_deadlock_against_itself() {
380        // Two streams over the same `Arc<Mutex<Receiver>>` contend
381        // for the same lock. One wins; the other parks until the
382        // first drops its guard. The expectation is that the second
383        // wakes up and observes the closed channel.
384        let (tx, rx) = mpsc::channel::<SessionNotification>(4);
385        let rx = Arc::new(Mutex::new(rx));
386        let send_result = tx.send(notification("hello")).await;
387        assert!(
388            send_result.is_ok(),
389            "the channel is open at the time of send"
390        );
391        drop(tx);
392
393        let mut first = NotificationStream::new(Arc::clone(&rx));
394        let mut second = NotificationStream::new(Arc::clone(&rx));
395        // The first stream takes the only value; the second
396        // observes the channel close.
397        assert!(first.next().await.is_some());
398        assert!(second.next().await.is_none());
399    }
400
401    #[tokio::test]
402    async fn agent_request_stream_round_trip() {
403        // The agent-request stream uses the same state machine, so
404        // the same shape of test covers it.
405        let (tx, rx) = mpsc::channel::<AgentRequest>(4);
406        let rx = Arc::new(Mutex::new(rx));
407        let send_result = tx
408            .send(AgentRequest {
409                id: serde_json::json!(1),
410                method: "fs/read_text_file".to_owned(),
411                params: serde_json::json!({}),
412            })
413            .await;
414        assert!(
415            send_result.is_ok(),
416            "the channel is open at the time of send"
417        );
418        drop(tx);
419
420        let mut stream = AgentRequestStream::new(rx);
421        let first = stream.next().await;
422        let method = match first {
423            Some(request) => request.method,
424            None => String::new(),
425        };
426        assert_eq!(method, "fs/read_text_file");
427        assert!(stream.next().await.is_none());
428    }
429}