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