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