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}