Skip to main content

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;