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}