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