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