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