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.
103    inbound: Receiver<DeliveredMessage>,
104    /// Background reader handle, joined on drop.
105    reader: Option<JoinHandle<()>>,
106}
107
108impl SubscriptionStream {
109    /// Connects to `address`, performs the handshake, subscribes to `channel`, and
110    /// starts the background reader that drains delivered messages.
111    ///
112    /// `accepted_schemas` is the client's schema-compatibility list; pass an empty
113    /// vector to let the server select the channel's configured schema (the
114    /// server's negotiation contract).
115    ///
116    /// # Errors
117    ///
118    /// Returns [`SdkError::Connection`] when the TCP connection or socket
119    /// configuration fails, and [`SdkError::Protocol`] when the handshake or
120    /// subscribe is rejected, or the socket cannot be cloned for the reader thread.
121    pub fn open(
122        address: &str,
123        channel: &str,
124        accepted_schemas: Vec<SchemaId>,
125    ) -> Result<Self, SdkError> {
126        let mut stream = connect_socket(address)?;
127        // A single buffer threads through the whole synchronous setup so any bytes
128        // the setup reads past the control-frame reply are preserved. The server
129        // may coalesce a `SubscribeAck` with the first `Deliver` frames into one TCP
130        // segment (the delivery pump runs in the same slice that acks the
131        // subscribe), and a socket read pulls up to `READ_CHUNK_BYTES` at once — so
132        // this buffer can hold whole (or partial) `Deliver` frames after the ack.
133        // Handing that residue to the reader thread is what keeps those deliveries
134        // from being dropped and, worse, from desyncing a reader that would
135        // otherwise start mid-frame on a fresh empty buffer.
136        let mut buffer = Vec::new();
137        handshake(&mut stream, &mut buffer)?;
138        let subscription_id = subscribe(&mut stream, &mut buffer, channel, accepted_schemas)?;
139
140        // The control exchange is over, so its deadline comes off: the reader
141        // blocks on socket input with no read window at all. Teardown shuts the
142        // socket down, which surfaces as a typed terminal — the socket signals,
143        // nothing sweeps. A window left armed here would be a wake cadence in
144        // steady state, which is the defect this retires, whatever period it
145        // carried.
146        stream
147            .set_read_timeout(None)
148            .map_err(|source| SdkError::Connection {
149                description: format!("failed to clear the subscription read deadline: {source}"),
150            })?;
151        let read_stream = stream.try_clone().map_err(|source| SdkError::Protocol {
152            description: format!("failed to clone subscription socket for reader thread: {source}"),
153        })?;
154        let (sender, inbound) = mpsc::channel();
155        let reader = std::thread::Builder::new()
156            .name("liminal-subscription-reader".to_string())
157            .spawn(move || run_reader(read_stream, buffer, &sender))
158            .map_err(|source| SdkError::Protocol {
159                description: format!("failed to start subscription reader thread: {source}"),
160            })?;
161
162        Ok(Self {
163            writer: stream,
164            subscription_id,
165            inbound,
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        // Best-effort clean teardown: tell the server to drop the subscription and
200        // close the connection. Failures are ignored — the connection close alone
201        // frees the server-side subscription when its subscriber process exits.
202        let unsubscribe = Frame::Unsubscribe {
203            flags: 0,
204            stream_id: SUBSCRIPTION_STREAM_ID,
205            subscription_id: self.subscription_id,
206        };
207        let _ = write_frame(&mut self.writer, &unsubscribe);
208        let _ = write_frame(&mut self.writer, &Frame::Disconnect { flags: 0 });
209        // Then TELL the reader. It blocks on socket input with no read window, so
210        // nothing but the socket can end its wait — a stop flag it never wakes to
211        // sample would be a lie about how it stops. Shutting the socket down
212        // surfaces a typed terminal to the blocked reader, exactly as the
213        // WebSocket sibling does, and the shutdown of the write half flushes the
214        // frames just written before its FIN. The join is therefore bounded by the
215        // shutdown, not by a peer's goodwill.
216        let _ = self.writer.shutdown(Shutdown::Both);
217        if let Some(reader) = self.reader.take() {
218            reader.join().ok();
219        }
220    }
221}
222
223/// Opens and configures the subscription socket (Nagle off, bounded read/write
224/// timeouts) before any framing.
225fn connect_socket(address: &str) -> Result<TcpStream, SdkError> {
226    let stream = TcpStream::connect(address).map_err(|source| SdkError::Connection {
227        description: format!("failed to connect subscription client to {address}: {source}"),
228    })?;
229    stream
230        .set_nodelay(true)
231        .map_err(|source| SdkError::Connection {
232            description: format!("failed to disable Nagle for {address}: {source}"),
233        })?;
234    // The named deadline for a synchronous control-frame reply, and nothing
235    // else: it covers the `Connect`/`ConnectAck` and `Subscribe`/`SubscribeAck`
236    // exchanges that run on the calling thread, and `open` takes it back off
237    // before the background reader ever sees the socket.
238    stream
239        .set_read_timeout(Some(SETUP_TIMEOUT))
240        .map_err(|source| SdkError::Connection {
241            description: format!(
242                "failed to set the subscription setup deadline for {address}: {source}"
243            ),
244        })?;
245    stream
246        .set_write_timeout(Some(WRITE_TIMEOUT))
247        .map_err(|source| SdkError::Connection {
248            description: format!(
249                "failed to set subscription write timeout for {address}: {source}"
250            ),
251        })?;
252    Ok(stream)
253}
254
255/// Drives the client handshake (`Connect` -> `ConnectAck`) on a fresh socket.
256///
257/// `buffer` carries any residue read past the reply forward to the next setup step
258/// (and ultimately the reader thread) rather than discarding it.
259fn handshake(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<(), SdkError> {
260    let connect = Frame::Connect {
261        flags: 0,
262        min_version: CLIENT_MIN_VERSION,
263        max_version: CLIENT_MAX_VERSION,
264        auth_token: Vec::new(),
265    };
266    write_frame(stream, &connect)?;
267    match read_one_frame(stream, buffer)? {
268        Frame::ConnectAck { .. } => Ok(()),
269        Frame::ConnectError {
270            reason_code,
271            message,
272            ..
273        } => Err(SdkError::Connection {
274            description: format!(
275                "server rejected subscription connection (reason {reason_code}): {}",
276                message.unwrap_or_else(|| "no detail".to_string())
277            ),
278        }),
279        other => Err(SdkError::Protocol {
280            description: format!(
281                "expected ConnectAck during subscription handshake, received {:?}",
282                other.frame_type()
283            ),
284        }),
285    }
286}
287
288/// Drives the synchronous subscribe round trip (`Subscribe` -> `SubscribeAck`) on
289/// a handshaken socket, returning the server-assigned subscription id.
290fn subscribe(
291    stream: &mut TcpStream,
292    buffer: &mut Vec<u8>,
293    channel: &str,
294    accepted_schemas: Vec<SchemaId>,
295) -> Result<u64, SdkError> {
296    let frame = Frame::Subscribe {
297        flags: 0,
298        stream_id: SUBSCRIPTION_STREAM_ID,
299        channel: channel.to_string(),
300        accepted_schemas,
301        max_in_flight: SUBSCRIBE_MAX_IN_FLIGHT,
302    };
303    write_frame(stream, &frame)?;
304    match read_one_frame(stream, buffer)? {
305        Frame::SubscribeAck {
306            subscription_id, ..
307        } => Ok(subscription_id),
308        Frame::SubscribeError {
309            reason_code,
310            message,
311            ..
312        } => Err(SdkError::Protocol {
313            description: format!(
314                "server rejected subscribe (reason {reason_code}): {}",
315                message.unwrap_or_else(|| "no detail".to_string())
316            ),
317        }),
318        other => Err(SdkError::Protocol {
319            description: format!(
320                "expected SubscribeAck during subscribe, received {:?}",
321                other.frame_type()
322            ),
323        }),
324    }
325}
326
327/// Background loop: drains the socket, surfacing each `Deliver` frame's message on
328/// `sender`.
329///
330/// The socket carries no read window here, so the loop blocks until the server
331/// sends or the connection ends: nothing wakes it on a timer and nothing sweeps.
332/// It returns (ending the thread) when the connection closes — including the
333/// `shutdown` teardown performs — when a `Disconnect` arrives, when the consumer
334/// has gone away, or on a fatal decode/IO error.
335///
336/// `buffer` is seeded with the setup residue (see [`SubscriptionStream::open`]): any
337/// `Deliver` bytes the synchronous subscribe read past the `SubscribeAck` are
338/// already here, so the loop decodes them first — before its next socket read —
339/// instead of losing them and starting mid-stream.
340fn run_reader(mut stream: TcpStream, mut buffer: Vec<u8>, sender: &Sender<DeliveredMessage>) {
341    loop {
342        // Connection closed or a fatal read/decode error: end the thread. The
343        // dropped `sender` surfaces as a `Disconnected` on the receiver side.
344        let Ok(frame) = next_frame(&mut stream, &mut buffer) else {
345            return;
346        };
347        match frame {
348            Frame::Deliver {
349                delivery_seq,
350                envelope,
351                ..
352            } => {
353                let message = DeliveredMessage {
354                    delivery_seq,
355                    schema_id: envelope.schema_id,
356                    payload: envelope.payload,
357                };
358                if sender.send(message).is_err() {
359                    // The receiver was dropped; nothing will consume further
360                    // deliveries, so stop reading.
361                    return;
362                }
363            }
364            // A server `Disconnect` ends the subscription cleanly.
365            Frame::Disconnect { .. } => return,
366            // Any other frame on a subscription connection is unexpected; ignore it
367            // rather than tearing the reader down so a stray frame cannot silently
368            // drop subsequent deliveries.
369            _ => {}
370        }
371    }
372}
373
374/// Reads until one complete frame decodes on the windowless steady-state socket.
375///
376/// There is no read window to expire here, so a [`FillOutcome::TimedOut`] would
377/// mean one was re-armed behind the reader's back. That is reported as the
378/// invariant break it is, rather than swallowed into a spin — a reader that
379/// looped on it would be a busy-wait, which is worse than the cadence this
380/// retired.
381fn next_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
382    loop {
383        match decode(buffer) {
384            Ok((frame, consumed)) => {
385                buffer.drain(..consumed);
386                return Ok(frame);
387            }
388            Err(
389                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
390            ) => match fill_buffer(stream, buffer)? {
391                FillOutcome::Read => {}
392                FillOutcome::TimedOut => {
393                    return Err(SdkError::Connection {
394                        description: "the subscription reader's steady-state socket reported a \
395                                      read deadline it should not carry"
396                            .to_string(),
397                    });
398                }
399            },
400            Err(error) => return Err(protocol_error(&error)),
401        }
402    }
403}
404
405/// Reads one complete control-frame reply under the named [`SETUP_TIMEOUT`]
406/// deadline — used for the synchronous handshake and subscribe replies, on the
407/// calling thread, before the background reader starts.
408///
409/// A socket read window elapsing is NOT the end: the reply may simply be slow, or
410/// arriving in pieces. Only the total deadline for this reply ends the wait.
411fn read_one_frame(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<Frame, SdkError> {
412    let deadline = Instant::now() + SETUP_TIMEOUT;
413    loop {
414        match decode(buffer) {
415            Ok((frame, consumed)) => {
416                buffer.drain(..consumed);
417                return Ok(frame);
418            }
419            Err(
420                ProtocolError::IncompleteHeader { .. } | ProtocolError::TruncatedPayload { .. },
421            ) => match fill_buffer(stream, buffer)? {
422                FillOutcome::Read => {}
423                FillOutcome::TimedOut => {
424                    if Instant::now() >= deadline {
425                        return Err(SdkError::Connection {
426                            description:
427                                "subscription connection timed out waiting for a control-frame reply"
428                                    .to_string(),
429                        });
430                    }
431                }
432            },
433            Err(error) => return Err(protocol_error(&error)),
434        }
435    }
436}
437
438/// Appends one socket read into `buffer`, mapping a read timeout to a non-fatal
439/// [`FillOutcome::TimedOut`] so the setup reader can weigh it against its
440/// deadline.
441fn fill_buffer(stream: &mut TcpStream, buffer: &mut Vec<u8>) -> Result<FillOutcome, SdkError> {
442    if buffer.len() > MAX_FRAME_BYTES {
443        return Err(SdkError::Protocol {
444            description: format!(
445                "subscription frame exceeded {MAX_FRAME_BYTES} bytes without a complete frame"
446            ),
447        });
448    }
449    let mut chunk = [0_u8; READ_CHUNK_BYTES];
450    match stream.read(&mut chunk) {
451        Ok(0) => Err(SdkError::Connection {
452            description: "server closed the subscription connection".to_string(),
453        }),
454        Ok(read) => {
455            let Some(received) = chunk.get(..read) else {
456                return Err(SdkError::Protocol {
457                    description:
458                        "subscription socket read reported more bytes than the buffer holds"
459                            .to_string(),
460                });
461            };
462            buffer.extend_from_slice(received);
463            Ok(FillOutcome::Read)
464        }
465        Err(error)
466            if matches!(
467                error.kind(),
468                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
469            ) =>
470        {
471            Ok(FillOutcome::TimedOut)
472        }
473        Err(error) => Err(SdkError::Connection {
474            description: format!("failed to read from subscription connection: {error}"),
475        }),
476    }
477}
478
479/// Outcome of one non-fatal socket read attempt.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481enum FillOutcome {
482    Read,
483    TimedOut,
484}
485
486/// Encodes and writes one frame to the socket, flushing it.
487fn write_frame(stream: &mut TcpStream, frame: &Frame) -> Result<(), SdkError> {
488    let len = encoded_len(frame).map_err(|error| protocol_error(&error))?;
489    let mut bytes = vec![0_u8; len];
490    let written = encode(frame, &mut bytes).map_err(|error| protocol_error(&error))?;
491    let encoded = bytes.get(..written).ok_or_else(|| SdkError::Protocol {
492        description: "subscription wire encoder reported an invalid byte count".to_string(),
493    })?;
494    stream
495        .write_all(encoded)
496        .map_err(|source| SdkError::Connection {
497            description: format!("failed to write subscription frame: {source}"),
498        })?;
499    stream.flush().map_err(|source| SdkError::Connection {
500        description: format!("failed to flush subscription frame: {source}"),
501    })
502}
503
504/// Maps a wire codec error into the SDK error taxonomy.
505fn protocol_error(error: &ProtocolError) -> SdkError {
506    SdkError::Protocol {
507        description: format!("subscription wire codec error: {error}"),
508    }
509}
510
511#[cfg(test)]
512#[path = "subscription_tests.rs"]
513mod tests;