Skip to main content

brain_db_sdk/
mux.rs

1//! Multiplexed connection: many requests in flight at once over one socket.
2//!
3//! A [`MuxConnection`] splits the stream, runs a background **reader task** that
4//! demultiplexes every inbound frame to the waiting request by its `stream_id`,
5//! and lets callers issue requests concurrently — verbs take `&self`, so one
6//! connection is shared (e.g. behind an `Arc`) across tasks.
7//!
8//! Each request registers an unbounded channel under a fresh `stream_id`,
9//! writes its frame (writes are serialized by a mutex so frames never
10//! interleave on the wire), and awaits frames routed to that channel until the
11//! EOS frame — so streaming verbs (RECALL / QUERY) and single-shot verbs share
12//! one path. An ERROR frame on the stream surfaces as [`BrainError::Server`].
13//! When the reader task dies (a transport drop or a fatal frame-codec error)
14//! it fails every outstanding request.
15//!
16//! `stream_id` 0 is the handshake stream; the handshake completes *before* the
17//! reader task starts, so request stream ids (numbered from 1) never collide
18//! with it even after the `u32` counter wraps.
19//!
20//! [`Subscription`] is the long-lived exception to the request/response shape:
21//! SUBSCRIBE opens a stream the server pushes events on indefinitely, so it
22//! can't collect to EOS like [`MuxConnection::request`]. It registers a route,
23//! sends the SUBSCRIBE frame, and hands back a handle the caller drains one
24//! event at a time. The shared write half, route table, stream-id counter and
25//! timeout all live in a single [`Shared`] behind an `Arc` so a subscription
26//! can send its own UNSUBSCRIBE on a fresh stream without borrowing the
27//! connection.
28
29use std::collections::HashMap;
30use std::sync::atomic::{AtomicU32, Ordering};
31use std::sync::{Arc, Mutex};
32use std::time::{Duration, SystemTime, UNIX_EPOCH};
33
34use tokio::io::{split, AsyncRead, AsyncWrite, ReadHalf, WriteHalf};
35use tokio::sync::mpsc;
36use tokio::task::JoinHandle;
37
38use crate::error::{BrainError, Result};
39use crate::transport::{read_frame, write_frame};
40use crate::wire::cbor::{from_cbor_bytes, to_cbor_bytes};
41use crate::wire::frame::{Frame, FLAG_EOS, MAX_PAYLOAD_BYTES};
42use crate::wire::opcode::Opcode;
43use crate::wire::types::{
44    AuthOkPayload, AuthPayload, ByeRequest, ClientPongRequest, ErrorResponse, HelloPayload,
45    ServerPingResponse, SubscribeRequest, SubscriptionEvent, UnsubscribeRequest,
46    UnsubscribeResponse, WelcomePayload,
47};
48
49const HANDSHAKE_STREAM_ID: u32 = 0;
50
51/// What the server told us during the handshake: the negotiated [`WelcomePayload`]
52/// (chosen wire version + server features) and the [`AuthOkPayload`] (the resolved
53/// session — agent id and tenant namespace). Returned by
54/// [`MuxConnection::connect`] and surfaced to the caller as the initial session.
55#[derive(Clone, Debug)]
56pub struct HandshakeOutcome {
57    /// WELCOME: the negotiated wire version and the server's feature limits.
58    pub welcome: WelcomePayload,
59    /// AUTH_OK: the session the credential resolved to — identity, namespace
60    /// and permissions.
61    pub auth_ok: AuthOkPayload,
62}
63
64/// Wall-clock nanoseconds since the Unix epoch, saturating at 0 if the
65/// clock is before the epoch. Used to stamp CLIENT_PONG replies.
66fn now_unix_nanos() -> u64 {
67    SystemTime::now()
68        .duration_since(UNIX_EPOCH)
69        .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
70}
71
72/// The route table the reader task delivers frames into. `closed` is set once
73/// the reader exits, so a request started after the connection died fails fast
74/// instead of hanging.
75struct RouteTable {
76    routes: HashMap<u32, mpsc::UnboundedSender<Frame>>,
77    closed: Option<String>,
78}
79
80/// State both the connection and any live [`Subscription`] share: the write
81/// half (serialized by a mutex), the route table, the stream-id allocator, and
82/// the per-request timeout. Held behind an `Arc` so a subscription can send its
83/// own UNSUBSCRIBE on a fresh stream without borrowing the `MuxConnection`.
84struct Shared<S> {
85    // Shared with the reader task so it can answer SERVER_PING with
86    // CLIENT_PONG without a separate writer handle.
87    writer: Arc<tokio::sync::Mutex<WriteHalf<S>>>,
88    table: Arc<Mutex<RouteTable>>,
89    next_stream_id: AtomicU32,
90    request_timeout: Option<Duration>,
91    /// The server's WELCOME `max_payload_size`, enforced before every write.
92    ///
93    /// This is a *policy* limit and is not the same as the frame format's
94    /// structural 24-bit ceiling: a server may advertise less than 16 MiB, and
95    /// nothing checked it — all three SDKs decoded this field and discarded it,
96    /// so an oversize request went out and came back as a server error, or
97    /// killed the connection, instead of failing locally with the number the
98    /// server just supplied.
99    max_payload_size: usize,
100}
101
102impl<S> Shared<S>
103where
104    S: AsyncWrite,
105{
106    fn alloc_stream_id(&self) -> u32 {
107        // Client-initiated op streams MUST be non-zero and ODD (stream 0 is
108        // the connection/handshake stream; the server rejects even client
109        // streams as `BadFrame`). Start at 1 and step by 2 so every request
110        // on a reused connection (REPL, pool) stays odd.
111        self.next_stream_id.fetch_add(2, Ordering::Relaxed)
112    }
113
114    /// Register a route for `stream_id`, returning its receiver. Fails if the
115    /// connection has already closed.
116    fn register(&self, stream_id: u32) -> Result<mpsc::UnboundedReceiver<Frame>> {
117        let (tx, rx) = mpsc::unbounded_channel();
118        let mut table = self.table.lock().expect("route table mutex poisoned");
119        if let Some(reason) = &table.closed {
120            return Err(BrainError::Protocol(format!("connection closed: {reason}")));
121        }
122        table.routes.insert(stream_id, tx);
123        Ok(rx)
124    }
125
126    fn deregister(&self, stream_id: u32) {
127        self.table
128            .lock()
129            .expect("route table mutex poisoned")
130            .routes
131            .remove(&stream_id);
132    }
133
134    /// Write one frame, serialized against every other writer on the socket.
135    ///
136    /// Checks the payload against the negotiated `max_payload_size` first, so
137    /// an oversize request fails here — naming the server's own limit — rather
138    /// than travelling the network to be rejected.
139    async fn write(&self, frame: &Frame) -> Result<()> {
140        if frame.payload.len() > self.max_payload_size {
141            return Err(BrainError::Protocol(format!(
142                "payload is {} bytes, over the {} the server advertised at WELCOME",
143                frame.payload.len(),
144                self.max_payload_size
145            )));
146        }
147        let mut w = self.writer.lock().await;
148        write_frame(&mut *w, frame).await
149    }
150
151    /// Send `opcode`+`payload` as a single EOS frame on `stream_id`, then
152    /// collect routed frames up to and including the inbound EOS.
153    async fn request_on(
154        &self,
155        stream_id: u32,
156        mut rx: mpsc::UnboundedReceiver<Frame>,
157        opcode: Opcode,
158        payload: Vec<u8>,
159    ) -> Result<Vec<Frame>> {
160        let frame = Frame::new(opcode.as_u16(), FLAG_EOS, stream_id, payload);
161        if let Err(e) = self.write(&frame).await {
162            self.deregister(stream_id);
163            return Err(e);
164        }
165
166        let mut frames = Vec::new();
167        loop {
168            let next = match self.request_timeout {
169                Some(timeout) => match tokio::time::timeout(timeout, rx.recv()).await {
170                    Ok(v) => v,
171                    Err(_) => {
172                        self.deregister(stream_id);
173                        return Err(BrainError::Timeout(timeout));
174                    }
175                },
176                None => rx.recv().await,
177            };
178            match next {
179                Some(frame) if frame.opcode == Opcode::Error as u16 => {
180                    self.deregister(stream_id);
181                    let err: ErrorResponse = from_cbor_bytes(&frame.payload)?;
182                    return Err(BrainError::from_server(err));
183                }
184                Some(frame) => {
185                    let is_last = frame.flags & FLAG_EOS != 0;
186                    frames.push(frame);
187                    if is_last {
188                        // The reader removes the route on EOS; nothing to clean up.
189                        return Ok(frames);
190                    }
191                }
192                None => return Err(self.closed_error()),
193            }
194        }
195    }
196
197    /// The error to surface when a request's channel closed without an EOS —
198    /// the reader recorded a failure reason, or the peer simply went away.
199    fn closed_error(&self) -> BrainError {
200        let table = self.table.lock().expect("route table mutex poisoned");
201        match &table.closed {
202            Some(reason) => BrainError::Protocol(format!("connection closed: {reason}")),
203            None => BrainError::Closed,
204        }
205    }
206}
207
208/// A connection that serves many concurrent requests over one socket.
209pub struct MuxConnection<S> {
210    shared: Arc<Shared<S>>,
211    reader: JoinHandle<()>,
212}
213
214impl<S> MuxConnection<S> {
215    /// Whether the connection is dead: the reader task has ended (the peer
216    /// closed the socket, or a read failed). Every further request on a closed
217    /// connection fails immediately, so a pool should replace it.
218    #[must_use]
219    pub fn is_closed(&self) -> bool {
220        self.reader.is_finished()
221            || self
222                .shared
223                .table
224                .lock()
225                .map_or(true, |t| t.closed.is_some())
226    }
227}
228
229impl<S> std::fmt::Debug for MuxConnection<S> {
230    /// Hand-written so it does not require `S: Debug`, and so it reports the
231    /// one thing worth seeing — how many requests are still in flight. Reads
232    /// the table directly rather than through `route_count`, which is only
233    /// available where `S: AsyncWrite`.
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        let live = self.shared.table.lock().ok().map(|t| t.routes.len());
236        f.debug_struct("MuxConnection")
237            .field("live_routes", &live)
238            .finish_non_exhaustive()
239    }
240}
241
242impl<S> Drop for MuxConnection<S> {
243    fn drop(&mut self) {
244        // The reader task borrows only the read half (moved into it); aborting
245        // it on drop avoids a leaked task once the connection goes away.
246        self.reader.abort();
247    }
248}
249
250impl<S> MuxConnection<S>
251where
252    S: AsyncRead + AsyncWrite + Send + 'static,
253{
254    /// Run the handshake on `stream`, then split it and spawn the reader task.
255    /// Returns the live connection and the negotiated [`HandshakeOutcome`].
256    ///
257    /// The handshake runs inline on the split halves (HELLO → WELCOME → AUTH →
258    /// AUTH_OK on stream 0); any bytes buffered past AUTH_OK are handed to the
259    /// reader task so no frame is lost at the seam.
260    pub async fn handshake(
261        stream: S,
262        hello: HelloPayload,
263        auth: AuthPayload,
264        request_timeout: Option<Duration>,
265    ) -> Result<(Self, HandshakeOutcome)> {
266        let (mut read_half, mut write_half) = split(stream);
267        let mut buf = Vec::new();
268        let offered = hello.supported_versions.clone();
269
270        send(&mut write_half, Opcode::Hello, HANDSHAKE_STREAM_ID, &hello).await?;
271        let welcome_frame =
272            read_handshake_reply(&mut read_half, &mut buf, Opcode::Welcome, request_timeout)
273                .await?;
274        let welcome: WelcomePayload = from_cbor_bytes(&welcome_frame.payload)?;
275        if !offered.contains(&welcome.chosen_version) {
276            return Err(BrainError::VersionMismatch {
277                chosen: welcome.chosen_version,
278                supported: offered,
279            });
280        }
281
282        send(&mut write_half, Opcode::Auth, HANDSHAKE_STREAM_ID, &auth).await?;
283        let auth_ok_frame =
284            read_handshake_reply(&mut read_half, &mut buf, Opcode::AuthOk, request_timeout).await?;
285        let auth_ok: AuthOkPayload = from_cbor_bytes(&auth_ok_frame.payload)?;
286
287        let table = Arc::new(Mutex::new(RouteTable {
288            routes: HashMap::new(),
289            closed: None,
290        }));
291        let writer = Arc::new(tokio::sync::Mutex::new(write_half));
292        let reader = tokio::spawn(reader_loop(
293            read_half,
294            buf,
295            Arc::clone(&table),
296            Arc::clone(&writer),
297        ));
298
299        let shared = Arc::new(Shared {
300            writer,
301            table,
302            next_stream_id: AtomicU32::new(1),
303            request_timeout,
304            // A server that leaves this field at its default advertises 0.
305            // Zero is not a meaningful payload cap -- read literally it refuses
306            // every request -- so it means "unset", and the structural 24-bit
307            // ceiling stands in. Reading it literally bricked the client
308            // against any such server.
309            max_payload_size: if welcome.server_features.max_payload_size == 0 {
310                MAX_PAYLOAD_BYTES
311            } else {
312                welcome.server_features.max_payload_size as usize
313            },
314        });
315
316        let conn = Self { shared, reader };
317        Ok((conn, HandshakeOutcome { welcome, auth_ok }))
318    }
319
320    /// Send one request and collect its response frames up to and including the
321    /// EOS frame. Safe to call concurrently from many tasks on a shared
322    /// connection; responses are routed back by `stream_id`.
323    pub async fn request(&self, opcode: Opcode, payload: Vec<u8>) -> Result<Vec<Frame>> {
324        let stream_id = self.shared.alloc_stream_id();
325        let rx = self.shared.register(stream_id)?;
326        self.shared.request_on(stream_id, rx, opcode, payload).await
327    }
328
329    /// Send one request and require exactly one EOS response frame.
330    pub async fn request_one(&self, opcode: Opcode, payload: Vec<u8>) -> Result<Frame> {
331        let mut frames = self.request(opcode, payload).await?;
332        if frames.len() != 1 {
333            return Err(BrainError::Protocol(format!(
334                "expected a single response frame, got {}",
335                frames.len()
336            )));
337        }
338        Ok(frames.remove(0))
339    }
340
341    /// Open a long-lived subscription. Allocates a stream id, registers its
342    /// route, sends the SUBSCRIBE frame, and returns a [`Subscription`] the
343    /// caller drains with [`Subscription::next`]. Unlike [`Self::request`],
344    /// this does **not** collect to EOS — the server pushes events until the
345    /// client unsubscribes.
346    pub async fn subscribe(&self, request: &SubscribeRequest) -> Result<Subscription<S>> {
347        let stream_id = self.shared.alloc_stream_id();
348        let rx = self.shared.register(stream_id)?;
349        let frame = Frame::new(
350            Opcode::SubscribeReq.as_u16(),
351            FLAG_EOS,
352            stream_id,
353            to_cbor_bytes(request),
354        );
355        if let Err(e) = self.shared.write(&frame).await {
356            self.shared.deregister(stream_id);
357            return Err(e);
358        }
359        Ok(Subscription {
360            shared: Arc::clone(&self.shared),
361            stream_id,
362            rx,
363            ended: false,
364        })
365    }
366
367    /// Send BYE to end the session cleanly.
368    pub async fn send_bye(&self) -> Result<()> {
369        // A CBOR map, not an empty payload: the server decodes this as a
370        // `ByeRequest`, and zero bytes is not valid CBOR.
371        let frame = Frame::new(
372            Opcode::Bye.as_u16(),
373            FLAG_EOS,
374            HANDSHAKE_STREAM_ID,
375            to_cbor_bytes(&ByeRequest::default()),
376        );
377        self.shared.write(&frame).await
378    }
379
380    /// Number of live routes in the table. Test/diagnostics hook: a clean
381    /// subscription teardown must leave no leaked route behind.
382    ///
383    /// # Panics
384    ///
385    /// If the route-table mutex is poisoned — which means another thread
386    /// panicked while holding it, and the connection's routing state is no
387    /// longer trustworthy.
388    #[must_use]
389    pub fn route_count(&self) -> usize {
390        self.shared
391            .table
392            .lock()
393            .expect("route table mutex poisoned")
394            .routes
395            .len()
396    }
397}
398
399/// A live server-push subscription stream. Drain events with [`Self::next`];
400/// call [`Self::unsubscribe`] for a clean teardown (the server then EOS-closes
401/// the event stream, which `next` observes as end-of-stream).
402///
403/// Dropping a subscription deregisters its route so a forgotten handle doesn't
404/// leak an entry in the route table — but `Drop` can't send UNSUBSCRIBE (it
405/// isn't async), so a dropped-without-unsubscribe subscription leaves the
406/// server pushing into a now-dead channel until it notices. Prefer
407/// `unsubscribe().await` before dropping.
408pub struct Subscription<S> {
409    shared: Arc<Shared<S>>,
410    stream_id: u32,
411    rx: mpsc::UnboundedReceiver<Frame>,
412    /// Set once an EOS frame has been observed: the next `next()` returns
413    /// `None` without touching the (already-removed) channel.
414    ended: bool,
415}
416
417impl<S> std::fmt::Debug for Subscription<S> {
418    /// Hand-written so it does not require `S: Debug`.
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.debug_struct("Subscription")
421            .field("stream_id", &self.stream_id)
422            .field("ended", &self.ended)
423            .finish_non_exhaustive()
424    }
425}
426
427impl<S> Subscription<S>
428where
429    S: AsyncWrite,
430{
431    /// The stream id the server pushes events on. Pass it to the server's
432    /// UNSUBSCRIBE target if driving the protocol manually.
433    #[must_use]
434    pub fn stream_id(&self) -> u32 {
435        self.stream_id
436    }
437
438    /// Await the next subscription event. Returns `Some(Ok(event))` for each
439    /// pushed `SUBSCRIBE_EVENT`, `Some(Err(_))` if a frame failed to decode or
440    /// the server sent an ERROR on the stream, and `None` once the stream has
441    /// ended (the server EOS-closed it, or the connection dropped).
442    ///
443    /// An EOS-flagged frame carrying an event yields that event; the *following*
444    /// call returns `None`. An EOS frame with an empty payload ends the stream
445    /// immediately.
446    pub async fn next(&mut self) -> Option<Result<SubscriptionEvent>> {
447        if self.ended {
448            return None;
449        }
450        let frame = self.rx.recv().await?;
451        if frame.opcode == Opcode::Error as u16 {
452            self.ended = true;
453            return Some(match from_cbor_bytes::<ErrorResponse>(&frame.payload) {
454                Ok(err) => Err(BrainError::from_server(err)),
455                Err(e) => Err(e.into()),
456            });
457        }
458        if frame.flags & FLAG_EOS != 0 {
459            // Last frame on the stream. The reader has already removed the
460            // route; mark ended so the next call short-circuits to `None`.
461            self.ended = true;
462            // A terminal EOS frame may carry a final event or be empty.
463            if frame.payload.is_empty() {
464                return None;
465            }
466        }
467        Some(from_cbor_bytes::<SubscriptionEvent>(&frame.payload).map_err(Into::into))
468    }
469
470    /// Tear the subscription down cleanly: send UNSUBSCRIBE on a fresh stream
471    /// and await its UNSUBSCRIBE_RESP. The server then EOS-closes the event
472    /// stream, which a subsequent [`Self::next`] observes as `None`.
473    pub async fn unsubscribe(&self) -> Result<UnsubscribeResponse> {
474        let req = UnsubscribeRequest {
475            target_stream_id: self.stream_id,
476        };
477        let stream_id = self.shared.alloc_stream_id();
478        let rx = self.shared.register(stream_id)?;
479        let mut frames = self
480            .shared
481            .request_on(stream_id, rx, Opcode::UnsubscribeReq, to_cbor_bytes(&req))
482            .await?;
483        if frames.len() != 1 {
484            return Err(BrainError::Protocol(format!(
485                "expected a single UNSUBSCRIBE_RESP frame, got {}",
486                frames.len()
487            )));
488        }
489        let frame = frames.remove(0);
490        if frame.opcode != Opcode::UnsubscribeResp as u16 {
491            return Err(BrainError::Protocol(format!(
492                "expected UNSUBSCRIBE_RESP ({:#06x}), got {:#06x}",
493                Opcode::UnsubscribeResp as u16,
494                frame.opcode
495            )));
496        }
497        Ok(from_cbor_bytes(&frame.payload)?)
498    }
499}
500
501impl<S> Drop for Subscription<S> {
502    fn drop(&mut self) {
503        // Deregister the subscription's route so a dropped handle doesn't leak
504        // a route-table entry. Cannot send UNSUBSCRIBE here (Drop isn't async);
505        // a clean shutdown should call `unsubscribe().await` first.
506        self.shared
507            .table
508            .lock()
509            .expect("route table mutex poisoned")
510            .routes
511            .remove(&self.stream_id);
512    }
513}
514
515/// Demultiplex inbound frames to waiting requests until the stream ends or a
516/// fatal error occurs, then fail every outstanding request.
517async fn reader_loop<S>(
518    mut read_half: ReadHalf<S>,
519    mut buf: Vec<u8>,
520    table: Arc<Mutex<RouteTable>>,
521    writer: Arc<tokio::sync::Mutex<WriteHalf<S>>>,
522) where
523    // `ReadHalf<S>`/`WriteHalf<S>` are `Unpin` regardless of `S`, so the
524    // read/write calls need only `S: AsyncRead + AsyncWrite`. Send/'static
525    // for the spawned task come from the call site (handshake bound).
526    S: AsyncRead + AsyncWrite,
527{
528    loop {
529        match read_frame(&mut read_half, &mut buf).await {
530            Ok(frame) => {
531                // Server idle-timer heartbeat: reply CLIENT_PONG on stream 0
532                // so the server doesn't reap an otherwise-idle connection.
533                // The reply echoes the server timestamp (0 if the payload
534                // can't be decoded — the server only needs *a* frame to reset
535                // its idle timer). Best-effort: a write failure surfaces as
536                // the next read error, which closes the connection cleanly.
537                if frame.opcode == Opcode::ServerPing.as_u16() {
538                    let server_ts = from_cbor_bytes::<ServerPingResponse>(&frame.payload)
539                        .map_or(0, |p| p.server_timestamp_unix_nanos);
540                    let pong = ClientPongRequest {
541                        server_timestamp_unix_nanos: server_ts,
542                        client_timestamp_unix_nanos: now_unix_nanos(),
543                    };
544                    let reply = Frame::new(
545                        Opcode::ClientPong.as_u16(),
546                        FLAG_EOS,
547                        HANDSHAKE_STREAM_ID,
548                        to_cbor_bytes(&pong),
549                    );
550                    let mut w = writer.lock().await;
551                    let _ = write_frame(&mut *w, &reply).await;
552                    continue;
553                }
554                let stream_id = frame.stream_id;
555                let is_last = frame.flags & FLAG_EOS != 0;
556                let mut t = table.lock().expect("route table mutex poisoned");
557                if let Some(tx) = t.routes.get(&stream_id) {
558                    // A receiver dropped (request cancelled) just means the send
559                    // fails; drop the frame and the route.
560                    let _ = tx.send(frame);
561                    if is_last {
562                        t.routes.remove(&stream_id);
563                    }
564                }
565                // An unknown stream_id is a late frame for a timed-out/cancelled
566                // request; drop it.
567            }
568            Err(e) => {
569                let mut t = table.lock().expect("route table mutex poisoned");
570                t.closed = Some(e.to_string());
571                // Dropping every sender wakes each waiting `rx.recv()` with
572                // `None`, so all outstanding requests fail.
573                t.routes.clear();
574                return;
575            }
576        }
577    }
578}
579
580/// Write a CBOR-bodied frame on a write half (handshake helper).
581async fn send<W, T>(write_half: &mut W, opcode: Opcode, stream_id: u32, payload: &T) -> Result<()>
582where
583    W: AsyncWrite + Unpin,
584    T: serde::Serialize,
585{
586    let frame = Frame::new(
587        opcode.as_u16(),
588        FLAG_EOS,
589        stream_id,
590        crate::wire::cbor::to_cbor_bytes(payload),
591    );
592    write_frame(write_half, &frame).await
593}
594
595/// Read one handshake reply and require it to carry `expected`.
596async fn read_handshake_reply<R: AsyncRead + Unpin>(
597    read_half: &mut R,
598    buf: &mut Vec<u8>,
599    expected: Opcode,
600    timeout: Option<Duration>,
601) -> Result<Frame> {
602    let frame = match timeout {
603        Some(t) => tokio::time::timeout(t, read_frame(read_half, buf))
604            .await
605            .map_err(|_| BrainError::Timeout(t))??,
606        None => read_frame(read_half, buf).await?,
607    };
608    if frame.opcode == Opcode::Error as u16 {
609        let err: ErrorResponse = from_cbor_bytes(&frame.payload)?;
610        return Err(BrainError::from_server(err));
611    }
612    if frame.opcode != expected.as_u16() {
613        return Err(BrainError::Protocol(format!(
614            "expected {expected:?} ({:#06x}), got opcode {:#06x}",
615            expected.as_u16(),
616            frame.opcode
617        )));
618    }
619    Ok(frame)
620}