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