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::sync::Arc;
22use alloc::vec;
23use alloc::vec::Vec;
24use core::time::Duration;
25
26use std::io::{Read, Write};
27use std::net::TcpStream;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
30use std::thread::JoinHandle;
31use std::time::Instant;
32
33use liminal::protocol::{
34    Frame, ProtocolError, ProtocolVersion, SchemaId, decode, encode, encoded_len,
35};
36
37use crate::SdkError;
38
39/// Minimum protocol version this client advertises during the handshake.
40const CLIENT_MIN_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
41/// Maximum protocol version this client advertises during the handshake.
42const CLIENT_MAX_VERSION: ProtocolVersion = ProtocolVersion::new(1, 0);
43/// Bound on a single socket write.
44const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
45/// Poll cadence the reader thread and synchronous setup reads use so they can
46/// observe the stop flag / a total deadline between reads.
47const READER_POLL_TIMEOUT: Duration = Duration::from_millis(100);
48/// Total budget for the synchronous handshake + subscribe reply reads.
49const SETUP_TIMEOUT: Duration = Duration::from_secs(5);
50/// Read chunk size used when draining the socket into the frame buffer.
51const READ_CHUNK_BYTES: usize = 4096;
52/// Upper bound on a single buffered frame, guarding against runaway buffering.
53const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024;
54/// The single application stream this subscription's deliveries ride on. One
55/// subscription per connection in v1, so a fixed stream id is sufficient.
56const SUBSCRIPTION_STREAM_ID: u32 = 1;
57/// In-flight window advertised on subscribe. The v1 server does not gate delivery
58/// on credit, so this is advisory; a generous value avoids any future pacing
59/// surprise while the credit mode is still v2 work.
60const SUBSCRIBE_MAX_IN_FLIGHT: u32 = 1024;
61
62/// A message the server delivered on this subscription.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct DeliveredMessage {
65    delivery_seq: u64,
66    schema_id: SchemaId,
67    payload: Vec<u8>,
68}
69
70impl DeliveredMessage {
71    /// The per-subscription monotonic delivery sequence (starts at 1). The anchor
72    /// the future ack/resume protocol will acknowledge against.
73    #[must_use]
74    pub const fn delivery_seq(&self) -> u64 {
75        self.delivery_seq
76    }
77
78    /// The schema id the server selected for this subscription's stream.
79    #[must_use]
80    pub const fn schema_id(&self) -> SchemaId {
81        self.schema_id
82    }
83
84    /// The delivered payload bytes.
85    #[must_use]
86    pub fn payload(&self) -> &[u8] {
87        &self.payload
88    }
89
90    /// Consumes the message, returning the owned payload bytes.
91    #[must_use]
92    pub fn into_payload(self) -> Vec<u8> {
93        self.payload
94    }
95}
96
97/// A connected subscription whose background reader surfaces delivered messages.
98///
99/// Construct with [`SubscriptionStream::open`]; the background reader starts
100/// immediately and runs until the stream is dropped. Pull delivered messages with
101/// [`SubscriptionStream::recv_timeout`].
102#[derive(Debug)]
103pub struct SubscriptionStream {
104    /// Write half, used only by setup and the best-effort teardown on drop.
105    writer: TcpStream,
106    /// Server-assigned subscription id, echoed on `Unsubscribe` at teardown.
107    subscription_id: u64,
108    /// Delivered messages surfaced by the background reader.
109    inbound: Receiver<DeliveredMessage>,
110    /// Signals the reader thread to stop; set on drop.
111    stop: Arc<AtomicBool>,
112    /// Background reader handle, joined on drop.
113    reader: Option<JoinHandle<()>>,
114}
115
116impl SubscriptionStream {
117    /// Connects to `address`, performs the handshake, subscribes to `channel`, and
118    /// starts the background reader that drains delivered messages.
119    ///
120    /// `accepted_schemas` is the client's schema-compatibility list; pass an empty
121    /// vector to let the server select the channel's configured schema (the
122    /// server's negotiation contract).
123    ///
124    /// # Errors
125    ///
126    /// Returns [`SdkError::Connection`] when the TCP connection or socket
127    /// configuration fails, and [`SdkError::Protocol`] when the handshake or
128    /// subscribe is rejected, or the socket cannot be cloned for the reader thread.
129    pub fn open(
130        address: &str,
131        channel: &str,
132        accepted_schemas: Vec<SchemaId>,
133    ) -> Result<Self, SdkError> {
134        let mut stream = connect_socket(address)?;
135        // A single buffer threads through the whole synchronous setup so any bytes
136        // the setup reads past the control-frame reply are preserved. The server
137        // may coalesce a `SubscribeAck` with the first `Deliver` frames into one TCP
138        // segment (the delivery pump runs in the same slice that acks the
139        // subscribe), and a socket read pulls up to `READ_CHUNK_BYTES` at once — so
140        // this buffer can hold whole (or partial) `Deliver` frames after the ack.
141        // Handing that residue to the reader thread is what keeps those deliveries
142        // from being dropped and, worse, from desyncing a reader that would
143        // otherwise start mid-frame on a fresh empty buffer.
144        let mut buffer = Vec::new();
145        handshake(&mut stream, &mut buffer)?;
146        let subscription_id = subscribe(&mut stream, &mut buffer, channel, accepted_schemas)?;
147
148        let read_stream = stream.try_clone().map_err(|source| SdkError::Protocol {
149            description: format!("failed to clone subscription socket for reader thread: {source}"),
150        })?;
151        let stop = Arc::new(AtomicBool::new(false));
152        let (sender, inbound) = mpsc::channel();
153        let reader_stop = Arc::clone(&stop);
154        let reader = std::thread::Builder::new()
155            .name("liminal-subscription-reader".to_string())
156            .spawn(move || run_reader(read_stream, buffer, &sender, &reader_stop))
157            .map_err(|source| SdkError::Protocol {
158                description: format!("failed to start subscription reader thread: {source}"),
159            })?;
160
161        Ok(Self {
162            writer: stream,
163            subscription_id,
164            inbound,
165            stop,
166            reader: Some(reader),
167        })
168    }
169
170    /// Blocks up to `timeout` for the next delivered message from the server.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`SdkError::Connection`] when no message arrives within `timeout`
175    /// or the background reader has stopped (e.g. the server closed the stream).
176    pub fn recv_timeout(&self, timeout: Duration) -> Result<DeliveredMessage, SdkError> {
177        self.inbound.recv_timeout(timeout).map_err(|error| {
178            let detail = match error {
179                RecvTimeoutError::Timeout => "no delivery arrived within the timeout",
180                RecvTimeoutError::Disconnected => {
181                    "the subscription reader stopped before a delivery arrived"
182                }
183            };
184            SdkError::Connection {
185                description: format!("subscription receive failed: {detail}"),
186            }
187        })
188    }
189
190    /// The server-assigned id for this subscription.
191    #[must_use]
192    pub const fn subscription_id(&self) -> u64 {
193        self.subscription_id
194    }
195}
196
197impl Drop for SubscriptionStream {
198    fn drop(&mut self) {
199        self.stop.store(true, Ordering::SeqCst);
200        // Best-effort clean teardown: tell the server to drop the subscription and
201        // close the connection. Failures are ignored — the connection close alone
202        // frees the server-side subscription when its subscriber process exits.
203        let unsubscribe = Frame::Unsubscribe {
204            flags: 0,
205            stream_id: SUBSCRIPTION_STREAM_ID,
206            subscription_id: self.subscription_id,
207        };
208        let _ = write_frame(&mut self.writer, &unsubscribe);
209        let _ = write_frame(&mut self.writer, &Frame::Disconnect { flags: 0 });
210        if let Some(reader) = self.reader.take() {
211            // The reader wakes within READER_POLL_TIMEOUT to observe the stop flag,
212            // so this join does not hang on a quiet connection.
213            reader.join().ok();
214        }
215    }
216}
217
218/// Opens and configures the subscription socket (Nagle off, bounded read/write
219/// timeouts) before any framing.
220fn connect_socket(address: &str) -> Result<TcpStream, SdkError> {
221    let stream = TcpStream::connect(address).map_err(|source| SdkError::Connection {
222        description: format!("failed to connect subscription client to {address}: {source}"),
223    })?;
224    stream
225        .set_nodelay(true)
226        .map_err(|source| SdkError::Connection {
227            description: format!("failed to disable Nagle for {address}: {source}"),
228        })?;
229    stream
230        .set_read_timeout(Some(READER_POLL_TIMEOUT))
231        .map_err(|source| SdkError::Connection {
232            description: format!("failed to set subscription read timeout for {address}: {source}"),
233        })?;
234    stream
235        .set_write_timeout(Some(WRITE_TIMEOUT))
236        .map_err(|source| SdkError::Connection {
237            description: format!(
238                "failed to set subscription write timeout for {address}: {source}"
239            ),
240        })?;
241    Ok(stream)
242}
243
244/// Drives the client handshake (`Connect` -> `ConnectAck`) on a fresh socket.
245///
246/// `buffer` carries any residue read past the reply forward to the next setup step
247/// (and ultimately the reader thread) rather than discarding it.
248fn handshake(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<(), SdkError> {
249    let connect = Frame::Connect {
250        flags: 0,
251        min_version: CLIENT_MIN_VERSION,
252        max_version: CLIENT_MAX_VERSION,
253        auth_token: Vec::new(),
254    };
255    write_frame(stream, &connect)?;
256    match read_one_frame(stream, buffer)? {
257        Frame::ConnectAck { .. } => Ok(()),
258        Frame::ConnectError {
259            reason_code,
260            message,
261            ..
262        } => Err(SdkError::Connection {
263            description: format!(
264                "server rejected subscription connection (reason {reason_code}): {}",
265                message.unwrap_or_else(|| "no detail".to_string())
266            ),
267        }),
268        other => Err(SdkError::Protocol {
269            description: format!(
270                "expected ConnectAck during subscription handshake, received {:?}",
271                other.frame_type()
272            ),
273        }),
274    }
275}
276
277/// Drives the synchronous subscribe round trip (`Subscribe` -> `SubscribeAck`) on
278/// a handshaken socket, returning the server-assigned subscription id.
279fn subscribe(
280    stream: &mut TcpStream,
281    buffer: &mut Vec<u8>,
282    channel: &str,
283    accepted_schemas: Vec<SchemaId>,
284) -> Result<u64, SdkError> {
285    let frame = Frame::Subscribe {
286        flags: 0,
287        stream_id: SUBSCRIPTION_STREAM_ID,
288        channel: channel.to_string(),
289        accepted_schemas,
290        max_in_flight: SUBSCRIBE_MAX_IN_FLIGHT,
291    };
292    write_frame(stream, &frame)?;
293    match read_one_frame(stream, buffer)? {
294        Frame::SubscribeAck {
295            subscription_id, ..
296        } => Ok(subscription_id),
297        Frame::SubscribeError {
298            reason_code,
299            message,
300            ..
301        } => Err(SdkError::Protocol {
302            description: format!(
303                "server rejected subscribe (reason {reason_code}): {}",
304                message.unwrap_or_else(|| "no detail".to_string())
305            ),
306        }),
307        other => Err(SdkError::Protocol {
308            description: format!(
309                "expected SubscribeAck during subscribe, received {:?}",
310                other.frame_type()
311            ),
312        }),
313    }
314}
315
316/// Background loop: drains the socket, surfacing each `Deliver` frame's message on
317/// `sender`.
318///
319/// Returns (ending the thread) when the stop flag is set, the connection closes,
320/// a `Disconnect` arrives, or a fatal decode/IO error occurs. A read timeout is
321/// non-fatal: it just lets the loop re-check the stop flag.
322///
323/// `buffer` is seeded with the setup residue (see [`SubscriptionStream::open`]): any
324/// `Deliver` bytes the synchronous subscribe read past the `SubscribeAck` are
325/// already here, so the loop decodes them first — before its next socket read —
326/// instead of losing them and starting mid-stream.
327fn run_reader(
328    mut stream: TcpStream,
329    mut buffer: Vec<u8>,
330    sender: &Sender<DeliveredMessage>,
331    stop: &AtomicBool,
332) {
333    while !stop.load(Ordering::SeqCst) {
334        let frame = match next_frame(&mut stream, &mut buffer) {
335            Ok(Some(frame)) => frame,
336            // A read timeout with no complete frame: loop to re-check the stop flag.
337            Ok(None) => continue,
338            // Connection closed or a fatal read/decode error: end the thread. The
339            // dropped `sender` surfaces as a `Disconnected` on the receiver side.
340            Err(_) => return,
341        };
342        match frame {
343            Frame::Deliver {
344                delivery_seq,
345                envelope,
346                ..
347            } => {
348                let message = DeliveredMessage {
349                    delivery_seq,
350                    schema_id: envelope.schema_id,
351                    payload: envelope.payload,
352                };
353                if sender.send(message).is_err() {
354                    // The receiver was dropped; nothing will consume further
355                    // deliveries, so stop reading.
356                    return;
357                }
358            }
359            // A server `Disconnect` ends the subscription cleanly.
360            Frame::Disconnect { .. } => return,
361            // Any other frame on a subscription connection is unexpected; ignore it
362            // rather than tearing the reader down so a stray frame cannot silently
363            // drop subsequent deliveries.
364            _ => {}
365        }
366    }
367}
368
369/// Reads until one complete frame decodes, treating a read timeout as
370/// `Ok(None)` so the caller can re-check the stop flag without ending the loop.
371fn next_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Option<Frame>, SdkError> {
372    loop {
373        match decode(buffer) {
374            Ok((frame, consumed)) => {
375                buffer.drain(..consumed);
376                return Ok(Some(frame));
377            }
378            Err(
379                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
380            ) => match fill_buffer(stream, buffer)? {
381                FillOutcome::Read => {}
382                FillOutcome::TimedOut => return Ok(None),
383            },
384            Err(error) => return Err(protocol_error(&error)),
385        }
386    }
387}
388
389/// Reads one complete frame, retrying read timeouts until [`SETUP_TIMEOUT`]
390/// elapses — used for the synchronous handshake and subscribe replies before the
391/// background reader starts.
392fn read_one_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
393    let deadline = Instant::now() + SETUP_TIMEOUT;
394    loop {
395        match decode(buffer) {
396            Ok((frame, consumed)) => {
397                buffer.drain(..consumed);
398                return Ok(frame);
399            }
400            Err(
401                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
402            ) => match fill_buffer(stream, buffer)? {
403                FillOutcome::Read => {}
404                FillOutcome::TimedOut => {
405                    if Instant::now() >= deadline {
406                        return Err(SdkError::Connection {
407                            description:
408                                "subscription connection timed out waiting for a control-frame reply"
409                                    .to_string(),
410                        });
411                    }
412                }
413            },
414            Err(error) => return Err(protocol_error(&error)),
415        }
416    }
417}
418
419/// Appends one socket read into `buffer`, mapping a read timeout to a non-fatal
420/// [`FillOutcome::TimedOut`].
421fn fill_buffer(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<FillOutcome, SdkError> {
422    if buffer.len() > MAX_FRAME_BYTES {
423        return Err(SdkError::Protocol {
424            description: format!(
425                "subscription frame exceeded {MAX_FRAME_BYTES} bytes without a complete frame"
426            ),
427        });
428    }
429    let mut chunk = [0_u8; READ_CHUNK_BYTES];
430    match stream.read(&mut chunk) {
431        Ok(0) => Err(SdkError::Connection {
432            description: "server closed the subscription connection".to_string(),
433        }),
434        Ok(read) => {
435            let Some(received) = chunk.get(..read) else {
436                return Err(SdkError::Protocol {
437                    description:
438                        "subscription socket read reported more bytes than the buffer holds"
439                            .to_string(),
440                });
441            };
442            buffer.extend_from_slice(received);
443            Ok(FillOutcome::Read)
444        }
445        Err(error)
446            if matches!(
447                error.kind(),
448                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
449            ) =>
450        {
451            Ok(FillOutcome::TimedOut)
452        }
453        Err(error) => Err(SdkError::Connection {
454            description: format!("failed to read from subscription connection: {error}"),
455        }),
456    }
457}
458
459/// Outcome of one non-fatal socket read attempt.
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461enum FillOutcome {
462    Read,
463    TimedOut,
464}
465
466/// Encodes and writes one frame to the socket, flushing it.
467fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), SdkError> {
468    let len = encoded_len(frame).map_err(|error| protocol_error(&error))?;
469    let mut bytes = vec![0_u8; len];
470    let written = encode(frame, &mut bytes).map_err(|error| protocol_error(&error))?;
471    let encoded = bytes.get(..written).ok_or_else(|| SdkError::Protocol {
472        description: "subscription wire encoder reported an invalid byte count".to_string(),
473    })?;
474    stream
475        .write_all(encoded)
476        .map_err(|source| SdkError::Connection {
477            description: format!("failed to write subscription frame: {source}"),
478        })?;
479    stream.flush().map_err(|source| SdkError::Connection {
480        description: format!("failed to flush subscription frame: {source}"),
481    })
482}
483
484/// Maps a wire codec error into the SDK error taxonomy.
485fn protocol_error(error: &ProtocolError) -> SdkError {
486    SdkError::Protocol {
487        description: format!("subscription wire codec error: {error}"),
488    }
489}
490
491#[cfg(test)]
492#[path = "subscription_tests.rs"]
493mod tests;