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