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;