Skip to main content

liminal_sdk/remote/tcp/
push_client.rs

1//! Client-side background reader for server-initiated pushes.
2//!
3//! Every other SDK transport call is request/response: the client writes a frame
4//! and reads exactly one reply to its own request ([`Connection::round_trip`]). A
5//! server PUSH inverts that — the server writes a [`Frame::Push`] on the client's
6//! existing connection at a time of the server's choosing, with no outstanding
7//! client request to read it. [`PushClient`] is the piece that consumes those
8//! inbound frames: it owns a connection whose socket is drained by a dedicated
9//! background reader thread, surfaces each pushed frame on a channel, and lets the
10//! caller send back a correlated [`Frame::PushReply`] on the same socket.
11//!
12//! # Read/write split
13//!
14//! A push connection is read concurrently (the background thread blocks on the
15//! socket) and written concurrently (the caller replies). `TcpStream` is cloned so
16//! the reader thread owns one handle and the writer holds the other behind a
17//! `Mutex`; the two handles share the same underlying socket, so a reply written
18//! by the caller travels the connection the server is pushing on. This keeps the
19//! request/reply [`Connection`] (which couples a single read to a single write)
20//! completely untouched — the push path is additive, not a rewrite.
21
22use alloc::format;
23use alloc::string::ToString;
24use alloc::sync::Arc;
25use alloc::vec;
26use alloc::vec::Vec;
27use core::time::Duration;
28
29use std::io::{Read, Write};
30use std::net::TcpStream;
31use std::sync::Mutex;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
34use std::thread::JoinHandle;
35
36use liminal::protocol::{
37    CausalContext, Frame, MessageEnvelope, ProtocolError, ProtocolVersion, SchemaId,
38    WorkerRegisterOutcome, WorkerRegistration, decode, encode, encoded_len,
39};
40
41use crate::SdkError;
42
43/// Minimum protocol version this client advertises during the handshake.
44const CLIENT_MIN_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
45/// Maximum protocol version this client advertises during the handshake.
46const CLIENT_MAX_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
47/// Bound on a single socket write.
48const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
49/// Poll cadence the reader thread uses so it can observe the stop flag promptly
50/// between reads while still blocking efficiently on the socket the rest of the
51/// time.
52const READER_POLL_TIMEOUT: Duration = Duration::from_millis(100);
53/// Read chunk size used when draining the socket into the frame buffer.
54const READ_CHUNK_BYTES: usize = 4096;
55/// Upper bound on a single buffered frame, guarding against runaway buffering.
56const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
57/// Application stream id used for the client's push reply frames.
58const APPLICATION_STREAM_ID: u32 = 1;
59
60/// The reserved channel a worker publishes agent-observability events to over its
61/// existing push connection.
62///
63/// It is NOT a general pub/sub channel: the server routes a publish on this exact
64/// channel name straight to its `ConnectionNotifier` observability hook (bypassing
65/// the channel-fan-out cluster), so a worker never needs a second connection to
66/// stream a transcript. The name is a wire contract shared by the worker publisher
67/// and the server's demux, so it is pinned here as the single source of truth.
68pub const OBSERVABILITY_CHANNEL: &str = "aion.observability.v1";
69
70/// A frame the server pushed to this client.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct PushedFrame {
73    /// Correlation id the server assigned; echo it on the reply.
74    correlation_id: u64,
75    /// Opaque payload bytes the server pushed.
76    payload: Vec<u8>,
77}
78
79impl PushedFrame {
80    /// Correlation id to echo back on the reply so the server matches it.
81    #[must_use]
82    pub const fn correlation_id(&self) -> u64 {
83        self.correlation_id
84    }
85
86    /// Opaque payload bytes the server pushed.
87    #[must_use]
88    pub fn payload(&self) -> &[u8] {
89        &self.payload
90    }
91
92    /// Consumes the frame, returning the owned payload bytes.
93    #[must_use]
94    pub fn into_payload(self) -> Vec<u8> {
95        self.payload
96    }
97}
98
99/// A connected client that consumes server pushes and sends correlated replies.
100///
101/// Construct with [`PushClient::connect`]; the background reader starts
102/// immediately and runs until the client is dropped. Pull pushed frames with
103/// [`PushClient::recv_timeout`] and answer them with [`PushClient::reply`].
104#[derive(Debug)]
105pub struct PushClient {
106    /// Write half of the shared socket, guarded so the caller's reply does not
107    /// interleave bytes with any other writer.
108    writer: Arc<Mutex<TcpStream>>,
109    /// Inbound pushed frames surfaced by the background reader.
110    inbound: Receiver<PushedFrame>,
111    /// Signals the reader thread to stop; set on drop.
112    stop: Arc<AtomicBool>,
113    /// Background reader handle, joined on drop.
114    reader: Option<JoinHandle<()>>,
115}
116
117impl PushClient {
118    /// Connects to `address`, performs the protocol handshake, and starts the
119    /// background reader that drains inbound server pushes.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`SdkError::Connection`] when the TCP connection or socket
124    /// configuration fails, and [`SdkError::Protocol`] when the handshake is
125    /// rejected or the socket cannot be cloned for the reader thread.
126    pub fn connect(address: &str) -> Result<Self, SdkError> {
127        // Open access: an empty token is byte-identical to the pre-auth handshake.
128        Self::connect_with_auth(address, &[])
129    }
130
131    /// Connects and handshakes carrying `auth_token`, then starts the background
132    /// reader, for a server gated by an `[auth]` section. Additive to [`connect`];
133    /// an empty token is equivalent to it.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SdkError::Connection`] when the TCP connection or socket
138    /// configuration fails or the server rejects the token, and
139    /// [`SdkError::Protocol`] when the handshake is otherwise rejected or the socket
140    /// cannot be cloned for the reader thread.
141    ///
142    /// [`connect`]: Self::connect
143    pub fn connect_with_auth(address: &str, auth_token: &[u8]) -> Result<Self, SdkError> {
144        let mut stream = connect_socket(address)?;
145        handshake(&mut stream, auth_token)?;
146        Self::start_reader(stream)
147    }
148
149    /// Connects, performs the handshake, then synchronously registers this client
150    /// as a worker before starting the background reader.
151    ///
152    /// This mirrors the synchronous `Connect`/`ConnectAck` pattern: the
153    /// `WorkerRegister` frame is written and its [`Frame::WorkerRegisterAck`] read
154    /// on the calling thread, BEFORE the Push-only background reader is spawned, so
155    /// the ack is never swallowed by the reader. A connect-variant (rather than a
156    /// `register()` method on a connected client) is the cleanest fit: `connect`
157    /// spawns the reader as its last step, so registration must be threaded into
158    /// the connect sequence to land before that spawn; a post-connect method would
159    /// race the already-running reader for the ack frame.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`SdkError::Connection`] when the TCP connection or socket
164    /// configuration fails, and [`SdkError::Protocol`] when the handshake is
165    /// rejected, the server rejects the registration (the rejection reason is
166    /// carried in the error), or the socket cannot be cloned for the reader thread.
167    pub fn connect_with_registration(
168        address: &str,
169        registration: WorkerRegistration,
170    ) -> Result<Self, SdkError> {
171        Self::connect_with_registration_and_auth(address, registration, &[])
172    }
173
174    /// Connects, handshakes carrying `auth_token`, registers the worker, then starts
175    /// the reader — the auth-gated variant of [`connect_with_registration`]. Additive;
176    /// an empty token is equivalent to it.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`SdkError::Connection`] when the TCP connection or socket
181    /// configuration fails or the server rejects the token, and
182    /// [`SdkError::Protocol`] when the handshake is otherwise rejected, the server
183    /// rejects the registration (the reason is carried in the error), or the socket
184    /// cannot be cloned for the reader thread.
185    ///
186    /// [`connect_with_registration`]: Self::connect_with_registration
187    pub fn connect_with_registration_and_auth(
188        address: &str,
189        registration: WorkerRegistration,
190        auth_token: &[u8],
191    ) -> Result<Self, SdkError> {
192        let mut stream = connect_socket(address)?;
193        handshake(&mut stream, auth_token)?;
194        register(&mut stream, registration)?;
195        Self::start_reader(stream)
196    }
197
198    /// Spawns the Push-only background reader over a handshaken (and, for a worker,
199    /// already-registered) stream and returns the running client.
200    fn start_reader(stream: TcpStream) -> Result<Self, SdkError> {
201        // Clone the socket so the reader thread owns one handle and the writer
202        // holds the other; both refer to the same underlying connection.
203        let read_stream = stream.try_clone().map_err(|source| SdkError::Protocol {
204            description: format!("failed to clone push socket for reader thread: {source}"),
205        })?;
206
207        let stop = Arc::new(AtomicBool::new(false));
208        let (sender, inbound) = channel();
209        let reader_stop = Arc::clone(&stop);
210        let reader = std::thread::Builder::new()
211            .name("liminal-push-reader".to_string())
212            .spawn(move || run_reader(read_stream, &sender, &reader_stop))
213            .map_err(|source| SdkError::Protocol {
214                description: format!("failed to start push reader thread: {source}"),
215            })?;
216
217        Ok(Self {
218            writer: Arc::new(Mutex::new(stream)),
219            inbound,
220            stop,
221            reader: Some(reader),
222        })
223    }
224
225    /// Blocks up to `timeout` for the next pushed frame from the server.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`SdkError::Connection`] when no push arrives within `timeout` or
230    /// the background reader has stopped (e.g. the server closed the connection).
231    pub fn recv_timeout(&self, timeout: Duration) -> Result<PushedFrame, SdkError> {
232        self.inbound.recv_timeout(timeout).map_err(|error| {
233            let detail = match error {
234                RecvTimeoutError::Timeout => "no server push arrived within the timeout",
235                RecvTimeoutError::Disconnected => {
236                    "the push reader stopped before a server push arrived"
237                }
238            };
239            SdkError::Connection {
240                description: format!("push receive failed: {detail}"),
241            }
242        })
243    }
244
245    /// Sends a correlated reply to a pushed frame, echoing its correlation id so
246    /// the server matches the reply back to the originating push.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`SdkError::Protocol`] when the reply frame cannot be encoded and
251    /// [`SdkError::Connection`] when it cannot be written to the socket or the
252    /// writer lock is poisoned.
253    pub fn reply(&self, correlation_id: u64, payload: Vec<u8>) -> Result<(), SdkError> {
254        let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, correlation_id, payload)
255            .map_err(|error| protocol_error(&error))?;
256        let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
257            description: format!("push writer lock poisoned: {error}"),
258        })?;
259        write_frame(&mut writer, &frame)
260    }
261
262    /// A cheap, cloneable handle to this push connection's write half, for
263    /// background tasks that publish out-of-band frames on the same socket without
264    /// owning the full client (which cannot be cloned — it holds the reader thread
265    /// join handle).
266    ///
267    /// The returned [`PushWriter`] shares the client's `Arc<Mutex<TcpStream>>`, so a
268    /// frame it writes travels the SAME connection the server pushes on. It is the
269    /// worker's observability-drain leg: a drain task holds one and publishes each
270    /// [`OBSERVABILITY_CHANNEL`] event live while the client keeps serving pushes.
271    #[must_use]
272    pub fn writer_handle(&self) -> PushWriter {
273        PushWriter {
274            writer: Arc::clone(&self.writer),
275        }
276    }
277
278    /// Publish `payload` to `channel` over this connection (out-of-band from the
279    /// push/reply round trip).
280    ///
281    /// Convenience shorthand for `self.writer_handle().publish(channel, payload)`.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`SdkError::Protocol`] when the publish frame cannot be encoded and
286    /// [`SdkError::Connection`] when it cannot be written to the socket or the
287    /// writer lock is poisoned.
288    pub fn publish(&self, channel: &str, payload: Vec<u8>) -> Result<(), SdkError> {
289        self.writer_handle().publish(channel, payload)
290    }
291}
292
293/// A cheap clone of a [`PushClient`]'s write half.
294///
295/// It writes `Frame::Publish` frames on the SAME socket the client receives pushes
296/// on, so a background drain task can stream observability events upstream without a
297/// second connection. Cloning is an `Arc` bump; the underlying socket and its write
298/// lock are shared with the originating [`PushClient`].
299#[derive(Clone, Debug)]
300pub struct PushWriter {
301    writer: Arc<Mutex<TcpStream>>,
302}
303
304impl PushWriter {
305    /// Publish `payload` to `channel` on the shared connection.
306    ///
307    /// Writes a single `Frame::Publish` carrying the opaque bytes verbatim (schema
308    /// id zero, an independent causal context — the server routes the reserved
309    /// observability channel straight to its notifier hook, so no schema negotiation
310    /// or ordering context is required). The write takes the shared writer lock, so
311    /// it never interleaves bytes with a concurrent push reply.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`SdkError::Protocol`] when the publish frame cannot be encoded and
316    /// [`SdkError::Connection`] when it cannot be written to the socket or the writer
317    /// lock is poisoned.
318    pub fn publish(&self, channel: &str, payload: Vec<u8>) -> Result<(), SdkError> {
319        let envelope = MessageEnvelope::new(
320            SchemaId::new([0_u8; SchemaId::WIRE_LEN]),
321            CausalContext::independent(),
322            payload,
323        );
324        let frame = Frame::new_publish(APPLICATION_STREAM_ID, channel, envelope)
325            .map_err(|error| protocol_error(&error))?;
326        let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
327            description: format!("push writer lock poisoned: {error}"),
328        })?;
329        write_frame(&mut writer, &frame)
330    }
331
332    /// Send a correlated reply to a server push on the shared connection, echoing the
333    /// push's `correlation_id` so the server matches the reply to its push.
334    ///
335    /// Identical wire effect to [`PushClient::reply`], but issued from a cheap
336    /// [`PushWriter`] clone so a BACKGROUND task (e.g. a long-running agent dispatch)
337    /// can answer its own push after it completes, without holding the full client or
338    /// blocking the serve loop. Shares the writer lock, so it never interleaves bytes
339    /// with a concurrent publish or reply.
340    ///
341    /// # Errors
342    ///
343    /// Returns [`SdkError::Protocol`] when the reply frame cannot be encoded and
344    /// [`SdkError::Connection`] when it cannot be written to the socket or the writer
345    /// lock is poisoned.
346    pub fn reply(&self, correlation_id: u64, payload: Vec<u8>) -> Result<(), SdkError> {
347        let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, correlation_id, payload)
348            .map_err(|error| protocol_error(&error))?;
349        let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
350            description: format!("push writer lock poisoned: {error}"),
351        })?;
352        write_frame(&mut writer, &frame)
353    }
354}
355
356impl Drop for PushClient {
357    fn drop(&mut self) {
358        self.stop.store(true, Ordering::SeqCst);
359        if let Some(reader) = self.reader.take() {
360            // The reader wakes within READER_POLL_TIMEOUT to observe the stop flag,
361            // so this join does not hang on a quiet connection.
362            reader.join().ok();
363        }
364    }
365}
366
367/// Opens and configures the push-client socket (Nagle off, bounded read/write
368/// timeouts) before any framing.
369fn connect_socket(address: &str) -> Result<TcpStream, SdkError> {
370    let stream = TcpStream::connect(address).map_err(|source| SdkError::Connection {
371        description: format!("failed to connect push client to {address}: {source}"),
372    })?;
373    stream
374        .set_nodelay(true)
375        .map_err(|source| SdkError::Connection {
376            description: format!("failed to disable Nagle for {address}: {source}"),
377        })?;
378    // A bounded read timeout lets the reader thread wake to check the stop flag
379    // even when the server is silent; without it the thread would block forever
380    // on a quiet connection and never observe drop.
381    stream
382        .set_read_timeout(Some(READER_POLL_TIMEOUT))
383        .map_err(|source| SdkError::Connection {
384            description: format!("failed to set push read timeout for {address}: {source}"),
385        })?;
386    stream
387        .set_write_timeout(Some(WRITE_TIMEOUT))
388        .map_err(|source| SdkError::Connection {
389            description: format!("failed to set push write timeout for {address}: {source}"),
390        })?;
391    Ok(stream)
392}
393
394/// Drives the synchronous worker-registration round trip
395/// (`WorkerRegister` -> `WorkerRegisterAck`) on a handshaken socket, before the
396/// background reader is spawned.
397///
398/// A `Rejected` ack maps to a typed [`SdkError::Protocol`] carrying the server's
399/// reason; any non-ack reply is a protocol error.
400fn register(stream: &mut TcpStream, registration: WorkerRegistration) -> Result<(), SdkError> {
401    let frame = Frame::WorkerRegister {
402        flags: 0,
403        registration,
404    };
405    write_frame(stream, &frame)?;
406    let mut buffer = Vec::new();
407    match read_one_frame(stream, &mut buffer)? {
408        Frame::WorkerRegisterAck {
409            outcome: WorkerRegisterOutcome::Accepted,
410            ..
411        } => Ok(()),
412        Frame::WorkerRegisterAck {
413            outcome: WorkerRegisterOutcome::Rejected { reason },
414            ..
415        } => Err(SdkError::Protocol {
416            description: format!("server rejected worker registration: {reason}"),
417        }),
418        other => Err(SdkError::Protocol {
419            description: format!(
420                "expected WorkerRegisterAck during registration, received {:?}",
421                other.frame_type()
422            ),
423        }),
424    }
425}
426
427/// Drives the client handshake (`Connect` -> `ConnectAck`) on a fresh socket,
428/// carrying `auth_token` (empty for an open, non-auth server).
429fn handshake(stream: &mut TcpStream, auth_token: &[u8]) -> Result<(), SdkError> {
430    let connect = Frame::Connect {
431        flags: 0,
432        min_version: CLIENT_MIN_VERSION,
433        max_version: CLIENT_MAX_VERSION,
434        auth_token: auth_token.to_vec(),
435    };
436    write_frame(stream, &connect)?;
437    let mut buffer = Vec::new();
438    match read_one_frame(stream, &mut buffer)? {
439        Frame::ConnectAck { .. } => Ok(()),
440        Frame::ConnectError {
441            reason_code,
442            message,
443            ..
444        } => Err(SdkError::Connection {
445            description: format!(
446                "server rejected push connection (reason {reason_code}): {}",
447                message.unwrap_or_else(|| "no detail".to_string())
448            ),
449        }),
450        other => Err(SdkError::Protocol {
451            description: format!(
452                "expected ConnectAck during push handshake, received {:?}",
453                other.frame_type()
454            ),
455        }),
456    }
457}
458
459/// Background loop: drains the socket, surfacing each `Push` frame on `sender`.
460///
461/// Returns (ending the thread) when the stop flag is set, the connection closes,
462/// or a fatal decode/IO error occurs. A read timeout is non-fatal: it just lets
463/// the loop re-check the stop flag.
464fn run_reader(mut stream: TcpStream, sender: &Sender<PushedFrame>, stop: &AtomicBool) {
465    let mut buffer = Vec::new();
466    while !stop.load(Ordering::SeqCst) {
467        match next_frame(&mut stream, &mut buffer) {
468            Ok(Some(Frame::Push {
469                correlation_id,
470                payload,
471                ..
472            })) => {
473                if sender
474                    .send(PushedFrame {
475                        correlation_id,
476                        payload,
477                    })
478                    .is_err()
479                {
480                    // The receiver was dropped; nothing will consume further
481                    // pushes, so stop reading.
482                    return;
483                }
484            }
485            // `Some(_)`: any non-Push frame on a push connection is unexpected for
486            // this spike — ignore it rather than tearing the reader down so a stray
487            // frame cannot silently drop subsequent pushes. `None`: a read timeout
488            // with no complete frame. Both just loop to re-check the stop flag.
489            Ok(Some(_) | None) => {}
490            // Connection closed or a fatal read/decode error: end the thread. The
491            // dropped `sender` surfaces as a `Disconnected` on the receiver side.
492            Err(_) => return,
493        }
494    }
495}
496
497/// Reads until one complete frame decodes, treating a read timeout as
498/// `Ok(None)` so the caller can re-check the stop flag without ending the loop.
499fn next_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Option<Frame>, SdkError> {
500    loop {
501        match decode(buffer) {
502            Ok((frame, consumed)) => {
503                buffer.drain(..consumed);
504                return Ok(Some(frame));
505            }
506            Err(
507                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
508            ) => match fill_buffer(stream, buffer)? {
509                FillOutcome::Read => {}
510                FillOutcome::TimedOut => return Ok(None),
511            },
512            Err(error) => return Err(protocol_error(&error)),
513        }
514    }
515}
516
517/// Reads one complete frame, blocking (no timeout tolerance) — used for the
518/// synchronous handshake and worker-registration replies, before the background
519/// reader starts.
520fn read_one_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
521    loop {
522        match decode(buffer) {
523            Ok((frame, consumed)) => {
524                buffer.drain(..consumed);
525                return Ok(frame);
526            }
527            Err(
528                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
529            ) => match fill_buffer(stream, buffer)? {
530                FillOutcome::Read => {}
531                FillOutcome::TimedOut => {
532                    return Err(SdkError::Connection {
533                        description: "push connection timed out waiting for a control-frame reply"
534                            .to_string(),
535                    });
536                }
537            },
538            Err(error) => return Err(protocol_error(&error)),
539        }
540    }
541}
542
543/// Appends one socket read into `buffer`, mapping a read timeout to a non-fatal
544/// [`FillOutcome::TimedOut`] so the reader can poll the stop flag.
545fn fill_buffer(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<FillOutcome, SdkError> {
546    if buffer.len() > MAX_FRAME_BYTES {
547        return Err(SdkError::Protocol {
548            description: format!(
549                "push frame exceeded {MAX_FRAME_BYTES} bytes without a complete frame"
550            ),
551        });
552    }
553    let mut chunk = [0_u8; READ_CHUNK_BYTES];
554    match stream.read(&mut chunk) {
555        Ok(0) => Err(SdkError::Connection {
556            description: "server closed the push connection".to_string(),
557        }),
558        Ok(read) => {
559            let Some(received) = chunk.get(..read) else {
560                return Err(SdkError::Protocol {
561                    description: "push socket read reported more bytes than the buffer holds"
562                        .to_string(),
563                });
564            };
565            buffer.extend_from_slice(received);
566            Ok(FillOutcome::Read)
567        }
568        Err(error)
569            if matches!(
570                error.kind(),
571                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
572            ) =>
573        {
574            Ok(FillOutcome::TimedOut)
575        }
576        Err(error) => Err(SdkError::Connection {
577            description: format!("failed to read from push connection: {error}"),
578        }),
579    }
580}
581
582/// Outcome of one non-fatal socket read attempt.
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584enum FillOutcome {
585    Read,
586    TimedOut,
587}
588
589/// Encodes and writes one frame to the socket, flushing it.
590fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), SdkError> {
591    let len = encoded_len(frame).map_err(|error| protocol_error(&error))?;
592    let mut bytes = vec![0_u8; len];
593    let written = encode(frame, &mut bytes).map_err(|error| protocol_error(&error))?;
594    let encoded = bytes.get(..written).ok_or_else(|| SdkError::Protocol {
595        description: "push wire encoder reported an invalid byte count".to_string(),
596    })?;
597    stream
598        .write_all(encoded)
599        .map_err(|source| SdkError::Connection {
600            description: format!("failed to write push frame: {source}"),
601        })?;
602    stream.flush().map_err(|source| SdkError::Connection {
603        description: format!("failed to flush push frame: {source}"),
604    })
605}
606
607/// Maps a wire codec error into the SDK error taxonomy.
608fn protocol_error(error: &ProtocolError) -> SdkError {
609    SdkError::Protocol {
610        description: format!("push wire codec error: {error}"),
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use liminal::protocol::FrameType;
618
619    #[test]
620    fn pushed_frame_exposes_correlation_and_payload() {
621        let frame = PushedFrame {
622            correlation_id: 7,
623            payload: vec![1, 2, 3],
624        };
625        assert_eq!(frame.correlation_id(), 7);
626        assert_eq!(frame.payload(), &[1, 2, 3]);
627        assert_eq!(frame.into_payload(), vec![1, 2, 3]);
628    }
629
630    #[test]
631    fn publish_frame_round_trips_through_codec() -> Result<(), SdkError> {
632        // The observability publish frame the drain leg writes: a Publish on the
633        // reserved channel carrying opaque payload bytes verbatim.
634        let envelope = MessageEnvelope::new(
635            SchemaId::new([0_u8; SchemaId::WIRE_LEN]),
636            CausalContext::independent(),
637            vec![9, 9, 9],
638        );
639        let frame = Frame::new_publish(APPLICATION_STREAM_ID, OBSERVABILITY_CHANNEL, envelope)
640            .map_err(|error| protocol_error(&error))?;
641        let len = encoded_len(&frame).map_err(|error| protocol_error(&error))?;
642        let mut bytes = vec![0_u8; len];
643        let written = encode(&frame, &mut bytes).map_err(|error| protocol_error(&error))?;
644        let (decoded, consumed) =
645            decode(&bytes[..written]).map_err(|error| protocol_error(&error))?;
646        assert_eq!(consumed, written);
647        assert_eq!(decoded.frame_type(), FrameType::Publish);
648        let Frame::Publish {
649            channel, envelope, ..
650        } = decoded
651        else {
652            return Err(SdkError::Protocol {
653                description: "expected a Publish frame".to_string(),
654            });
655        };
656        assert_eq!(channel, OBSERVABILITY_CHANNEL);
657        assert_eq!(envelope.payload, vec![9, 9, 9]);
658        Ok(())
659    }
660
661    #[test]
662    fn reply_frame_round_trips_through_codec() -> Result<(), SdkError> {
663        let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, 9, vec![4, 5])
664            .map_err(|error| protocol_error(&error))?;
665        let len = encoded_len(&frame).map_err(|error| protocol_error(&error))?;
666        let mut bytes = vec![0_u8; len];
667        let written = encode(&frame, &mut bytes).map_err(|error| protocol_error(&error))?;
668        let (decoded, consumed) =
669            decode(&bytes[..written]).map_err(|error| protocol_error(&error))?;
670        assert_eq!(consumed, written);
671        assert_eq!(decoded.frame_type(), FrameType::PushReply);
672        Ok(())
673    }
674}