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::ReceiverAlreadyClaimed`] when a receiver handle owns
334    /// reception, or [`SdkError::Connection`] when no push arrives within `timeout` or
335    /// the background reader has stopped (e.g. the server closed the connection).
336    ///
337    /// Once the reader has stopped and every held frame has been taken, this
338    /// returns the error the reader stopped with: the read or decode failure
339    /// itself, or [`SdkError::Backpressure`] naming a [`MailboxOverflow`](crate::MailboxOverflow).
340    pub fn recv_timeout(&self, timeout: Duration) -> Result<PushedFrame, SdkError> {
341        self.mailbox.take_push_timeout(timeout)
342    }
343
344    /// Receives a frame or typed terminal, waiting on arrival without a timer.
345    ///
346    /// # Errors
347    /// Returns the mailbox lock failure or [`SdkError::ReceiverAlreadyClaimed`]
348    /// after a receiver handle has claimed reception.
349    pub fn recv(&self) -> Result<PushReceiveOutcome, SdkError> {
350        receive(&self.mailbox, true)
351    }
352
353    /// Claims the sole, non-cloneable receiver that may outlive this client.
354    ///
355    /// # Errors
356    /// Returns [`SdkError::ReceiverAlreadyClaimed`] after a prior claim or while
357    /// any direct receive is active, or a mailbox lock failure.
358    pub fn receiver_handle(&self) -> Result<PushReceiver, SdkError> {
359        self.mailbox.claim_receiver()?;
360        Ok(PushReceiver {
361            mailbox: Arc::clone(&self.mailbox),
362        })
363    }
364
365    /// The mailbox counters: frames and bytes held now and at peak, how often
366    /// the reader stopped reading because the consumer had not made room, and
367    /// how many frames were refused as too large to ever hold.
368    ///
369    /// # Errors
370    ///
371    /// Returns [`SdkError::Connection`] when the mailbox lock is poisoned.
372    pub fn mailbox_stats(&self) -> Result<MailboxStats, SdkError> {
373        self.mailbox.stats()
374    }
375
376    /// Sends a correlated reply to a pushed frame, echoing its correlation id so
377    /// the server matches the reply back to the originating push.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`SdkError::Protocol`] when the reply frame cannot be encoded and
382    /// [`SdkError::Connection`] when it cannot be written to the socket or the
383    /// writer lock is poisoned.
384    pub fn reply(&self, correlation_id: u64, payload: Vec<u8>) -> Result<(), SdkError> {
385        let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, correlation_id, payload)
386            .map_err(|error| protocol_error(&error))?;
387        let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
388            description: format!("push writer lock poisoned: {error}"),
389        })?;
390        write_frame(&mut writer, &frame)
391    }
392
393    /// A cheap, cloneable handle to this push connection's write half, for
394    /// background tasks that publish out-of-band frames on the same socket without
395    /// owning the full client (which cannot be cloned — it holds the reader thread
396    /// join handle).
397    ///
398    /// The returned [`PushWriter`] shares the client's `Arc<Mutex<TcpStream>>`, so a
399    /// frame it writes travels the SAME connection the server pushes on. It is the
400    /// worker's observability-drain leg: a drain task holds one and publishes each
401    /// [`OBSERVABILITY_CHANNEL`] event live while the client keeps serving pushes.
402    #[must_use]
403    pub fn writer_handle(&self) -> PushWriter {
404        PushWriter {
405            writer: Arc::clone(&self.writer),
406            ledger: Arc::clone(&self.ledger),
407        }
408    }
409
410    /// Publish `payload` to `channel` over this connection (out-of-band from the
411    /// push/reply round trip).
412    ///
413    /// Convenience shorthand for `self.writer_handle().publish(channel, payload)`.
414    ///
415    /// # Errors
416    ///
417    /// Returns [`SdkError::Protocol`] when the publish frame cannot be encoded and
418    /// [`SdkError::Connection`] when it cannot be written to the socket or the
419    /// writer lock is poisoned.
420    pub fn publish(&self, channel: &str, payload: Vec<u8>) -> Result<(), SdkError> {
421        self.writer_handle().publish(channel, payload)
422    }
423
424    /// Awaits the server's verdict for every response-eliciting publish
425    /// written to this connection before the call, bounded by a single
426    /// wall-clock budget (5 s, in the spirit of [`DROP_DRAIN_BUDGET`]) — a
427    /// deadline'd blocking channel receive, never a poll loop.
428    ///
429    /// Publishes to the reserved [`OBSERVABILITY_CHANNEL`] elicit no server
430    /// response by design and are excluded from the flush contract. Responses
431    /// are paired to publishes by FIFO wire order (there is no correlation id
432    /// on the wire), and rejections are returned verbatim in
433    /// [`FlushOutcome::failures`].
434    ///
435    /// `failures.is_empty() && unresolved == 0` is the ONLY proven-accepted
436    /// shape. **Budget expiry with unresolved publishes is a NORMAL outcome
437    /// the caller must inspect ([`FlushOutcome::unresolved`]), never an
438    /// `Err`.** A `flush()` never half-closes the socket — the client stays
439    /// fully usable — so its [`FlushOutcome::mode`] is always
440    /// [`FlushMode::VerdictOnly`]; [`FlushMode::FlushedAndHalfClosed`] can
441    /// only be produced by [`PushClient::close`]. Concurrent flushes
442    /// serialize: a second flush waits on the flush guard, then covers only
443    /// its own write-boundary. A [`Frame::PublishAck`] proves server
444    /// acceptance, never delivery to any subscriber. Verdicts share the socket
445    /// with pushes: while the push mailbox is full the reader is not reading,
446    /// so a caller that stops receiving pushes sees its verdicts unresolved at
447    /// the budget until it receives again.
448    ///
449    /// # Errors
450    ///
451    /// The outer `Err` is reserved for failures of the flush mechanism
452    /// itself: [`SdkError::Connection`] when the flush guard is poisoned, and
453    /// [`SdkError::Protocol`] when more publish responses arrived than
454    /// response-eliciting publishes were written (a broken pairing invariant
455    /// — the flush fails loudly rather than ever mispairing a verdict).
456    pub fn flush(&self) -> Result<FlushOutcome, SdkError> {
457        let (failures, unresolved) = self.ledger.drain(FLUSH_BUDGET)?;
458        Ok(FlushOutcome::new(
459            failures,
460            unresolved,
461            FlushMode::VerdictOnly,
462        ))
463    }
464
465    /// Flush-then-graceful-close: runs [`PushClient::flush`], then tears the
466    /// connection down the way `Drop` does — so the caller learns the verdict
467    /// of every in-flight publish BEFORE the socket goes away, which `Drop`
468    /// structurally cannot report.
469    ///
470    /// As sole owner of the socket the teardown half-closes gracefully (FIN,
471    /// then drain to the server's FIN) and the outcome's mode is
472    /// [`FlushMode::FlushedAndHalfClosed`]. When a live [`PushWriter`] clone
473    /// still shares the socket a write-half shutdown would break the clone's
474    /// publishes, so close collects verdicts only — no FIN — and discloses
475    /// the degradation as [`FlushMode::VerdictOnly`]; a caller that needs the
476    /// FIN guarantee drops the clones first.
477    ///
478    /// # Errors
479    ///
480    /// Exactly [`PushClient::flush`]'s mechanism errors; the teardown itself
481    /// is best-effort and silent, as on `Drop`.
482    pub fn close(self) -> Result<FlushOutcome, SdkError> {
483        // The client receives nothing more, so held and later pushes are
484        // discarded now: a reader waiting on a full mailbox would otherwise
485        // never read the verdicts this drain awaits.
486        self.mailbox.abandon()?;
487        let (failures, unresolved) = self.ledger.drain(FLUSH_BUDGET)?;
488        // Sole owner iff no live `PushWriter` clone shares the write half (the
489        // reader thread holds a raw cloned stream, not this `Arc`).
490        let mode = if Arc::strong_count(&self.writer) == 1 {
491            FlushMode::FlushedAndHalfClosed
492        } else {
493            FlushMode::VerdictOnly
494        };
495        // `Drop` performs the graceful teardown this mode discloses: stop and
496        // join the reader, then drain pending acks — with a write-half FIN as
497        // sole owner, or a bounded best-effort drain (no FIN) over a shared
498        // socket.
499        drop(self);
500        Ok(FlushOutcome::new(failures, unresolved, mode))
501    }
502}
503
504/// A frame or the terminal provenance of one blocking push reception.
505#[derive(Debug)]
506pub enum PushReceiveOutcome {
507    /// One pushed frame, removed from the bounded mailbox.
508    Frame(PushedFrame),
509    /// The connection no longer supplies pushes.
510    Closed {
511        /// Why reception ended.
512        cause: PushCloseCause,
513    },
514}
515
516/// Typed provenance for a closed push receiver.
517#[derive(Debug, Clone, PartialEq, Eq)]
518pub enum PushCloseCause {
519    /// The owning client closed or dropped the connection.
520    OwnerClose,
521    /// The peer sent EOF after a complete frame boundary.
522    PeerClose,
523    /// The reader failed; the original failure is retained.
524    ReaderFailure {
525        /// Original transport, codec or mailbox failure.
526        error: SdkError,
527    },
528}
529
530/// Exclusive reception over the client's existing bounded mailbox.
531///
532/// It cannot be cloned, so one owner drains each pushed frame.
533///
534/// ```compile_fail
535/// fn cloneable<T: Clone>() {}
536/// cloneable::<liminal_sdk::PushReceiver>();
537/// ```
538#[derive(Debug)]
539pub struct PushReceiver {
540    mailbox: Arc<Mailbox<PushedFrame>>,
541}
542
543impl PushReceiver {
544    /// Waits for arrival or closure without a timeout or poll loop.
545    ///
546    /// # Errors
547    /// Returns a mailbox lock failure.
548    pub fn recv(&self) -> Result<PushReceiveOutcome, SdkError> {
549        receive(&self.mailbox, false)
550    }
551}
552
553fn receive(mailbox: &Mailbox<PushedFrame>, direct: bool) -> Result<PushReceiveOutcome, SdkError> {
554    use crate::remote::mailbox::BlockingTake;
555    Ok(match mailbox.take_blocking(direct)? {
556        BlockingTake::Item(frame) => PushReceiveOutcome::Frame(frame),
557        BlockingTake::Abandoned => PushReceiveOutcome::Closed {
558            cause: PushCloseCause::OwnerClose,
559        },
560        BlockingTake::Ended(ReaderEnd::PeerClosed(_)) => PushReceiveOutcome::Closed {
561            cause: PushCloseCause::PeerClose,
562        },
563        BlockingTake::Ended(ReaderEnd::Failed(error)) => PushReceiveOutcome::Closed {
564            cause: PushCloseCause::ReaderFailure { error },
565        },
566        BlockingTake::Ended(ReaderEnd::Stopped) => PushReceiveOutcome::Closed {
567            cause: PushCloseCause::ReaderFailure {
568                error: SdkError::Connection {
569                    description: "push reader stopped without a peer close".to_string(),
570                },
571            },
572        },
573    })
574}
575
576/// A cheap clone of a [`PushClient`]'s write half.
577///
578/// It writes `Frame::Publish` frames on the SAME socket the client receives pushes
579/// on, so a background drain task can stream observability events upstream without a
580/// second connection. Cloning is an `Arc` bump; the underlying socket and its write
581/// lock are shared with the originating [`PushClient`].
582#[derive(Clone, Debug)]
583pub struct PushWriter {
584    writer: Arc<Mutex<TcpStream>>,
585    /// Shared flush accounting: publishes this clone writes are counted so the
586    /// originating client's [`PushClient::flush`] covers them too.
587    ledger: Arc<FlushLedger>,
588}
589
590impl PushWriter {
591    /// Publish `payload` to `channel` on the shared connection.
592    ///
593    /// Writes a single `Frame::Publish` carrying the opaque bytes verbatim (schema
594    /// id zero, an independent causal context — the server routes the reserved
595    /// observability channel straight to its notifier hook, so no schema negotiation
596    /// or ordering context is required). The write takes the shared writer lock, so
597    /// it never interleaves bytes with a concurrent push reply.
598    ///
599    /// # Errors
600    ///
601    /// Returns [`SdkError::Protocol`] when the publish frame cannot be encoded and
602    /// [`SdkError::Connection`] when it cannot be written to the socket or the writer
603    /// lock is poisoned.
604    pub fn publish(&self, channel: &str, payload: Vec<u8>) -> Result<(), SdkError> {
605        let envelope = MessageEnvelope::new(
606            SchemaId::new([0_u8; SchemaId::WIRE_LEN]),
607            CausalContext::independent(),
608            payload,
609        );
610        let frame = Frame::new_publish(APPLICATION_STREAM_ID, channel, envelope)
611            .map_err(|error| protocol_error(&error))?;
612        let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
613            description: format!("push writer lock poisoned: {error}"),
614        })?;
615        write_frame(&mut writer, &frame)?;
616        // Count the publish for the flush contract while still holding the
617        // writer lock, so the count follows wire order. Publishes to the
618        // reserved observability channel elicit no server response by design
619        // and stay OUT of the flush contract.
620        if channel != OBSERVABILITY_CHANNEL {
621            self.ledger.record_written();
622        }
623        Ok(())
624    }
625
626    /// Send a correlated reply to a server push on the shared connection, echoing the
627    /// push's `correlation_id` so the server matches the reply to its push.
628    ///
629    /// Identical wire effect to [`PushClient::reply`], but issued from a cheap
630    /// [`PushWriter`] clone so a BACKGROUND task (e.g. a long-running agent dispatch)
631    /// can answer its own push after it completes, without holding the full client or
632    /// blocking the serve loop. Shares the writer lock, so it never interleaves bytes
633    /// with a concurrent publish or reply.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`SdkError::Protocol`] when the reply frame cannot be encoded and
638    /// [`SdkError::Connection`] when it cannot be written to the socket or the writer
639    /// lock is poisoned.
640    pub fn reply(&self, correlation_id: u64, payload: Vec<u8>) -> Result<(), SdkError> {
641        let frame = Frame::new_push_reply(APPLICATION_STREAM_ID, correlation_id, payload)
642            .map_err(|error| protocol_error(&error))?;
643        let mut writer = self.writer.lock().map_err(|error| SdkError::Connection {
644            description: format!("push writer lock poisoned: {error}"),
645        })?;
646        write_frame(&mut writer, &frame)
647    }
648}
649
650/// Graceful, TOLD teardown.
651///
652/// The reader blocks on socket input with no read window, so nothing but the
653/// socket itself can end its wait — a stop flag it never wakes to sample would
654/// be a lie about how it stops. The half-close IS that tell, and it is the same
655/// act that keeps the close graceful: shutting the write half sends a FIN, so
656/// the server reads and fans out every publish frame still buffered before it,
657/// acks each one, then closes. The reader consumes those acks into the flush
658/// ledger — it is now the drainer — and exits on the server's own FIN.
659///
660/// Reading to EOF is what keeps the final close a FIN rather than a RST: closing
661/// a socket whose receive buffer still holds unread bytes resets the connection,
662/// and on a reset the server's kernel discards the publish frames it has not yet
663/// read, so those fire-and-forget publishes never fan out. That guarantee is
664/// unchanged; only the reader, rather than a separate drain loop, now performs
665/// it. Its wall-clock bound has moved with it, from a per-read deadline plus a
666/// read cap to a single [`DROP_DRAIN_BUDGET`] wait on the reader's own exit — so
667/// a peer that never sends its FIN still cannot wedge drop.
668///
669/// The half-close is taken only when this `PushClient` is the sole owner of the
670/// socket. With a live [`PushWriter`] clone still publishing, a write-half
671/// shutdown would break the clone's writes, so only the read half is shut: that
672/// ends the reader's wait and leaves the clone writing. Nothing reads the
673/// clone's later acks after this point — the degradation already disclosed as
674/// [`FlushMode::VerdictOnly`], and the reason a caller who needs verdicts calls
675/// [`PushClient::close`] before dropping.
676impl Drop for PushClient {
677    fn drop(&mut self) {
678        // Sole owner iff no live `PushWriter` clone shares the write half; the
679        // reader thread holds a raw cloned stream, not this `Arc`.
680        let sole_owner = Arc::strong_count(&self.writer) == 1;
681        // Nothing will receive from here on: release a reader waiting on a full
682        // mailbox and have it discard rather than hold what it reads next. A
683        // poisoned mailbox cannot hold the reader either: its next offer
684        // reports the poison and the reader ends.
685        if self.mailbox.abandon().is_err() {
686            self.shutdown_socket(Shutdown::Both);
687        }
688        if sole_owner {
689            self.shutdown_socket(Shutdown::Write);
690            if !self.await_reader_exit(DROP_DRAIN_BUDGET) {
691                // The peer never closed inside the budget. End the reader's wait
692                // at the socket so drop stays bounded rather than hanging on a
693                // FIN that is not coming.
694                self.shutdown_socket(Shutdown::Both);
695            }
696        } else {
697            self.shutdown_socket(Shutdown::Read);
698        }
699        if let Some(reader) = self.reader.take() {
700            reader.join().ok();
701        }
702    }
703}
704
705/// Opens and configures the push-client socket (Nagle off, the caller-selected
706/// maximum wait for one setup read, a bounded write timeout) before any framing.
707fn connect_socket(address: &str, setup_deadline: Duration) -> Result<TcpStream, SdkError> {
708    let stream = TcpStream::connect(address).map_err(|source| SdkError::Connection {
709        description: format!("failed to connect push client to {address}: {source}"),
710    })?;
711    stream
712        .set_nodelay(true)
713        .map_err(|source| SdkError::Connection {
714            description: format!("failed to disable Nagle for {address}: {source}"),
715        })?;
716    // The named deadline for a synchronous control-frame reply, and nothing
717    // else: it covers the `Connect`/`ConnectAck` and
718    // `WorkerRegister`/`WorkerRegisterAck` exchanges that run on the CALLING
719    // thread, and `start_reader` takes it back off before the background reader
720    // ever sees the socket.
721    stream
722        .set_read_timeout(Some(setup_deadline))
723        .map_err(|source| SdkError::Connection {
724            description: format!("failed to set the push setup deadline for {address}: {source}"),
725        })?;
726    stream
727        .set_write_timeout(Some(WRITE_TIMEOUT))
728        .map_err(|source| SdkError::Connection {
729            description: format!("failed to set push write timeout for {address}: {source}"),
730        })?;
731    Ok(stream)
732}
733
734/// Drives the synchronous worker-registration round trip
735/// (`WorkerRegister` -> `WorkerRegisterAck`) on a handshaken socket, before the
736/// background reader is spawned.
737///
738/// A `Rejected` ack maps to a typed [`SdkError::Protocol`] carrying the server's
739/// reason; any non-ack reply is a protocol error.
740pub(super) fn register(
741    stream: &mut TcpStream,
742    registration: WorkerRegistration,
743    setup_deadline: Duration,
744) -> Result<(), SdkError> {
745    let frame = Frame::WorkerRegister {
746        flags: 0,
747        registration,
748    };
749    write_frame(stream, &frame)?;
750    let mut buffer = Vec::new();
751    match read_one_frame(stream, &mut buffer, setup_deadline)? {
752        Frame::WorkerRegisterAck {
753            outcome: WorkerRegisterOutcome::Accepted,
754            ..
755        } => Ok(()),
756        Frame::WorkerRegisterAck {
757            outcome: WorkerRegisterOutcome::Rejected { reason },
758            ..
759        } => Err(SdkError::Protocol {
760            description: format!("server rejected worker registration: {reason}"),
761        }),
762        other => Err(SdkError::Protocol {
763            description: format!(
764                "expected WorkerRegisterAck during registration, received {:?}",
765                other.frame_type()
766            ),
767        }),
768    }
769}
770
771/// Drives the client handshake (`Connect` -> `ConnectAck`) on a fresh socket,
772/// carrying `auth_token` (empty for an open, non-auth server).
773fn handshake(
774    stream: &mut TcpStream,
775    auth_token: &[u8],
776    setup_deadline: Duration,
777) -> Result<(), SdkError> {
778    let connect = Frame::Connect {
779        flags: 0,
780        min_version: CLIENT_MIN_VERSION,
781        max_version: CLIENT_MAX_VERSION,
782        auth_token: auth_token.to_vec(),
783    };
784    write_frame(stream, &connect)?;
785    let mut buffer = Vec::new();
786    match read_one_frame(stream, &mut buffer, setup_deadline)? {
787        Frame::ConnectAck { .. } => Ok(()),
788        Frame::ConnectError {
789            reason_code,
790            message,
791            ..
792        } => Err(SdkError::Connection {
793            description: format!(
794                "server rejected push connection (reason {reason_code}): {}",
795                message.unwrap_or_else(|| "no detail".to_string())
796            ),
797        }),
798        other => Err(SdkError::Protocol {
799            description: format!(
800                "expected ConnectAck during push handshake, received {:?}",
801                other.frame_type()
802            ),
803        }),
804    }
805}
806
807/// Background loop: drains the socket, surfacing each `Push` frame in
808/// `mailbox` and each publish verdict (`PublishAck`/`PublishError`) on
809/// `verdicts` in wire order for the flush contract.
810///
811/// The socket carries no read window here, so the loop blocks until the server
812/// sends or the connection ends: nothing wakes it on a timer and nothing sweeps.
813/// A full mailbox holds the loop inside [`Mailbox::offer`], so the socket is
814/// not read until the consumer makes room. It returns (ending the thread) with
815/// the reason it stopped: the connection closed — including the `shutdown`
816/// teardown performs — the consumer went away, a frame could never fit the
817/// mailbox, or a read or decode failed.
818fn run_reader(
819    mut stream: TcpStream,
820    mailbox: &Mailbox<PushedFrame>,
821    verdicts: &Sender<PublishVerdict>,
822    ledger: &FlushLedger,
823) -> ReaderEnd {
824    let mut buffer = Vec::new();
825    loop {
826        match next_frame(&mut stream, &mut buffer) {
827            Ok(Some(Frame::Push {
828                correlation_id,
829                payload,
830                ..
831            })) => {
832                let pushed = PushedFrame {
833                    correlation_id,
834                    payload,
835                };
836                if let Some(end) = offer_or_end(mailbox, pushed) {
837                    return end;
838                }
839            }
840            // The server's per-publish verdicts: captured and forwarded in wire
841            // order (never discarded — they are what `flush()` awaits). The
842            // verdict receiver lives in the client, so a failed send means the
843            // client is gone.
844            Ok(Some(Frame::PublishAck { .. })) => {
845                if verdicts.send(PublishVerdict::Accepted).is_err() {
846                    return ReaderEnd::Stopped;
847                }
848                ledger.record_arrival();
849            }
850            Ok(Some(Frame::PublishError {
851                reason_code,
852                message,
853                ..
854            })) => {
855                let rejection = PublishRejection::new(reason_code, message);
856                if verdicts.send(PublishVerdict::Rejected(rejection)).is_err() {
857                    return ReaderEnd::Stopped;
858                }
859                ledger.record_arrival();
860            }
861            // Any other frame on a push connection is unexpected — set aside
862            // and counted rather than tearing the reader down, so a stray
863            // frame cannot drop the pushes behind it.
864            Ok(None) => return ReaderEnd::PeerClosed(peer_closed()),
865            Ok(Some(_)) => {
866                if let Err(error) = mailbox.record_ignored_frame() {
867                    return ReaderEnd::Failed(error);
868                }
869            }
870            // Connection closed or a fatal read/decode error: end the thread,
871            // carrying the error itself. The consumer receives it once every
872            // held frame has been taken, and teardown learns from the same
873            // record that the reader is done.
874            Err(error) => return ReaderEnd::Failed(error),
875        }
876    }
877}
878
879/// Reads until one complete frame decodes on the windowless steady-state socket.
880///
881/// There is no read window to expire here, so a [`FillOutcome::TimedOut`] would
882/// mean one was re-armed behind the reader's back. That is reported as the
883/// invariant break it is, rather than swallowed into a spin — a reader that
884/// looped on it would be a busy-wait, which is worse than the cadence this
885/// retired.
886fn next_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Option<Frame>, SdkError> {
887    loop {
888        match decode(buffer) {
889            Ok((frame, consumed)) => {
890                buffer.drain(..consumed);
891                return Ok(Some(frame));
892            }
893            Err(
894                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
895            ) => match fill_buffer(stream, buffer)? {
896                FillOutcome::Read => {}
897                FillOutcome::Closed if buffer.is_empty() => return Ok(None),
898                FillOutcome::Closed => return Err(peer_closed()),
899                FillOutcome::TimedOut => {
900                    return Err(SdkError::Connection {
901                        description: "the push reader's steady-state socket reported a read \
902                                      deadline it should not carry"
903                            .to_string(),
904                    });
905                }
906            },
907            Err(error) => return Err(protocol_error(&error)),
908        }
909    }
910}
911
912/// Reads one complete control-frame reply under the caller-selected wall-clock
913/// deadline — used for the synchronous handshake and worker-registration replies,
914/// on the calling thread, before the background reader starts.
915///
916/// A socket read window elapsing is NOT the end: the reply may simply be slow, or
917/// arriving in pieces. Only the total deadline for this reply ends the wait. The
918/// shape this replaces died on the FIRST elapsed window, which — composed with a
919/// 100 ms reader poll cadence armed before the handshake — made connect fatal to
920/// any peer slower than 100 ms. Nobody chose that.
921fn read_one_frame(
922    stream: &mut TcpStream,
923    buffer: &mut Vec<u8>,
924    setup_deadline: Duration,
925) -> Result<Frame, SdkError> {
926    let deadline = Instant::now() + setup_deadline;
927    loop {
928        match decode(buffer) {
929            Ok((frame, consumed)) => {
930                buffer.drain(..consumed);
931                return Ok(frame);
932            }
933            Err(
934                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
935            ) => match fill_buffer(stream, buffer)? {
936                FillOutcome::Read => {}
937                FillOutcome::Closed => return Err(peer_closed()),
938                FillOutcome::TimedOut => {
939                    if Instant::now() >= deadline {
940                        return Err(SdkError::Connection {
941                            description:
942                                "push connection timed out waiting for a control-frame reply"
943                                    .to_string(),
944                        });
945                    }
946                }
947            },
948            Err(error) => return Err(protocol_error(&error)),
949        }
950    }
951}
952
953/// Appends one socket read into `buffer`, mapping a read timeout to a non-fatal
954/// [`FillOutcome::TimedOut`] so the setup reader can weigh it against its
955/// deadline.
956fn fill_buffer(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<FillOutcome, SdkError> {
957    if buffer.len() > MAX_FRAME_BYTES {
958        return Err(SdkError::Protocol {
959            description: format!(
960                "push frame exceeded {MAX_FRAME_BYTES} bytes without a complete frame"
961            ),
962        });
963    }
964    let mut chunk = [0_u8; READ_CHUNK_BYTES];
965    match stream.read(&mut chunk) {
966        Ok(0) => Ok(FillOutcome::Closed),
967        Ok(read) => {
968            let Some(received) = chunk.get(..read) else {
969                return Err(SdkError::Protocol {
970                    description: "push socket read reported more bytes than the buffer holds"
971                        .to_string(),
972                });
973            };
974            buffer.extend_from_slice(received);
975            Ok(FillOutcome::Read)
976        }
977        Err(error)
978            if matches!(
979                error.kind(),
980                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
981            ) =>
982        {
983            Ok(FillOutcome::TimedOut)
984        }
985        Err(error) => Err(SdkError::Connection {
986            description: format!("failed to read from push connection: {error}"),
987        }),
988    }
989}
990
991/// Outcome of one non-fatal socket read attempt.
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
993enum FillOutcome {
994    Closed,
995    Read,
996    TimedOut,
997}
998
999fn peer_closed() -> SdkError {
1000    SdkError::Connection {
1001        description: "server closed the push connection".to_string(),
1002    }
1003}
1004
1005/// Encodes and writes one frame to the socket, flushing it.
1006fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), SdkError> {
1007    let len = encoded_len(frame).map_err(|error| protocol_error(&error))?;
1008    let mut bytes = vec![0_u8; len];
1009    let written = encode(frame, &mut bytes).map_err(|error| protocol_error(&error))?;
1010    let encoded = bytes.get(..written).ok_or_else(|| SdkError::Protocol {
1011        description: "push wire encoder reported an invalid byte count".to_string(),
1012    })?;
1013    stream
1014        .write_all(encoded)
1015        .map_err(|source| SdkError::Connection {
1016            description: format!("failed to write push frame: {source}"),
1017        })?;
1018    stream.flush().map_err(|source| SdkError::Connection {
1019        description: format!("failed to flush push frame: {source}"),
1020    })
1021}
1022
1023/// Maps a wire codec error into the SDK error taxonomy.
1024fn protocol_error(error: &ProtocolError) -> SdkError {
1025    SdkError::Protocol {
1026        description: format!("push wire codec error: {error}"),
1027    }
1028}
1029
1030#[cfg(test)]
1031#[path = "push_client_tests.rs"]
1032mod tests;
1033
1034#[cfg(test)]
1035#[path = "push_client_mailbox_tests.rs"]
1036mod mailbox_tests;
1037
1038#[cfg(test)]
1039#[path = "push_client_receive_tests.rs"]
1040mod receive_tests;