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