Skip to main content

wcode_protocol/
server.rs

1//! Serve sessions over a socket: accept connections, bridge frames ↔ handles.
2//!
3//! Each connection gets a writer draining an outbound queue, one **fan** task per
4//! served session (forwarding that session's events, each frame stamped with its
5//! origin session), and the read loop answering inbound requests. A request is
6//! demuxed to the handle named by its `Frame::session`; a single-session server
7//! also accepts any id (the legacy default, so a `--socket` client's placeholder
8//! still reaches it). Requests are answered with the actor's reply (correlated by
9//! the envelope's `reply_to`), so a client's `ask` resolves even while streamed
10//! events flow on the same connection.
11//!
12//! The roster is **live**: beside an explicit root (served under its own session
13//! id, distinct from the registry's `agent:orchestrator` alias), the server
14//! follows a [`watch::Receiver`] of the extra sessions. A session registered
15//! after the server started — a worker a running orchestrator spawns — is fanned
16//! to every connection, and the grown roster is **pushed** as an unsolicited
17//! [`AgentEvent::Sessions`] frame, so a client learns new ids without polling.
18
19use std::collections::HashSet;
20use std::io;
21use std::path::Path;
22
23use tokio::io::{AsyncRead, AsyncWrite, BufReader, BufWriter};
24#[cfg(unix)]
25use tokio::net::UnixListener;
26use tokio::sync::{broadcast, mpsc, watch};
27use wcode_harness::actor::SessionHandle;
28use wcode_harness::event::AgentEvent;
29use wcode_harness::protocol::{Frame, PROTOCOL_VERSION, Request, SessionId, SessionInfo};
30
31use crate::Registry;
32use crate::frame::{read_frame, write_frame};
33
34/// The live roster of *extra* sessions served beside the root — a session is
35/// `(SessionId, SessionHandle)`. Empty means "root only"; the value is refreshed
36/// by a [`crate::Registry`] as sessions are registered.
37pub type Roster = watch::Receiver<Vec<(SessionId, SessionHandle)>>;
38
39/// A served session: the id every outbound frame carrying it is stamped with,
40/// and the handle the server reads events from / writes requests to.
41pub type Root = (SessionId, SessionHandle);
42
43/// The arguments of a [`wcode_harness::protocol::Request::Define`], passed to a
44/// [`DefineHandler`]. Mirrors the CLI's worker spec.
45#[derive(Clone, Debug, Default)]
46pub struct DefineArgs {
47    /// The worker's address; auto-assigned when absent.
48    pub name: Option<String>,
49    /// Model id override; `None` inherits the server's model.
50    pub model: Option<String>,
51    /// Role text appended to the worker's system prompt.
52    pub role: Option<String>,
53    /// Tool allow-list; `None` = the full default set.
54    pub tools: Option<Vec<String>>,
55    /// Provider base URL override; `None` inherits the server's.
56    pub base_url: Option<String>,
57    /// Provider API key override; `None` inherits the server's.
58    pub api_key: Option<String>,
59    /// Enforce read-only for the defined worker (D1/D2).
60    pub read_only: bool,
61    /// Reasoning-effort override (D3); `None` inherits, synonyms clear.
62    pub effort: Option<String>,
63}
64
65/// Builds a worker from [`DefineArgs`] and returns its address. A socket server
66/// uses it to answer [`wcode_harness::protocol::Request::Define`]; the
67/// composition root injects one, since this crate cannot name a session factory.
68pub type DefineHandler =
69    std::sync::Arc<dyn Fn(DefineArgs) -> Result<SessionId, String> + Send + Sync>;
70
71/// Bind `path` and serve the root session plus `roster` until the listener
72/// errors.
73///
74/// `root` is served first and labelled by its own `SessionId` (the agent's
75/// session id — *not* the registry's `agent:orchestrator` alias). Every session
76/// named by `roster` is served too, now and as it grows. A client addresses a
77/// session by `frame.session`; a server whose *current* served set is exactly
78/// one session also accepts any id, preserving the single-session flow (the
79/// `--socket` client's placeholder `"remote"` still reaches it). `define`, when
80/// installed, answers `Request::Define` — the composition root's worker factory.
81///
82/// `registry` is consulted (read-only) for each served session's model, so the
83/// roster this server pushes names models the server actually knows.
84#[cfg(unix)]
85pub async fn serve(
86    registry: Registry,
87    roster: Roster,
88    root: Root,
89    define: Option<DefineHandler>,
90    listener: UnixListener,
91) -> io::Result<()> {
92    loop {
93        let (stream, _addr) = listener.accept().await?;
94        tokio::spawn(serve_stream(
95            registry.clone(),
96            roster.clone(),
97            root.clone(),
98            define.clone(),
99            stream,
100        ));
101    }
102}
103
104/// Bind `path`, then [`serve`].
105#[cfg(unix)]
106pub async fn serve_at(
107    registry: Registry,
108    roster: Roster,
109    root: Root,
110    define: Option<DefineHandler>,
111    path: &Path,
112) -> io::Result<()> {
113    let listener = crate::socket::bind(path).await?;
114    serve(registry, roster, root, define, listener).await
115}
116
117/// Serve the frame loop over the process's own stdin/stdout — the `--stdio`
118/// transport (`docs/vscode-extension-plan.md` §3.1). **Exactly one client**:
119/// there is no listener, so no second connection is even expressible. Returns
120/// `Ok(())` when the client's stdin reaches EOF (clean shutdown; the spawning
121/// client owns the process lifetime).
122pub async fn serve_stdio(
123    registry: Registry,
124    roster: Roster,
125    root: Root,
126    define: Option<DefineHandler>,
127) -> io::Result<()> {
128    let stream = tokio::io::join(tokio::io::stdin(), tokio::io::stdout());
129    serve_stream(registry, roster, root, define, stream).await;
130    Ok(())
131}
132
133/// Drive **one** connection over any byte stream: the frame loop (read → demux
134/// → actor), the per-session [`fan`] tasks, the live roster push, and the
135/// request replies. Transport-free — the caller supplies the bytes. Returns
136/// when the peer's stream reaches a clean EOF (`read_frame` → `Ok(None)`) or
137/// the reader/writer errors.
138///
139/// The ONE unix assumption removed: `UnixStream::into_split` becomes
140/// `tokio::io::split`, which needs only `AsyncRead + AsyncWrite`.
141pub async fn serve_stream<S>(
142    registry: Registry,
143    mut roster: Roster,
144    root: Root,
145    define: Option<DefineHandler>,
146    stream: S,
147) where
148    S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
149{
150    let (read, write) = tokio::io::split(stream);
151    let (out, out_rx) = mpsc::unbounded_channel::<Frame<AgentEvent>>();
152    tokio::spawn(write_loop(write, out_rx));
153
154    let (root_id, root_handle) = root;
155    // Every id already fanned to this connection, so a session is fanned once.
156    let mut seen: HashSet<SessionId> = HashSet::new();
157
158    // Fan the root, then every session already in the roster. `borrow_and_update`
159    // marks the current value seen, so the watch task below only reacts to
160    // changes made *after* this snapshot — no id is fanned twice and none is
161    // missed.
162    seen.insert(root_id.clone());
163    fan(&out, &root_id, &root_handle);
164    let initial = roster.borrow_and_update().clone();
165    for (id, handle) in &initial {
166        if seen.insert(id.clone()) {
167            fan(&out, id, handle);
168        }
169    }
170    // Seed the connection with the roster it starts with, so a client learns the
171    // served ids on connect — and a session registered in the window before this
172    // task first ran (already folded into the snapshot above) is not lost to the
173    // watch's "seen" mark.
174    let _ = out.send(Frame {
175        v: PROTOCOL_VERSION,
176        id: 0,
177        reply_to: None,
178        session: root_id.clone(),
179        sender: None,
180        body: AgentEvent::Sessions {
181            sessions: roster_infos(&registry, &root_id, &initial),
182        },
183    });
184
185    // The read loop needs the live roster too, for routing and `ListSessions`.
186    // A clone is a read-only view (its own seen-pointer is irrelevant); the
187    // watch task owns the original, whose seen-pointer stays at the snapshot.
188    let read_roster = roster.clone();
189
190    // Follow the roster: fan each newly-seen session once and push the grown
191    // roster to this connection, so a client learns late ids without polling.
192    {
193        let out = out.clone();
194        let root_id = root_id.clone();
195        let registry = registry.clone();
196        tokio::spawn(async move {
197            loop {
198                if roster.changed().await.is_err() {
199                    break;
200                }
201                let current = roster.borrow_and_update().clone();
202                // Push on ANY roster change, not just growth: a state-only
203                // transition (a worker goes Running) leaves `locals()` identical,
204                // so a `grew` test would miss it — but `set_state` bumped the
205                // watch, so this task ran. The payload is tiny (one `SessionInfo`
206                // per member). `fan()` is still gated on `seen.insert`, so no
207                // event is fanned twice.
208                for (id, handle) in &current {
209                    if seen.insert(id.clone()) {
210                        fan(&out, id, handle);
211                    }
212                }
213                let sessions = roster_infos(&registry, &root_id, &current);
214                let _ = out.send(Frame {
215                    v: PROTOCOL_VERSION,
216                    id: 0,
217                    reply_to: None,
218                    session: root_id.clone(),
219                    sender: None,
220                    body: AgentEvent::Sessions { sessions },
221                });
222            }
223        });
224    }
225
226    // Answer each request (in its own task, so a long run never blocks reads).
227    let mut read = BufReader::new(read);
228    while let Ok(Some(frame)) = read_frame::<_, Request>(&mut read).await {
229        let Frame {
230            id, session, sender, body, ..
231        } = frame;
232
233        // The served set: the root first, then the live roster (deduplicated).
234        let live: Vec<(SessionId, SessionHandle)> = read_roster
235            .borrow()
236            .iter()
237            .filter(|(sid, _)| sid != &root_id)
238            .cloned()
239            .collect();
240
241        // `ListSessions` names no session: the server answers it directly with
242        // the roster (root first), before any demux.
243        if matches!(body, Request::ListSessions) {
244            let sessions = roster_infos(&registry, &root_id, &live);
245            let _ = out.send(Frame {
246                v: PROTOCOL_VERSION,
247                id,
248                reply_to: Some(id),
249                session,
250                sender: None,
251                body: AgentEvent::Sessions { sessions },
252            });
253            continue;
254        }
255
256        // `ListModels` names no session either: answer it from the registry catalog
257        // (possibly EMPTY before the background fetch lands), with `reply_to`.
258        if matches!(body, Request::ListModels) {
259            let _ = out.send(Frame {
260                v: PROTOCOL_VERSION,
261                id,
262                reply_to: Some(id),
263                session,
264                sender: None,
265                body: AgentEvent::Models { models: registry.models() },
266            });
267            continue;
268        }
269
270        // `Define` names no session either: the server answers it directly, via
271        // the injected handler (or a clean error when none is installed). The
272        // handler registers into the same registry the roster watches, so the
273        // new worker is pushed to every client with no extra work.
274        if let Request::Define {
275            name,
276            model,
277            role,
278            tools,
279            base_url,
280            api_key,
281            read_only,
282            effort,
283        } = &body
284        {
285            let reply = match &define {
286                Some(handler) => match handler(DefineArgs {
287                    name: name.clone(),
288                    model: model.clone(),
289                    role: role.clone(),
290                    tools: tools.clone(),
291                    base_url: base_url.clone(),
292                    api_key: api_key.clone(),
293                    read_only: *read_only,
294                    effort: effort.clone(),
295                }) {
296                    Ok(id) => AgentEvent::Spawned { worker: id },
297                    Err(message) => AgentEvent::Error { message },
298                },
299                None => AgentEvent::Error {
300                    message: "agent definition is not enabled on this server (serve --agents)"
301                        .into(),
302                },
303            };
304            let _ = out.send(Frame {
305                v: PROTOCOL_VERSION,
306                id,
307                reply_to: Some(id),
308                session,
309                sender: None,
310                body: reply,
311            });
312            continue;
313        }
314
315        // Demux on `frame.session`: the root; a roster session of that name; the
316        // sole session of a single-session server (any id — the legacy default);
317        // or, on a multi-session server, a correlated error echoing the request.
318        let handle = if session == root_id {
319            Some(root_handle.clone())
320        } else if let Some((_, handle)) = live.iter().find(|(sid, _)| sid == &session) {
321            Some(handle.clone())
322        } else if live.is_empty() {
323            Some(root_handle.clone())
324        } else {
325            None
326        };
327        let Some(handle) = handle else {
328            let message = format!("unknown session {session}");
329            let _ = out.send(Frame {
330                v: PROTOCOL_VERSION,
331                id,
332                reply_to: Some(id),
333                session,
334                sender: None,
335                body: AgentEvent::Error { message },
336            });
337            continue;
338        };
339
340        let out = out.clone();
341        let registry = registry.clone();
342        // The mirror needs the request BEFORE `body` moves into `ask`, and the reply
343        // frame needs `session` AFTER the mirror, so capture both up front. `SetEffort
344        // { effort: None }` is a meaningful CLEAR, so match the VARIANT, not `is_some`.
345        let mirror: Option<(Option<String>, Option<String>)> = match &body {
346            Request::SetModel { model } => Some((Some(model.clone()), None)),
347            Request::SetEffort { effort } => Some((None, effort.clone())),
348            _ => None,
349        };
350        let session_for_reply = session.clone();
351        tokio::spawn(async move {
352            // A peer's frame carries its address in `from`; feed it to the
353            // target so `before_inbound` and the sender tag see it (A2A).
354            let reply = match sender {
355                Some(from) => handle.ask_from(from, body).await,
356                None => handle.ask(body).await,
357            };
358            if let Ok(reply) = reply {
359                // Mirror a live `SetModel`/`SetEffort` into the registry, so the next
360                // roster push names it (no re-publish for anything else).
361                match mirror {
362                    Some((Some(model), _)) => registry.set_model(session.clone(), model),
363                    Some((None, effort)) => registry.set_effort(session.clone(), effort),
364                    None => {}
365                }
366                let frame = Frame {
367                    v: PROTOCOL_VERSION,
368                    id,
369                    reply_to: Some(id),
370                    session: session_for_reply,
371                    sender: None,
372                    body: reply,
373                };
374                let _ = out.send(frame);
375            }
376        });
377    }
378}
379
380/// The served sessions in order: the root first, then each roster session (minus
381/// the root, if a roster ever names it) — each joined with the model the registry
382/// knows for it (`None` when unset). The push payload of `AgentEvent::Sessions`.
383fn roster_infos(
384    registry: &Registry,
385    root_id: &SessionId,
386    roster: &[(SessionId, SessionHandle)],
387) -> Vec<SessionInfo> {
388    let mut ids = vec![root_id.clone()];
389    for (id, _) in roster {
390        if id != root_id {
391            ids.push(id.clone());
392        }
393    }
394    ids.into_iter()
395        .map(|id| {
396            let model = registry.model_of(&id);
397            let state = registry.state_of(&id);
398            let effort = registry.effort_of(&id);
399            let context_window = registry.window_of(&id);
400            SessionInfo { id, model, effort, context_window, state }
401        })
402        .collect()
403}
404
405/// Forward one session's events to a connection, each frame stamped with its
406/// origin session — the routing key the client demuxes on. Ends when the
407/// subscriber closes or the connection's queue is gone.
408fn fan(out: &mpsc::UnboundedSender<Frame<AgentEvent>>, session: &SessionId, handle: &SessionHandle) {
409    let mut events = handle.subscribe();
410    let out = out.clone();
411    let session = session.clone();
412    tokio::spawn(async move {
413        loop {
414            match events.recv().await {
415                Ok(event) => {
416                    let frame = Frame {
417                        v: PROTOCOL_VERSION,
418                        id: 0,
419                        reply_to: None,
420                        session: session.clone(),
421                        sender: None,
422                        body: event,
423                    };
424                    if out.send(frame).is_err() {
425                        break;
426                    }
427                }
428                Err(broadcast::error::RecvError::Lagged(_)) => continue,
429                Err(broadcast::error::RecvError::Closed) => break,
430            }
431        }
432    });
433}
434
435async fn write_loop<W>(write: W, mut rx: mpsc::UnboundedReceiver<Frame<AgentEvent>>)
436where
437    W: AsyncWrite + Unpin,
438{
439    let mut write = BufWriter::new(write);
440    while let Some(frame) = rx.recv().await {
441        if write_frame(&mut write, &frame).await.is_err() {
442            break;
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use std::collections::VecDeque;
451    use std::sync::{Arc, Mutex};
452    use std::time::Duration;
453
454    use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
455    use wcode_harness::actor::SessionActor;
456    use wcode_harness::agent::{Agent, AgentConfig};
457    use wcode_harness::event::LlmStreamEvent;
458    use wcode_harness::message::StopReason;
459    use wcode_harness::streamfn::{LlmOpts, LlmStream, StreamFn};
460
461    /// A root session whose model streams one text delta then stops — enough to
462    /// drive a real `Submit` turn over the transport. There is no shared
463    /// constructor to reuse (`registry.rs`'s `session_with` is private to its
464    /// own test module), so this is the minimal one, mirroring
465    /// `tests/roundtrip.rs`.
466    fn test_root() -> (Root, Registry) {
467        let script = Arc::new(Mutex::new(VecDeque::from([vec![
468            LlmStreamEvent::TextDelta("hi".into()),
469            LlmStreamEvent::Done {
470                stop_reason: StopReason::Stop,
471                usage: None,
472            },
473        ]])));
474        let stream_fn: StreamFn = Arc::new(move |_ctx, _sys, _tools, _opts| {
475            let events = script.lock().unwrap().pop_front().unwrap_or_default();
476            Box::pin(futures::stream::iter(events)) as LlmStream
477        });
478        let handle = SessionActor::spawn(Agent::new(AgentConfig {
479            system: "sys".into(),
480            tools: Vec::new(),
481            llm: LlmOpts {
482                model: "m".into(),
483                ..LlmOpts::default()
484            },
485            stream_fn,
486            hooks: wcode_harness::hooks::HooksSet::default(),
487            session: None,
488            context: Vec::new(),
489            working_dir: std::path::PathBuf::new(),
490            max_turns: wcode_harness::loop_::DEFAULT_MAX_TURNS,
491            parallel_tools: true,
492            compaction: wcode_harness::compaction::CompactionPolicy::default(),
493            plan_mode: wcode_harness::hooks::PlanModeHandle::new(),
494        }));
495        ((SessionId::new("test"), handle), Registry::new())
496    }
497
498    /// A `serve_stream` driver over a fresh duplex pair, with an empty roster.
499    fn driver(server: tokio::io::DuplexStream) -> tokio::task::JoinHandle<()> {
500        let (root, registry) = test_root();
501        let roster: Roster = tokio::sync::watch::channel(Vec::new()).1;
502        tokio::spawn(serve_stream(registry, roster, root, None, server))
503    }
504
505    #[tokio::test]
506    async fn stdio_duplex_round_trips_submit_to_events() {
507        let (server, client) = tokio::io::duplex(64 * 1024);
508        let driver = driver(server);
509        let (cr, mut cw) = tokio::io::split(client);
510
511        let submit = Frame::new(1, SessionId::new("test"), Request::Submit { text: "hi".into() });
512        write_frame(&mut cw, &submit).await.unwrap();
513
514        // The NDJSON contract: every line is exactly one JSON object.
515        let mut reader = BufReader::new(cr);
516        let mut line = String::new();
517        let mut saw_message_end = false;
518        let mut saw_stopped = false;
519        for _ in 0..64 {
520            line.clear();
521            let n = tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut line))
522                .await
523                .expect("a frame in time")
524                .expect("read");
525            assert!(n > 0, "EOF before the run finished");
526            let value: serde_json::Value =
527                serde_json::from_str(line.trim()).expect("each line is one JSON object");
528            assert!(value.is_object(), "a frame is a flat JSON object");
529            match value["type"].as_str() {
530                Some("message_end") => saw_message_end = true,
531                Some("stopped") => {
532                    saw_stopped = true;
533                    break;
534                }
535                _ => {}
536            }
537        }
538        assert!(saw_message_end, "the Submit's streamed events arrived");
539        assert!(saw_stopped, "the run's correlated reply arrived");
540
541        // Dropping BOTH halves closes the duplex (tokio's `split` keeps the
542        // stream alive until the last half drops), so the driver sees a clean
543        // EOF and returns, no panic.
544        drop(cw);
545        drop(reader);
546        let joined = tokio::time::timeout(Duration::from_secs(5), driver)
547            .await
548            .expect("driver ends on EOF");
549        assert!(joined.is_ok());
550    }
551
552    #[tokio::test]
553    async fn a_malformed_line_ends_the_connection() {
554        let (server, client) = tokio::io::duplex(64 * 1024);
555        let driver = driver(server);
556        let (_cr, mut cw) = tokio::io::split(client);
557
558        cw.write_all(b"not json\n").await.unwrap();
559        cw.flush().await.unwrap();
560
561        // `read_frame` yields `InvalidData`; the loop stops rather than hanging.
562        let joined = tokio::time::timeout(Duration::from_secs(5), driver)
563            .await
564            .expect("driver stops, no hang");
565        assert!(joined.is_ok());
566    }
567
568    /// Read one NDJSON frame into `line`, failing the test if none arrives.
569    async fn read_line<R>(reader: &mut BufReader<R>, line: &mut String)
570    where
571        R: AsyncRead + Unpin,
572    {
573        let n = tokio::time::timeout(Duration::from_secs(5), reader.read_line(line))
574            .await
575            .expect("a frame in time")
576            .expect("read");
577        assert!(n > 0, "a non-empty frame");
578    }
579
580    /// Parse one NDJSON line as a JSON object.
581    fn json(line: &str) -> serde_json::Value {
582        serde_json::from_str(line.trim()).unwrap()
583    }
584
585    #[tokio::test]
586    async fn list_models_replies_with_the_registry_catalog() {
587        let (server, client) = tokio::io::duplex(64 * 1024);
588        let (root, registry) = test_root();
589        registry.set_models(vec!["m1".into(), "m2".into()]);
590        let roster: Roster = tokio::sync::watch::channel(Vec::new()).1;
591        let _driver = tokio::spawn(serve_stream(registry, roster, root, None, server));
592        let (cr, mut cw) = tokio::io::split(client);
593
594        let req = Frame::new(1, SessionId::new("test"), Request::ListModels);
595        write_frame(&mut cw, &req).await.unwrap();
596
597        // Frame 1 is the connection's seeded `sessions` push; frame 2 is the reply.
598        let mut reader = BufReader::new(cr);
599        let mut line = String::new();
600        read_line(&mut reader, &mut line).await;
601        assert_eq!(json(&line)["type"], "sessions");
602        line.clear();
603        read_line(&mut reader, &mut line).await;
604        let value = json(&line);
605        assert_eq!(value["type"], "models");
606        assert_eq!(value["reply_to"], 1);
607        assert_eq!(value["models"], serde_json::json!(["m1", "m2"]));
608    }
609
610    #[tokio::test]
611    async fn roster_carries_effort_and_window() {
612        let (server, client) = tokio::io::duplex(64 * 1024);
613        let (root, registry) = test_root();
614        let root_id = root.0.clone();
615        registry.set_model(root_id.clone(), "m1");
616        registry.set_effort(root_id.clone(), Some("high".into()));
617        registry.set_context_window(root_id.clone(), 200_000);
618        let roster: Roster = tokio::sync::watch::channel(Vec::new()).1;
619        let _driver = tokio::spawn(serve_stream(registry, roster, root, None, server));
620        let (cr, _cw) = tokio::io::split(client);
621
622        // The connection is SEEDED with a `sessions` push carrying the roster rows.
623        let mut reader = BufReader::new(cr);
624        let mut line = String::new();
625        read_line(&mut reader, &mut line).await;
626        let value = json(&line);
627        assert_eq!(value["type"], "sessions");
628        let row = &value["sessions"][0];
629        assert_eq!(row["id"], "test");
630        assert_eq!(row["model"], "m1");
631        assert_eq!(row["effort"], "high");
632        assert_eq!(row["context_window"], 200_000);
633    }
634
635    #[tokio::test]
636    async fn a_clean_eof_shuts_the_driver_down() {
637        let (server, client) = tokio::io::duplex(64 * 1024);
638        let driver = driver(server);
639        let (cr, mut cw) = tokio::io::split(client);
640
641        // A valid request gets a `Sessions` reply; then a clean EOF ends it.
642        let frame = Frame::new(1, SessionId::new("test"), Request::ListSessions);
643        write_frame(&mut cw, &frame).await.unwrap();
644
645        let mut reader = BufReader::new(cr);
646        let mut line = String::new();
647        let n = tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut line))
648            .await
649            .expect("a frame in time")
650            .expect("read");
651        assert!(n > 0);
652        let value: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
653        assert_eq!(value["type"], "sessions");
654
655        // Both halves must drop to close the duplex and reach a clean EOF.
656        drop(cw);
657        drop(reader);
658        let joined = tokio::time::timeout(Duration::from_secs(5), driver)
659            .await
660            .expect("driver ends on EOF");
661        assert!(joined.is_ok());
662    }
663}