liminal_sdk/remote/tcp/subscription.rs
1//! Client-side subscription stream: the receive half of the delivery pump.
2//!
3//! Where [`PushClient`](super::push_client::PushClient) consumes server-initiated
4//! *pushes*, a [`SubscriptionStream`] consumes server-initiated *deliveries*: the
5//! server writes a [`Frame::Deliver`] on the subscription's stream every time a
6//! message is published to the subscribed channel. This client owns a dedicated
7//! connection whose socket is drained by a background reader thread that routes
8//! each `Deliver` into a bounded mailbox the caller pulls with
9//! [`SubscriptionStream::recv_timeout`]; a full mailbox stops the reader
10//! reading the socket rather than dropping or holding without limit.
11//!
12//! # v1 shape
13//!
14//! One subscription per dedicated connection. Multiplexing several subscriptions
15//! over one connection arrives with the v2 credit mode (which also adds explicit
16//! per-delivery acks); until then a `SubscriptionStream` is a single channel
17//! subscription bound to its own socket, mirroring the one-connection-per-role
18//! shape the `PushClient` already uses.
19
20use alloc::format;
21use alloc::string::ToString;
22use alloc::sync::Arc;
23use alloc::vec;
24use alloc::vec::Vec;
25use core::time::Duration;
26
27use std::io::{Read, Write};
28use std::net::{Shutdown, TcpStream};
29use std::thread::JoinHandle;
30use std::time::Instant;
31
32use liminal::protocol::{
33 DELIVER_MESSAGE_ID_VERSION, DeliveredId, Frame, MessageId, ProtocolError, ProtocolVersion,
34 SchemaId, WorkerRegistration, decode, encode, encoded_len,
35};
36
37use crate::SdkError;
38use crate::remote::SETUP_TIMEOUT;
39use crate::remote::mailbox::{
40 Mailbox, MailboxItem, MailboxLimits, MailboxStats, MailboxWords, ReaderEnd, offer_or_end,
41};
42
43/// The subscription's words in the errors its mailbox hands the consumer.
44const SUBSCRIPTION_WORDS: MailboxWords = MailboxWords {
45 receive: "subscription receive failed",
46 item: "delivery",
47 reader: "subscription reader",
48};
49
50/// Minimum protocol version this client advertises during the handshake.
51const CLIENT_MIN_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
52/// Maximum protocol version this client advertises during the handshake; the
53/// server selects the highest version both support, so 1.0 stays offered.
54const CLIENT_MAX_VERSION: ProtocolVersion = DELIVER_MESSAGE_ID_VERSION;
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/// Upper bound on a single buffered frame, guarding against runaway buffering.
60const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024;
61/// The single application stream this subscription's deliveries ride on. One
62/// subscription per connection in v1, so a fixed stream id is sufficient.
63const SUBSCRIPTION_STREAM_ID: u32 = 1;
64/// In-flight window advertised on subscribe. The v1 server does not gate delivery
65/// on credit, so this is advisory; a generous value avoids any future pacing
66/// surprise while the credit mode is still v2 work.
67const SUBSCRIBE_MAX_IN_FLIGHT: u32 = 1024;
68
69/// A message the server delivered on this subscription.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct DeliveredMessage {
72 delivery_seq: u64,
73 schema_id: SchemaId,
74 payload: Vec<u8>,
75 /// The server's id for the message and its boot, when a 1.2 server sent it.
76 message: Option<DeliveredId>,
77}
78
79impl MailboxItem for DeliveredMessage {
80 fn payload_len(&self) -> usize {
81 self.payload.len()
82 }
83}
84
85impl DeliveredMessage {
86 /// The per-subscription monotonic delivery sequence (starts at 1). The anchor
87 /// the future ack/resume protocol will acknowledge against.
88 #[must_use]
89 pub const fn delivery_seq(&self) -> u64 {
90 self.delivery_seq
91 }
92
93 /// The schema id the server selected for this subscription's stream.
94 #[must_use]
95 pub const fn schema_id(&self) -> SchemaId {
96 self.schema_id
97 }
98
99 /// The delivered payload bytes.
100 #[must_use]
101 pub fn payload(&self) -> &[u8] {
102 &self.payload
103 }
104
105 /// Consumes the message, returning the owned payload bytes.
106 #[must_use]
107 pub fn into_payload(self) -> Vec<u8> {
108 self.payload
109 }
110
111 /// The server's id for this message and the boot it was drawn in, when the
112 /// server sent it (protocol 1.2, Fable 4c). `None` from an older server,
113 /// and for a durable message replayed from the store.
114 #[must_use]
115 pub const fn message(&self) -> Option<DeliveredId> {
116 self.message
117 }
118
119 /// The causal parent a reply to this message names, `<boot>:<id>`, or
120 /// `None` when the message carried no id: never an invented one.
121 #[must_use]
122 pub fn reply_parent(&self) -> Option<MessageId> {
123 self.message.map(DeliveredId::reply_parent)
124 }
125}
126
127/// A connected subscription whose background reader surfaces delivered messages.
128///
129/// Construct with [`SubscriptionStream::open`]; the background reader starts
130/// immediately and runs until the stream is dropped. Pull delivered messages with
131/// [`SubscriptionStream::recv_timeout`].
132#[derive(Debug)]
133pub struct SubscriptionStream {
134 /// Write half, used only by setup and the best-effort teardown on drop.
135 writer: TcpStream,
136 /// Server-assigned subscription id, echoed on `Unsubscribe` at teardown.
137 subscription_id: u64,
138 /// Delivered messages surfaced by the background reader, bounded in count
139 /// and bytes, then the one typed end the reader stopped with. A
140 /// `SubscribeError` arriving mid-stream is the ONLY explanation the
141 /// consumer will ever get for deliveries stopping, so it is that end,
142 /// received after every delivery before it (P0 #55).
143 mailbox: Arc<Mailbox<DeliveredMessage>>,
144 /// Background reader handle, joined on drop.
145 reader: Option<JoinHandle<()>>,
146}
147
148impl SubscriptionStream {
149 /// Connects to `address`, performs the handshake, subscribes to `channel`, and
150 /// starts the background reader that drains delivered messages.
151 ///
152 /// `accepted_schemas` is the client's schema-compatibility list; pass an empty
153 /// vector to let the server select the channel's configured schema (the
154 /// server's negotiation contract).
155 ///
156 /// # Errors
157 ///
158 /// Returns [`SdkError::Connection`] when the TCP connection or socket
159 /// configuration fails, and [`SdkError::Protocol`] when the handshake or
160 /// subscribe is rejected, or the socket cannot be cloned for the reader thread.
161 pub fn open(
162 address: &str,
163 channel: &str,
164 accepted_schemas: Vec<SchemaId>,
165 ) -> Result<Self, SdkError> {
166 Self::open_with_auth(address, channel, accepted_schemas, &[])
167 }
168
169 /// Connects, handshakes carrying `auth_token`, subscribes to `channel`, and
170 /// starts the background reader.
171 ///
172 /// A subscription owns a dedicated connection (the v1 shape), so it presents
173 /// its own credential in its own `Connect` frame; the token a
174 /// request/response transport was built with lives on that transport's
175 /// socket and cannot travel here. Additive to [`open`]: an empty token is
176 /// exactly the open-access handshake `open` performs, so an ungated server
177 /// sees byte-identical bytes either way.
178 ///
179 /// The server compares the token during the handshake and answers a
180 /// mismatch with `ConnectError` before closing, which surfaces here as
181 /// [`SdkError::Connection`].
182 ///
183 /// `accepted_schemas` is the client's schema-compatibility list; pass an
184 /// empty vector to let the server select the channel's configured schema.
185 ///
186 /// # Errors
187 ///
188 /// Returns [`SdkError::Connection`] when the TCP connection or socket
189 /// configuration fails or the token is rejected, and [`SdkError::Protocol`]
190 /// when the subscribe is rejected, or the socket cannot be cloned for the
191 /// reader thread.
192 ///
193 /// [`open`]: Self::open
194 pub fn open_with_auth(
195 address: &str,
196 channel: &str,
197 accepted_schemas: Vec<SchemaId>,
198 auth_token: &[u8],
199 ) -> Result<Self, SdkError> {
200 Self::open_with_limits(
201 address,
202 channel,
203 accepted_schemas,
204 auth_token,
205 MailboxLimits::SUBSCRIPTION,
206 )
207 }
208
209 /// [`Self::open_with_auth`] with the mailbox bounds supplied.
210 fn open_with_limits(
211 address: &str,
212 channel: &str,
213 accepted_schemas: Vec<SchemaId>,
214 auth_token: &[u8],
215 limits: MailboxLimits,
216 ) -> Result<Self, SdkError> {
217 Self::open_configured(address, channel, accepted_schemas, auth_token, None, limits)
218 }
219
220 /// Connects, handshakes, registers the connection as the worker
221 /// `registration` names, then subscribes to `channel`, so the server can
222 /// tell which worker the subscription belongs to (a subscription owns its
223 /// own connection, so the worker's push connection cannot say it).
224 ///
225 /// # Errors
226 ///
227 /// As [`open`], and [`SdkError::Protocol`] when the server rejects the
228 /// registration (the reason is carried in the error).
229 ///
230 /// [`open`]: Self::open
231 pub fn open_registered(
232 address: &str,
233 channel: &str,
234 accepted_schemas: Vec<SchemaId>,
235 registration: WorkerRegistration,
236 ) -> Result<Self, SdkError> {
237 Self::open_configured(
238 address,
239 channel,
240 accepted_schemas,
241 &[],
242 Some(registration),
243 MailboxLimits::SUBSCRIPTION,
244 )
245 }
246
247 fn open_configured(
248 address: &str,
249 channel: &str,
250 accepted_schemas: Vec<SchemaId>,
251 auth_token: &[u8],
252 registration: Option<WorkerRegistration>,
253 limits: MailboxLimits,
254 ) -> Result<Self, SdkError> {
255 let mut stream = connect_socket(address)?;
256 // A single buffer threads through the whole synchronous setup so any bytes
257 // the setup reads past the control-frame reply are preserved. The server
258 // may coalesce a `SubscribeAck` with the first `Deliver` frames into one TCP
259 // segment (the delivery pump runs in the same slice that acks the
260 // subscribe), and a socket read pulls up to `READ_CHUNK_BYTES` at once — so
261 // this buffer can hold whole (or partial) `Deliver` frames after the ack.
262 // Handing that residue to the reader thread is what keeps those deliveries
263 // from being dropped and, worse, from desyncing a reader that would
264 // otherwise start mid-frame on a fresh empty buffer.
265 let mut buffer = Vec::new();
266 handshake(&mut stream, &mut buffer, auth_token)?;
267 if let Some(registration) = registration {
268 super::push_client::register(&mut stream, registration, SETUP_TIMEOUT)?;
269 }
270 let subscription_id = subscribe(&mut stream, &mut buffer, channel, accepted_schemas)?;
271
272 // The control exchange is over, so its deadline comes off: the reader
273 // blocks on socket input with no read window at all. Teardown shuts the
274 // socket down, which surfaces as a typed terminal — the socket signals,
275 // nothing sweeps. A window left armed here would be a wake cadence in
276 // steady state, which is the defect this retires, whatever period it
277 // carried.
278 stream
279 .set_read_timeout(None)
280 .map_err(|source| SdkError::Connection {
281 description: format!("failed to clear the subscription read deadline: {source}"),
282 })?;
283 let read_stream = stream.try_clone().map_err(|source| SdkError::Protocol {
284 description: format!("failed to clone subscription socket for reader thread: {source}"),
285 })?;
286 Self::start(stream, read_stream, buffer, subscription_id, limits)
287 }
288
289 /// Starts the background reader over a subscribed stream, its setup
290 /// residue, and a mailbox with `limits`.
291 fn start(
292 stream: TcpStream,
293 read_stream: TcpStream,
294 buffer: Vec<u8>,
295 subscription_id: u64,
296 limits: MailboxLimits,
297 ) -> Result<Self, SdkError> {
298 let mailbox = Arc::new(Mailbox::new(limits, SUBSCRIPTION_WORDS));
299 let reader_mailbox = Arc::clone(&mailbox);
300 let reader = std::thread::Builder::new()
301 .name("liminal-subscription-reader".to_string())
302 .spawn(move || {
303 let end = run_reader(read_stream, buffer, &reader_mailbox);
304 reader_mailbox.close(end);
305 })
306 .map_err(|source| SdkError::Protocol {
307 description: format!("failed to start subscription reader thread: {source}"),
308 })?;
309
310 Ok(Self {
311 writer: stream,
312 subscription_id,
313 mailbox,
314 reader: Some(reader),
315 })
316 }
317
318 /// Blocks up to `timeout` for the next delivered message from the server.
319 ///
320 /// # Errors
321 ///
322 /// Returns [`SdkError::Connection`] when no message arrives within
323 /// `timeout`. Once the reader has stopped and every held delivery has been
324 /// taken, returns the error it stopped with: the server's mid-stream
325 /// `SubscribeError`, its `Disconnect`, the read or decode failure itself,
326 /// or [`SdkError::Backpressure`] naming a delivery too large for the
327 /// mailbox.
328 pub fn recv_timeout(&self, timeout: Duration) -> Result<DeliveredMessage, SdkError> {
329 self.mailbox.take(timeout)
330 }
331
332 /// The mailbox counters: deliveries and bytes held now and at peak, how
333 /// often the reader stopped reading for a consumer that had not made
334 /// room, refused and stray frames, and errors no consumer was left to
335 /// receive.
336 ///
337 /// # Errors
338 ///
339 /// Returns [`SdkError::Connection`] when the mailbox lock is poisoned.
340 pub fn stats(&self) -> Result<MailboxStats, SdkError> {
341 self.mailbox.stats()
342 }
343
344 /// The server-assigned id for this subscription.
345 #[must_use]
346 pub const fn subscription_id(&self) -> u64 {
347 self.subscription_id
348 }
349}
350
351impl Drop for SubscriptionStream {
352 fn drop(&mut self) {
353 // Best-effort clean teardown: tell the server to drop the subscription and
354 // close the connection. Failures are ignored — the connection close alone
355 // frees the server-side subscription when its subscriber process exits.
356 let unsubscribe = Frame::Unsubscribe {
357 flags: 0,
358 stream_id: SUBSCRIPTION_STREAM_ID,
359 subscription_id: self.subscription_id,
360 };
361 let _ = write_frame(&mut self.writer, &unsubscribe);
362 let _ = write_frame(&mut self.writer, &Frame::Disconnect { flags: 0 });
363 // Then TELL the reader. It blocks on socket input with no read window, so
364 // nothing but the socket can end its wait — a stop flag it never wakes to
365 // sample would be a lie about how it stops. Shutting the socket down
366 // surfaces a typed terminal to the blocked reader, exactly as the
367 // WebSocket sibling does, and the shutdown of the write half flushes the
368 // frames just written before its FIN. The join is therefore bounded by the
369 // shutdown, not by a peer's goodwill.
370 let _ = self.writer.shutdown(Shutdown::Both);
371 // Nothing receives from here on: release a reader waiting on a full
372 // mailbox so the join cannot wait on a consumer that has gone.
373 self.mailbox.release();
374 if let Some(reader) = self.reader.take() {
375 reader.join().ok();
376 }
377 }
378}
379
380/// Opens and configures the subscription socket (Nagle off, bounded read/write
381/// timeouts) before any framing.
382fn connect_socket(address: &str) -> Result<TcpStream, SdkError> {
383 let stream = TcpStream::connect(address).map_err(|source| SdkError::Connection {
384 description: format!("failed to connect subscription client to {address}: {source}"),
385 })?;
386 stream
387 .set_nodelay(true)
388 .map_err(|source| SdkError::Connection {
389 description: format!("failed to disable Nagle for {address}: {source}"),
390 })?;
391 // The named deadline for a synchronous control-frame reply, and nothing
392 // else: it covers the `Connect`/`ConnectAck` and `Subscribe`/`SubscribeAck`
393 // exchanges that run on the calling thread, and `open` takes it back off
394 // before the background reader ever sees the socket.
395 stream
396 .set_read_timeout(Some(SETUP_TIMEOUT))
397 .map_err(|source| SdkError::Connection {
398 description: format!(
399 "failed to set the subscription setup deadline for {address}: {source}"
400 ),
401 })?;
402 stream
403 .set_write_timeout(Some(WRITE_TIMEOUT))
404 .map_err(|source| SdkError::Connection {
405 description: format!(
406 "failed to set subscription write timeout for {address}: {source}"
407 ),
408 })?;
409 Ok(stream)
410}
411
412/// Drives the client handshake (`Connect` -> `ConnectAck`) on a fresh socket,
413/// presenting `auth_token` (empty for an open, non-auth server).
414///
415/// `buffer` carries any residue read past the reply forward to the next setup step
416/// (and ultimately the reader thread) rather than discarding it.
417fn handshake(
418 stream: &mut TcpStream,
419 buffer: &mut Vec<u8>,
420 auth_token: &[u8],
421) -> Result<(), SdkError> {
422 let connect = Frame::Connect {
423 flags: 0,
424 min_version: CLIENT_MIN_VERSION,
425 max_version: CLIENT_MAX_VERSION,
426 auth_token: auth_token.to_vec(),
427 };
428 write_frame(stream, &connect)?;
429 match read_one_frame(stream, buffer)? {
430 Frame::ConnectAck { .. } => Ok(()),
431 Frame::ConnectError {
432 reason_code,
433 message,
434 ..
435 } => Err(SdkError::Connection {
436 description: format!(
437 "server rejected subscription connection (reason {reason_code}): {}",
438 message.unwrap_or_else(|| "no detail".to_string())
439 ),
440 }),
441 other => Err(SdkError::Protocol {
442 description: format!(
443 "expected ConnectAck during subscription handshake, received {:?}",
444 other.frame_type()
445 ),
446 }),
447 }
448}
449
450/// Drives the synchronous subscribe round trip (`Subscribe` -> `SubscribeAck`) on
451/// a handshaken socket, returning the server-assigned subscription id.
452fn subscribe(
453 stream: &mut TcpStream,
454 buffer: &mut Vec<u8>,
455 channel: &str,
456 accepted_schemas: Vec<SchemaId>,
457) -> Result<u64, SdkError> {
458 let frame = Frame::Subscribe {
459 flags: 0,
460 stream_id: SUBSCRIPTION_STREAM_ID,
461 channel: channel.to_string(),
462 accepted_schemas,
463 max_in_flight: SUBSCRIBE_MAX_IN_FLIGHT,
464 };
465 write_frame(stream, &frame)?;
466 match read_one_frame(stream, buffer)? {
467 Frame::SubscribeAck {
468 subscription_id, ..
469 } => Ok(subscription_id),
470 Frame::SubscribeError {
471 reason_code,
472 message,
473 ..
474 } => Err(SdkError::Protocol {
475 description: format!(
476 "server rejected subscribe (reason {reason_code}): {}",
477 message.unwrap_or_else(|| "no detail".to_string())
478 ),
479 }),
480 other => Err(SdkError::Protocol {
481 description: format!(
482 "expected SubscribeAck during subscribe, received {:?}",
483 other.frame_type()
484 ),
485 }),
486 }
487}
488
489/// Background loop: drains the socket, surfacing each `Deliver` frame's message in
490/// `mailbox`, and returns the end the consumer receives after them.
491///
492/// The socket carries no read window here, so the loop blocks until the server
493/// sends or the connection ends: nothing wakes it on a timer and nothing sweeps.
494/// A full mailbox holds the loop inside its offer, so the socket is not read
495/// until the consumer makes room. It returns (ending the thread) when the
496/// connection closes — including the `shutdown` teardown performs — when a
497/// `Disconnect` or `SubscribeError` arrives, when a delivery could never fit
498/// the mailbox, or on a fatal decode/IO error, carrying that reason.
499///
500/// `buffer` is seeded with the setup residue (see [`SubscriptionStream::open`]): any
501/// `Deliver` bytes the synchronous subscribe read past the `SubscribeAck` are
502/// already here, so the loop decodes them first — before its next socket read —
503/// instead of losing them and starting mid-stream.
504fn run_reader(
505 mut stream: TcpStream,
506 mut buffer: Vec<u8>,
507 mailbox: &Mailbox<DeliveredMessage>,
508) -> ReaderEnd {
509 loop {
510 // Connection closed or a fatal read/decode error: end the thread,
511 // carrying the error itself to the consumer.
512 let frame = match next_frame(&mut stream, &mut buffer) {
513 Ok(frame) => frame,
514 Err(error) => return ReaderEnd::Failed(error),
515 };
516 match frame {
517 Frame::Deliver {
518 delivery_seq,
519 envelope,
520 message,
521 ..
522 } => {
523 let message = DeliveredMessage {
524 delivery_seq,
525 schema_id: envelope.schema_id,
526 payload: envelope.payload,
527 message,
528 };
529 if let Some(end) = offer_or_end(mailbox, message) {
530 return end;
531 }
532 }
533 // A server `Disconnect` ends the subscription cleanly, and the
534 // consumer is told so by name.
535 Frame::Disconnect { .. } => {
536 return ReaderEnd::Failed(SdkError::Connection {
537 description: "subscription receive failed: the server disconnected the \
538 subscription"
539 .to_string(),
540 });
541 }
542 // A `SubscribeError` arriving AFTER setup is the server ending this
543 // subscription -- the overflow shed sends exactly this and then
544 // releases the subscription at the channel actor, so no further
545 // delivery can ever arrive. It is surfaced to the consumer and ends
546 // the reader (P0 #55).
547 //
548 // This is the one exception to the stray-frame rule below, and the
549 // distinction is deliveries: ignoring a stray frame protects the
550 // deliveries still to come, and here there are none. Dropping this
551 // frame is what left a shed subscriber unable to tell "the server
552 // dropped me" from "nothing was published".
553 Frame::SubscribeError {
554 reason_code,
555 message,
556 ..
557 } => {
558 // A consumer already gone is counted by the mailbox's close.
559 return ReaderEnd::Failed(subscription_ended(reason_code, message));
560 }
561 // Any other frame on a subscription connection is unexpected; it is
562 // set aside and counted rather than tearing the reader down, so a
563 // stray frame cannot drop the deliveries behind it.
564 _ => {
565 if let Err(error) = mailbox.record_ignored_frame() {
566 return ReaderEnd::Failed(error);
567 }
568 }
569 }
570 }
571}
572
573/// Builds the typed terminal for a `SubscribeError` the server sent mid-stream.
574///
575/// The server's own detail is carried VERBATIM: it is the only text that says
576/// which limit ended the subscription, and a client that paraphrased it would
577/// leave an operator correlating a client log against a server log by guesswork.
578fn subscription_ended(reason_code: u16, message: Option<alloc::string::String>) -> SdkError {
579 SdkError::Protocol {
580 description: format!(
581 "server ended the subscription (reason {reason_code}): {}",
582 message.unwrap_or_else(|| "no detail".to_string())
583 ),
584 }
585}
586
587/// Reads until one complete frame decodes on the windowless steady-state socket.
588///
589/// There is no read window to expire here, so a [`FillOutcome::TimedOut`] would
590/// mean one was re-armed behind the reader's back. That is reported as the
591/// invariant break it is, rather than swallowed into a spin — a reader that
592/// looped on it would be a busy-wait, which is worse than the cadence this
593/// retired.
594fn next_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
595 loop {
596 match decode(buffer) {
597 Ok((frame, consumed)) => {
598 buffer.drain(..consumed);
599 return Ok(frame);
600 }
601 Err(
602 ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
603 ) => match fill_buffer(stream, buffer)? {
604 FillOutcome::Read => {}
605 FillOutcome::TimedOut => {
606 return Err(SdkError::Connection {
607 description: "the subscription reader's steady-state socket reported a \
608 read deadline it should not carry"
609 .to_string(),
610 });
611 }
612 },
613 Err(error) => return Err(protocol_error(&error)),
614 }
615 }
616}
617
618/// Reads one complete control-frame reply under the named [`SETUP_TIMEOUT`]
619/// deadline — used for the synchronous handshake and subscribe replies, on the
620/// calling thread, before the background reader starts.
621///
622/// A socket read window elapsing is NOT the end: the reply may simply be slow, or
623/// arriving in pieces. Only the total deadline for this reply ends the wait.
624fn read_one_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
625 let deadline = Instant::now() + SETUP_TIMEOUT;
626 loop {
627 match decode(buffer) {
628 Ok((frame, consumed)) => {
629 buffer.drain(..consumed);
630 return Ok(frame);
631 }
632 Err(
633 ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
634 ) => match fill_buffer(stream, buffer)? {
635 FillOutcome::Read => {}
636 FillOutcome::TimedOut => {
637 if Instant::now() >= deadline {
638 return Err(SdkError::Connection {
639 description:
640 "subscription connection timed out waiting for a control-frame reply"
641 .to_string(),
642 });
643 }
644 }
645 },
646 Err(error) => return Err(protocol_error(&error)),
647 }
648 }
649}
650
651/// Appends one socket read into `buffer`, mapping a read timeout to a non-fatal
652/// [`FillOutcome::TimedOut`] so the setup reader can weigh it against its
653/// deadline.
654fn fill_buffer(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<FillOutcome, SdkError> {
655 if buffer.len() > MAX_FRAME_BYTES {
656 return Err(SdkError::Protocol {
657 description: format!(
658 "subscription frame exceeded {MAX_FRAME_BYTES} bytes without a complete frame"
659 ),
660 });
661 }
662 let mut chunk = [0_u8; READ_CHUNK_BYTES];
663 match stream.read(&mut chunk) {
664 Ok(0) => Err(SdkError::Connection {
665 description: "server closed the subscription connection".to_string(),
666 }),
667 Ok(read) => {
668 let Some(received) = chunk.get(..read) else {
669 return Err(SdkError::Protocol {
670 description:
671 "subscription socket read reported more bytes than the buffer holds"
672 .to_string(),
673 });
674 };
675 buffer.extend_from_slice(received);
676 Ok(FillOutcome::Read)
677 }
678 Err(error)
679 if matches!(
680 error.kind(),
681 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
682 ) =>
683 {
684 Ok(FillOutcome::TimedOut)
685 }
686 Err(error) => Err(SdkError::Connection {
687 description: format!("failed to read from subscription connection: {error}"),
688 }),
689 }
690}
691
692/// Outcome of one non-fatal socket read attempt.
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694enum FillOutcome {
695 Read,
696 TimedOut,
697}
698
699/// Encodes and writes one frame to the socket, flushing it.
700fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), SdkError> {
701 let len = encoded_len(frame).map_err(|error| protocol_error(&error))?;
702 let mut bytes = vec![0_u8; len];
703 let written = encode(frame, &mut bytes).map_err(|error| protocol_error(&error))?;
704 let encoded = bytes.get(..written).ok_or_else(|| SdkError::Protocol {
705 description: "subscription wire encoder reported an invalid byte count".to_string(),
706 })?;
707 stream
708 .write_all(encoded)
709 .map_err(|source| SdkError::Connection {
710 description: format!("failed to write subscription frame: {source}"),
711 })?;
712 stream.flush().map_err(|source| SdkError::Connection {
713 description: format!("failed to flush subscription frame: {source}"),
714 })
715}
716
717/// Maps a wire codec error into the SDK error taxonomy.
718fn protocol_error(error: &ProtocolError) -> SdkError {
719 SdkError::Protocol {
720 description: format!("subscription wire codec error: {error}"),
721 }
722}
723
724#[cfg(test)]
725#[path = "subscription_tests.rs"]
726mod tests;
727
728#[cfg(test)]
729#[path = "subscription_mailbox_tests.rs"]
730mod mailbox_tests;