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