Skip to main content

wcode_protocol/
client.rs

1//! A remote client: the [`SessionHandle`] API over a socket.
2//!
3//! The client demuxes the connection the same way the actor demuxes its
4//! channels: a supervisor writes queued requests and reads inbound frames,
5//! routing *streaming* events by `Frame::session` to [`Client::subscribe`] and
6//! *replies* to [`Client::ask`] (matched by the envelope's `reply_to`). A
7//! [`Client`] is a per-session **view** over one connection: [`Client::with_session`]
8//! narrows it to one session, while [`Client::connect`]/[`Client::lazy`] is the
9//! legacy connection-wide view. So a caller cannot tell a [`Client`] from a
10//! [`SessionHandle`] — which is the whole point of [`crate::Backend`].
11//!
12//! It also **reconnects**: if the connection drops (a server restart, a blip),
13//! the supervisor re-establishes it with backoff and keeps going. Requests
14//! queued during the gap are delivered once it is back. An in-flight `ask`
15//! whose reply was lost to the drop fails with [`Closed`] — the reply is simply
16//! not coming — and the caller (or the user) retries.
17//!
18//! [`SessionHandle`]: wcode_harness::actor::SessionHandle
19
20use std::collections::HashMap;
21use std::io;
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::{Arc, Mutex, OnceLock, Weak};
25use std::time::Duration;
26
27use tokio::io::BufReader;
28use tokio::net::UnixStream;
29use tokio::sync::{broadcast, mpsc, oneshot, watch};
30use wcode_harness::actor::EVENT_BUFFER;
31use wcode_harness::event::AgentEvent;
32use wcode_harness::protocol::{Frame, PROTOCOL_VERSION, Request, SessionId, SessionInfo};
33
34use crate::Closed;
35use crate::frame::{read_frame, write_frame};
36
37/// Reconnect backoff: first wait, doubling up to the cap.
38const RECONNECT_BASE: Duration = Duration::from_millis(200);
39const RECONNECT_CAP: Duration = Duration::from_secs(5);
40
41/// The wire placeholder an unnarrowed view addresses: a single-session server
42/// routes any id to its sole session, so `connect`/`lazy` reach it without
43/// knowing the served id.
44const DEFAULT_SESSION: &str = "remote";
45
46/// Default bound for [`Client::flush`] and [`flush_all`]: long enough to drain a
47/// healthy socket, short enough that an unreachable or reconnecting peer never
48/// stalls a one-shot exit.
49pub const FLUSH_TIMEOUT: Duration = Duration::from_millis(2000);
50
51/// One item on a connection's outbound queue: a request to write, or a **flush
52/// barrier** that completes once every earlier frame has been written. The
53/// barrier is client-local — it is never sent to the server.
54// A `Frame` is much larger than a barrier, but boxing every request would add an
55// allocation to the fire-and-forget hot path; the variant skew is harmless here.
56#[allow(clippy::large_enum_variant)]
57enum Out {
58    Frame(Frame<Request>),
59    Flush(oneshot::Sender<()>),
60}
61
62/// Every live connection this process holds, as weak handles so a dropped
63/// [`Client`] is pruned. Populated in [`Client::adopt`]; drained by [`flush_all`].
64static CONNECTIONS: OnceLock<Mutex<Vec<Weak<Inner>>>> = OnceLock::new();
65
66fn connections() -> &'static Mutex<Vec<Weak<Inner>>> {
67    CONNECTIONS.get_or_init(|| Mutex::new(Vec::new()))
68}
69
70fn register(inner: &Arc<Inner>) {
71    let mut list = connections().lock().unwrap();
72    list.retain(|handle| handle.strong_count() > 0);
73    list.push(Arc::downgrade(inner));
74}
75
76/// Best-effort flush of **every** live connection this process holds, so queued
77/// fire-and-forget frames are written before a one-shot exit drops the runtime.
78/// Each connection is bounded by `timeout` and they run concurrently, so the
79/// call returns within ~`timeout` even if some peers are unreachable. The hot
80/// path ([`Client::send`]/[`Client::send_from`]) stays fire-and-forget.
81pub async fn flush_all(timeout: Duration) {
82    let inners: Vec<Arc<Inner>> = {
83        let mut list = connections().lock().unwrap();
84        list.retain(|handle| handle.strong_count() > 0);
85        list.iter().filter_map(Weak::upgrade).collect()
86    };
87    let mut handles = Vec::with_capacity(inners.len());
88    for inner in inners {
89        handles.push(tokio::spawn(async move { inner.flush(timeout).await }));
90    }
91    for handle in handles {
92        let _ = handle.await;
93    }
94}
95
96/// A connection to a remote session, reconnecting automatically if it drops.
97///
98/// A clone is a **view**: it shares the connection and request queue but may
99/// address a different session ([`Client::with_session`]).
100#[derive(Clone)]
101pub struct Client {
102    inner: Arc<Inner>,
103    /// The session this view addresses; `None` is the connection-wide view.
104    session: Option<SessionId>,
105}
106
107struct Inner {
108    path: PathBuf,
109    /// Per-session event fans, created on first use — a [`Client::with_session`]
110    /// view hears only its own session's.
111    events: Mutex<HashMap<SessionId, broadcast::Sender<AgentEvent>>>,
112    /// The connection-wide fan: the legacy view (`connect`/`lazy`) hears every
113    /// session's events, exactly as the single-session client did before
114    /// multiplexing.
115    all_events: broadcast::Sender<AgentEvent>,
116    /// The connection-wide roster of served sessions, updated when the server
117    /// pushes an unsolicited `AgentEvent::Sessions` frame (a session registered
118    /// at runtime). Shared across every view — see [`Client::subscribe_roster`].
119    roster: watch::Sender<Vec<SessionInfo>>,
120    /// Reply waiters, keyed by request id.
121    pending: Mutex<HashMap<u64, oneshot::Sender<AgentEvent>>>,
122    next_id: AtomicU64,
123    /// Requests (and flush barriers) queued for the supervisor, drained across
124    /// reconnects. On `Inner` — shared by every view — so [`flush_all`] reaches a
125    /// connection through its weak handle.
126    out: mpsc::UnboundedSender<Out>,
127}
128
129impl Client {
130    /// Connect to a server listening at `path`.
131    ///
132    /// The first connect is eager — `Err` if nothing is listening — but after
133    /// that the client reconnects on its own if the connection drops.
134    pub async fn connect(path: &Path) -> io::Result<Client> {
135        let stream = crate::socket::connect(path).await?;
136        Ok(Client::adopt(path.to_path_buf(), Some(stream)))
137    }
138
139    /// Connect **lazily**: never fails. The supervisor keeps trying `path` in the
140    /// background, so a peer that is not up yet (or restarts) is reached once it
141    /// appears — the right mode for an A2A peer (`--peer`/`[peers]`), where two
142    /// sessions may each be waiting for the other.
143    pub fn lazy(path: &Path) -> Client {
144        Client::adopt(path.to_path_buf(), None)
145    }
146
147    fn adopt(path: PathBuf, stream: Option<UnixStream>) -> Client {
148        let (out, out_rx) = mpsc::unbounded_channel::<Out>();
149        let inner = Arc::new(Inner {
150            path,
151            events: Mutex::new(HashMap::new()),
152            all_events: broadcast::channel(EVENT_BUFFER).0,
153            roster: watch::channel(Vec::new()).0,
154            pending: Mutex::new(HashMap::new()),
155            next_id: AtomicU64::new(1),
156            out,
157        });
158        register(&inner);
159        tokio::spawn(supervise(Arc::downgrade(&inner), out_rx, stream));
160        Client {
161            inner,
162            session: None,
163        }
164    }
165
166    /// A per-session view over the same connection: a cheap clone sharing the
167    /// `Inner` and request queue but addressed to `session`. Its
168    /// [`Client::subscribe`] hears only `session`'s events; replies are matched
169    /// by `reply_to` and are independent of the view.
170    ///
171    /// To hear a session's stream, `session` must be the id the server actually
172    /// **serves** (the id it stamps outbound frames with — learn it from
173    /// [`Request::ListSessions`]). A view narrowed to any other id hears
174    /// nothing; over a single-session server, where the served id is not
175    /// otherwise known, only the connection-wide view (`connect`/`lazy`) is safe.
176    pub fn with_session(&self, session: SessionId) -> Client {
177        Client {
178            inner: self.inner.clone(),
179            session: Some(session),
180        }
181    }
182
183    /// Fire-and-forget: queue a request. Streaming events (and the eventual
184    /// reply) arrive on the subscription from [`Client::subscribe`]. Fails only
185    /// once every clone has dropped and the supervisor has ended.
186    pub fn send(&self, request: Request) -> Result<(), Closed> {
187        let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
188        self.inner
189            .out
190            .send(Out::Frame(self.frame(id, None, request)))
191            .map_err(|_| Closed)
192    }
193
194    /// Fire-and-forget, attributing the request to a remote `from` address
195    /// (A2A). The server feeds `from` into the target's `before_inbound` and its
196    /// sender tag, exactly as an in-process `send_from` does.
197    pub fn send_from(&self, from: SessionId, request: Request) -> Result<(), Closed> {
198        let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
199        self.inner
200            .out
201            .send(Out::Frame(self.frame(id, Some(from), request)))
202            .map_err(|_| Closed)
203    }
204
205    /// Send a request and await its correlated reply. Fails with [`Closed`] if
206    /// the connection drops before the reply arrives.
207    pub async fn ask(&self, request: Request) -> Result<AgentEvent, Closed> {
208        let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
209        let (tx, rx) = oneshot::channel();
210        self.inner.pending.lock().unwrap().insert(id, tx);
211        if self
212            .inner
213            .out
214            .send(Out::Frame(self.frame(id, None, request)))
215            .is_err()
216        {
217            self.inner.pending.lock().unwrap().remove(&id);
218            return Err(Closed);
219        }
220        rx.await.map_err(|_| Closed)
221    }
222
223    /// Stream the events this view hears: its own session's, or — for the
224    /// connection-wide view (`connect`/`lazy`) — every session's. Each subscriber
225    /// gets its own receiver, which survives a reconnect (only events during the
226    /// gap are lost).
227    ///
228    /// The view must address the **served** id to hear its stream (see
229    /// [`Client::with_session`]).
230    pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
231        self.inner.sender(self.session.as_ref()).subscribe()
232    }
233
234    /// A live view of the **connection-wide** roster of served sessions,
235    /// **seeded** with the current value. The server pushes an unsolicited
236    /// [`AgentEvent::Sessions`] frame when the roster grows (a session
237    /// registered at runtime); this receiver yields the new list. It is
238    /// connection-wide, *not* per-session: every view over this connection
239    /// shares it, so an unnarrowed and a `with_session` view see the same ids.
240    pub fn subscribe_roster(&self) -> watch::Receiver<Vec<SessionInfo>> {
241        self.inner.roster.subscribe()
242    }
243
244    /// Best-effort barrier: resolves once every frame queued **before** this call
245    /// has been written to the socket — the queue is FIFO and the supervisor
246    /// writes in order — or after `timeout` if the peer is unreachable or
247    /// reconnecting, so a one-shot exit flushes its fire-and-forget deliveries
248    /// without hanging. [`flush_all`] flushes every connection at once.
249    pub async fn flush(&self, timeout: Duration) {
250        self.inner.flush(timeout).await;
251    }
252
253    fn frame(&self, id: u64, sender: Option<SessionId>, body: Request) -> Frame<Request> {
254        Frame {
255            v: PROTOCOL_VERSION,
256            id,
257            reply_to: None,
258            session: self
259                .session
260                .clone()
261                .unwrap_or_else(|| SessionId::new(DEFAULT_SESSION)),
262            sender,
263            body,
264        }
265    }
266}
267
268impl Inner {
269    /// The event fan for a view: the connection-wide one for an unnarrowed view,
270    /// else the (lazily created) fan for that session. Kept in the shared `Inner`,
271    /// not on a view, so subscribers survive a reconnect — only events during the
272    /// gap are lost.
273    fn sender(&self, session: Option<&SessionId>) -> broadcast::Sender<AgentEvent> {
274        match session {
275            None => self.all_events.clone(),
276            Some(session) => self
277                .events
278                .lock()
279                .unwrap()
280                .entry(session.clone())
281                .or_insert_with(|| broadcast::channel(EVENT_BUFFER).0)
282                .clone(),
283        }
284    }
285
286    /// Enqueue a flush barrier and await it, bounded by `timeout`. Best-effort: a
287    /// timeout or a closed queue simply returns.
288    async fn flush(&self, timeout: Duration) {
289        let (tx, rx) = oneshot::channel();
290        if self.out.send(Out::Flush(tx)).is_err() {
291            return;
292        }
293        let _ = tokio::time::timeout(timeout, rx).await;
294    }
295}
296
297/// Own the connection: write queued requests and read events, reconnecting on
298/// drop until every [`Client`] clone is gone. Holds only a [`Weak`] reference to
299/// [`Inner`], so it never pins a connection to life: the last `Client` dropping
300/// ends the queue (the sender on `Inner` drops, so `out.recv()` reaches `None`)
301/// and this task (the upgrade fails, including in the reconnect/backoff loop).
302async fn supervise(
303    inner: Weak<Inner>,
304    mut out: mpsc::UnboundedReceiver<Out>,
305    mut next: Option<UnixStream>,
306) {
307    let mut backoff = RECONNECT_BASE;
308    loop {
309        // Never hold a strong reference across an await: a dropped `Client` must
310        // be able to end this task.
311        let path = match inner.upgrade() {
312            Some(inner) => inner.path.clone(),
313            None => return,
314        };
315        let stream = match next.take() {
316            Some(stream) => stream,
317            None => match crate::socket::connect(&path).await {
318                Ok(stream) => stream,
319                Err(_) => {
320                    tokio::time::sleep(backoff).await;
321                    backoff = (backoff * 2).min(RECONNECT_CAP);
322                    continue;
323                }
324            },
325        };
326        backoff = RECONNECT_BASE;
327
328        let (read, mut write) = stream.into_split();
329        let mut read = BufReader::new(read);
330        loop {
331            tokio::select! {
332                item = out.recv() => match item {
333                    Some(Out::Frame(frame)) => {
334                        if write_frame(&mut write, &frame).await.is_err() {
335                            break;
336                        }
337                    }
338                    // FIFO: reaching a barrier means every earlier frame was
339                    // written, so completing it is a real flush.
340                    Some(Out::Flush(done)) => {
341                        let _ = done.send(());
342                    }
343                    // Every clone dropped: nothing left to serve.
344                    None => return,
345                },
346                event = read_frame::<_, AgentEvent>(&mut read) => match event {
347                    Ok(Some(frame)) => match inner.upgrade() {
348                        Some(inner) => deliver(&inner, frame),
349                        None => return,
350                    },
351                    // EOF or a broken read: the connection is gone.
352                    _ => break,
353                },
354            }
355        }
356        // The connection dropped: fail every in-flight `ask` (its reply is not
357        // coming), then loop to reconnect.
358        match inner.upgrade() {
359            Some(inner) => inner.pending.lock().unwrap().clear(),
360            None => return,
361        }
362    }
363}
364
365fn deliver(inner: &Inner, frame: Frame<AgentEvent>) {
366    match frame.reply_to {
367        Some(id) => {
368            if let Some(tx) = inner.pending.lock().unwrap().remove(&id) {
369                let _ = tx.send(frame.body);
370            }
371        }
372        None => {
373            // An unsolicited `Sessions` frame is the server's roster push (a
374            // session registered at runtime): record it connection-wide, so a
375            // `subscribe_roster` view learns the new id without polling.
376            if let AgentEvent::Sessions { sessions } = &frame.body {
377                let _ = inner.roster.send(sessions.clone());
378            }
379            // Ids are unique per connection (a single `next_id` atomic), so a
380            // reply and a streamed event never collide — no re-namespacing is
381            // needed. Route the event to its own session's fan *and* the
382            // connection-wide fan, so the legacy view still hears it all.
383            let _ = inner.all_events.send(frame.body.clone());
384            let _ = inner.sender(Some(&frame.session)).send(frame.body);
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn inner_with_out() -> (Inner, mpsc::UnboundedReceiver<Out>) {
394        let (out, out_rx) = mpsc::unbounded_channel();
395        let inner = Inner {
396            path: PathBuf::from("/nonexistent"),
397            events: Mutex::new(HashMap::new()),
398            all_events: broadcast::channel(EVENT_BUFFER).0,
399            roster: watch::channel(Vec::new()).0,
400            pending: Mutex::new(HashMap::new()),
401            next_id: AtomicU64::new(1),
402            out,
403        };
404        (inner, out_rx)
405    }
406
407    fn inner() -> Inner {
408        inner_with_out().0
409    }
410
411    /// The registered weak handle whose connection has this unique path (so a
412    /// concurrently-running test's client is never mistaken for ours).
413    #[cfg(unix)]
414    fn weak_with_path(path: &Path) -> Option<Weak<Inner>> {
415        connections()
416            .lock()
417            .unwrap()
418            .iter()
419            .find(|handle| handle.upgrade().is_some_and(|inner| inner.path.as_path() == path))
420            .cloned()
421    }
422
423    fn stream_frame(session: SessionId, body: AgentEvent) -> Frame<AgentEvent> {
424        Frame {
425            v: PROTOCOL_VERSION,
426            id: 0,
427            reply_to: None,
428            session,
429            sender: None,
430            body,
431        }
432    }
433
434    #[tokio::test]
435    async fn deliver_splits_replies_from_streamed_events() {
436        let inner = inner();
437        // A reply resolves the matching waiter and is never streamed.
438        let (tx, rx) = oneshot::channel();
439        inner.pending.lock().unwrap().insert(7, tx);
440        let mut stream = inner.sender(None).subscribe();
441        deliver(
442            &inner,
443            Frame {
444                v: PROTOCOL_VERSION,
445                id: 7,
446                reply_to: Some(7),
447                session: SessionId::new("s"),
448                sender: None,
449                body: AgentEvent::Ack,
450            },
451        );
452        assert!(matches!(rx.await.unwrap(), AgentEvent::Ack));
453        assert!(stream.try_recv().is_err(), "a reply is not streamed");
454    }
455
456    #[tokio::test]
457    async fn deliver_routes_a_streamed_event_by_session() {
458        let inner = inner();
459        let a = SessionId::agent("a");
460        let b = SessionId::agent("b");
461        let mut ra = inner.sender(Some(&a)).subscribe();
462        let mut rb = inner.sender(Some(&b)).subscribe();
463        let mut all = inner.sender(None).subscribe();
464        deliver(&inner, stream_frame(a.clone(), AgentEvent::AgentEnd));
465        assert!(matches!(ra.try_recv().unwrap(), AgentEvent::AgentEnd));
466        assert!(matches!(all.try_recv().unwrap(), AgentEvent::AgentEnd));
467        assert!(rb.try_recv().is_err(), "b did not hear a's event");
468    }
469
470    /// A dropped `Client` must end its supervisor. The supervisor holds only a
471    /// `Weak<Inner>`, so the last strong reference dropping makes it return — a
472    /// strong reference would pin it retrying the dead path forever (the queue
473    /// sender lives on `Inner`, so `out.recv()` would never reach `None`).
474    #[tokio::test]
475    async fn the_supervisor_exits_when_the_last_client_drops() {
476        let (inner, out_rx) = inner_with_out();
477        let inner = Arc::new(inner);
478        let supervisor = tokio::spawn(supervise(Arc::downgrade(&inner), out_rx, None));
479
480        // It runs (retrying the dead path) while the client is alive...
481        tokio::time::sleep(Duration::from_millis(50)).await;
482        assert!(
483            !supervisor.is_finished(),
484            "the supervisor runs while a client holds the connection"
485        );
486
487        // ...and returns as soon as the last strong reference is dropped.
488        drop(inner);
489        tokio::time::timeout(Duration::from_secs(2), supervisor)
490            .await
491            .expect("the supervisor returns once the last client drops")
492            .expect("the supervisor task did not panic");
493    }
494
495    /// And through the real constructor: `Client::lazy`'s registered weak dies
496    /// with the client, so `flush_all`'s pruning sees no live entry (and the
497    /// supervisor, holding no strong reference, tears the connection down).
498    #[cfg(unix)]
499    #[tokio::test]
500    async fn a_dropped_client_leaves_no_live_connection() {
501        let path = PathBuf::from("/nonexistent-teardown-xyz.sock");
502        let client = Client::lazy(&path);
503        // Let the supervisor actually start (it upgrades `Inner` when it runs) —
504        // otherwise this would not exercise the pin.
505        tokio::time::sleep(Duration::from_millis(50)).await;
506        let weak = weak_with_path(&path).expect("the lazy client registered its connection");
507        assert!(weak.upgrade().is_some(), "registered while the client lives");
508
509        drop(client);
510        let mut dead = false;
511        for _ in 0..200 {
512            if weak.upgrade().is_none() {
513                dead = true;
514                break;
515            }
516            tokio::time::sleep(Duration::from_millis(10)).await;
517        }
518        assert!(
519            dead,
520            "a dropped client leaves no live connection (the supervisor released it)"
521        );
522    }
523}