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}