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