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}