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