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::Vec;
24use core::time::Duration;
25
26use std::net::{Shutdown, TcpStream};
27use std::thread::JoinHandle;
28
29use liminal::protocol::{
30    DeliveredId, Frame, MessageId, ProtocolError, SchemaId, WorkerRegistration,
31};
32
33use crate::SdkError;
34use crate::remote::SETUP_TIMEOUT;
35mod subscription_reader;
36mod subscription_setup;
37use subscription_reader::run_reader;
38mod subscription_signals;
39mod subscription_wire;
40use crate::remote::mailbox::{Mailbox, MailboxItem, MailboxLimits, MailboxStats, MailboxWords};
41use subscription_setup::{connect_socket, handshake, subscribe};
42use subscription_wire::write_frame;
43
44/// The subscription's words in the errors its mailbox hands the consumer.
45const SUBSCRIPTION_WORDS: MailboxWords = MailboxWords {
46    receive: "subscription receive failed",
47    item: "delivery",
48    reader: "subscription reader",
49};
50
51/// Bound on a single socket write.
52const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
53/// Read chunk size used when draining the socket into the frame buffer.
54const READ_CHUNK_BYTES: usize = 4096;
55/// Upper bound on a single buffered frame, guarding against runaway buffering.
56const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024;
57/// The single application stream this subscription's deliveries ride on. One
58/// subscription per connection in v1, so a fixed stream id is sufficient.
59const SUBSCRIPTION_STREAM_ID: u32 = 1;
60/// A message the server delivered on this subscription.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct DeliveredMessage {
63    delivery_seq: u64,
64    schema_id: SchemaId,
65    payload: Vec<u8>,
66    /// The server's id for the message and its boot, when a 1.2 server sent it.
67    message: Option<DeliveredId>,
68}
69
70impl MailboxItem for DeliveredMessage {
71    fn payload_len(&self) -> usize {
72        self.payload.len()
73    }
74}
75
76impl DeliveredMessage {
77    /// The per-subscription monotonic delivery sequence (starts at 1). The anchor
78    /// the future ack/resume protocol will acknowledge against.
79    #[must_use]
80    pub const fn delivery_seq(&self) -> u64 {
81        self.delivery_seq
82    }
83
84    /// The schema id the server selected for this subscription's stream.
85    #[must_use]
86    pub const fn schema_id(&self) -> SchemaId {
87        self.schema_id
88    }
89
90    /// The delivered payload bytes.
91    #[must_use]
92    pub fn payload(&self) -> &[u8] {
93        &self.payload
94    }
95
96    /// Consumes the message, returning the owned payload bytes.
97    #[must_use]
98    pub fn into_payload(self) -> Vec<u8> {
99        self.payload
100    }
101
102    /// The server's id for this message and the boot it was drawn in, when the
103    /// server sent it (protocol 1.2, Fable 4c). `None` from an older server,
104    /// and for a durable message replayed from the store.
105    #[must_use]
106    pub const fn message(&self) -> Option<DeliveredId> {
107        self.message
108    }
109
110    /// The causal parent a reply to this message names, `<boot>:<id>`, or
111    /// `None` when the message carried no id: never an invented one.
112    #[must_use]
113    pub fn reply_parent(&self) -> Option<MessageId> {
114        self.message.map(DeliveredId::reply_parent)
115    }
116}
117
118/// A connected subscription whose background reader surfaces delivered messages.
119///
120/// Construct with [`SubscriptionStream::open`]; the background reader starts
121/// immediately and runs until the stream is dropped. Pull delivered messages with
122/// [`SubscriptionStream::recv_timeout`].
123#[derive(Debug)]
124pub struct SubscriptionStream {
125    /// Write half, used only by setup and the best-effort teardown on drop.
126    writer: TcpStream,
127    /// Server-assigned subscription id, echoed on `Unsubscribe` at teardown.
128    subscription_id: u64,
129    /// Delivered messages surfaced by the background reader, bounded in count
130    /// and bytes, then the one typed end the reader stopped with. A
131    /// `SubscribeError` arriving mid-stream is the ONLY explanation the
132    /// consumer will ever get for deliveries stopping, so it is that end,
133    /// received after every delivery before it (P0 #55).
134    mailbox: Arc<Mailbox<DeliveredMessage>>,
135    /// Background reader handle, joined on drop.
136    reader: Option<JoinHandle<()>>,
137}
138
139impl SubscriptionStream {
140    /// Connects to `address`, performs the handshake, subscribes to `channel`, and
141    /// starts the background reader that drains delivered messages.
142    ///
143    /// `accepted_schemas` is the client's schema-compatibility list; pass an empty
144    /// vector to let the server select the channel's configured schema (the
145    /// server's negotiation contract).
146    ///
147    /// # Errors
148    ///
149    /// Returns [`SdkError::Connection`] when the TCP connection or socket
150    /// configuration fails, and [`SdkError::Protocol`] when the handshake or
151    /// subscribe is rejected, or the socket cannot be cloned for the reader thread.
152    pub fn open(
153        address: &str,
154        channel: &str,
155        accepted_schemas: Vec<SchemaId>,
156    ) -> Result<Self, SdkError> {
157        Self::open_with_auth(address, channel, accepted_schemas, &[])
158    }
159
160    /// Connects, handshakes carrying `auth_token`, subscribes to `channel`, and
161    /// starts the background reader.
162    ///
163    /// A subscription owns a dedicated connection (the v1 shape), so it presents
164    /// its own credential in its own `Connect` frame; the token a
165    /// request/response transport was built with lives on that transport's
166    /// socket and cannot travel here. Additive to [`open`]: an empty token is
167    /// exactly the open-access handshake `open` performs, so an ungated server
168    /// sees byte-identical bytes either way.
169    ///
170    /// The server compares the token during the handshake and answers a
171    /// mismatch with `ConnectError` before closing, which surfaces here as
172    /// [`SdkError::Connection`].
173    ///
174    /// `accepted_schemas` is the client's schema-compatibility list; pass an
175    /// empty vector to let the server select the channel's configured schema.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`SdkError::Connection`] when the TCP connection or socket
180    /// configuration fails or the token is rejected, and [`SdkError::Protocol`]
181    /// when the subscribe is rejected, or the socket cannot be cloned for the
182    /// reader thread.
183    ///
184    /// [`open`]: Self::open
185    pub fn open_with_auth(
186        address: &str,
187        channel: &str,
188        accepted_schemas: Vec<SchemaId>,
189        auth_token: &[u8],
190    ) -> Result<Self, SdkError> {
191        Self::open_with_limits(
192            address,
193            channel,
194            accepted_schemas,
195            auth_token,
196            MailboxLimits::SUBSCRIPTION,
197        )
198    }
199
200    /// [`Self::open_with_auth`] with the mailbox bounds supplied.
201    fn open_with_limits(
202        address: &str,
203        channel: &str,
204        accepted_schemas: Vec<SchemaId>,
205        auth_token: &[u8],
206        limits: MailboxLimits,
207    ) -> Result<Self, SdkError> {
208        Self::open_configured(address, channel, accepted_schemas, auth_token, None, limits)
209    }
210
211    /// Connects, handshakes, registers the connection as the worker
212    /// `registration` names, then subscribes to `channel`, so the server can
213    /// tell which worker the subscription belongs to (a subscription owns its
214    /// own connection, so the worker's push connection cannot say it).
215    ///
216    /// # Errors
217    ///
218    /// As [`open`], and [`SdkError::Protocol`] when the server rejects the
219    /// registration (the reason is carried in the error).
220    ///
221    /// [`open`]: Self::open
222    pub fn open_registered(
223        address: &str,
224        channel: &str,
225        accepted_schemas: Vec<SchemaId>,
226        registration: WorkerRegistration,
227    ) -> Result<Self, SdkError> {
228        Self::open_configured(
229            address,
230            channel,
231            accepted_schemas,
232            &[],
233            Some(registration),
234            MailboxLimits::SUBSCRIPTION,
235        )
236    }
237
238    fn open_configured(
239        address: &str,
240        channel: &str,
241        accepted_schemas: Vec<SchemaId>,
242        auth_token: &[u8],
243        registration: Option<WorkerRegistration>,
244        limits: MailboxLimits,
245    ) -> Result<Self, SdkError> {
246        let mut stream = connect_socket(address)?;
247        // A single buffer threads through the whole synchronous setup so any bytes
248        // the setup reads past the control-frame reply are preserved. The server
249        // may coalesce a `SubscribeAck` with the first `Deliver` frames into one TCP
250        // segment (the delivery pump runs in the same slice that acks the
251        // subscribe), and a socket read pulls up to `READ_CHUNK_BYTES` at once — so
252        // this buffer can hold whole (or partial) `Deliver` frames after the ack.
253        // Handing that residue to the reader thread is what keeps those deliveries
254        // from being dropped and, worse, from desyncing a reader that would
255        // otherwise start mid-frame on a fresh empty buffer.
256        let mut buffer = Vec::new();
257        handshake(&mut stream, &mut buffer, auth_token)?;
258        if let Some(registration) = registration {
259            super::push_client::register(&mut stream, registration, SETUP_TIMEOUT)?;
260        }
261        let subscription_id = subscribe(&mut stream, &mut buffer, channel, accepted_schemas)?;
262
263        // The control exchange is over, so its deadline comes off: the reader
264        // blocks on socket input with no read window at all. Teardown shuts the
265        // socket down, which surfaces as a typed terminal — the socket signals,
266        // nothing sweeps. A window left armed here would be a wake cadence in
267        // steady state, which is the defect this retires, whatever period it
268        // carried.
269        stream
270            .set_read_timeout(None)
271            .map_err(|source| SdkError::Connection {
272                description: format!("failed to clear the subscription read deadline: {source}"),
273            })?;
274        let read_stream = stream.try_clone().map_err(|source| SdkError::Protocol {
275            description: format!("failed to clone subscription socket for reader thread: {source}"),
276        })?;
277        Self::start(stream, read_stream, buffer, subscription_id, limits)
278    }
279
280    /// Starts the background reader over a subscribed stream, its setup
281    /// residue, and a mailbox with `limits`.
282    fn start(
283        stream: TcpStream,
284        read_stream: TcpStream,
285        buffer: Vec<u8>,
286        subscription_id: u64,
287        limits: MailboxLimits,
288    ) -> Result<Self, SdkError> {
289        let mailbox = Arc::new(Mailbox::new(limits, SUBSCRIPTION_WORDS));
290        let reader_mailbox = Arc::clone(&mailbox);
291        let reader = std::thread::Builder::new()
292            .name("liminal-subscription-reader".to_string())
293            .spawn(move || {
294                let end = run_reader(read_stream, buffer, &reader_mailbox);
295                reader_mailbox.close(end);
296            })
297            .map_err(|source| SdkError::Protocol {
298                description: format!("failed to start subscription reader thread: {source}"),
299            })?;
300
301        Ok(Self {
302            writer: stream,
303            subscription_id,
304            mailbox,
305            reader: Some(reader),
306        })
307    }
308
309    /// Blocks up to `timeout` for the next delivered message from the server.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`SdkError::Connection`] when no message arrives within
314    /// `timeout`. Once the reader has stopped and every held delivery has been
315    /// taken, returns the error it stopped with: the server's mid-stream
316    /// `SubscribeError`, its `Disconnect`, the read or decode failure itself,
317    /// or [`SdkError::Backpressure`] naming a delivery too large for the
318    /// mailbox.
319    pub fn recv_timeout(&self, timeout: Duration) -> Result<DeliveredMessage, SdkError> {
320        self.mailbox.take(timeout)
321    }
322
323    /// The mailbox counters: deliveries and bytes held now and at peak, how
324    /// often the reader stopped reading for a consumer that had not made
325    /// room, refused and stray frames, and errors no consumer was left to
326    /// receive.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`SdkError::Connection`] when the mailbox lock is poisoned.
331    pub fn stats(&self) -> Result<MailboxStats, SdkError> {
332        self.mailbox.stats()
333    }
334
335    /// The server-assigned id for this subscription.
336    #[must_use]
337    pub const fn subscription_id(&self) -> u64 {
338        self.subscription_id
339    }
340}
341
342impl Drop for SubscriptionStream {
343    fn drop(&mut self) {
344        // Best-effort clean teardown: tell the server to drop the subscription and
345        // close the connection. Failures are ignored — the connection close alone
346        // frees the server-side subscription when its subscriber process exits.
347        let unsubscribe = Frame::Unsubscribe {
348            flags: 0,
349            stream_id: SUBSCRIPTION_STREAM_ID,
350            subscription_id: self.subscription_id,
351        };
352        let _ = write_frame(&mut self.writer, &unsubscribe);
353        let _ = write_frame(&mut self.writer, &Frame::Disconnect { flags: 0 });
354        // Then TELL the reader. It blocks on socket input with no read window, so
355        // nothing but the socket can end its wait — a stop flag it never wakes to
356        // sample would be a lie about how it stops. Shutting the socket down
357        // surfaces a typed terminal to the blocked reader, exactly as the
358        // WebSocket sibling does, and the shutdown of the write half flushes the
359        // frames just written before its FIN. The join is therefore bounded by the
360        // shutdown, not by a peer's goodwill.
361        let _ = self.writer.shutdown(Shutdown::Both);
362        // Nothing receives from here on: release a reader waiting on a full
363        // mailbox so the join cannot wait on a consumer that has gone.
364        self.mailbox.release();
365        if let Some(reader) = self.reader.take() {
366            reader.join().ok();
367        }
368    }
369}
370
371#[cfg(test)]
372use subscription_setup::CLIENT_MAX_VERSION;
373
374#[cfg(test)]
375use alloc::vec;
376#[cfg(test)]
377use liminal::protocol::{decode, encode, encoded_len};
378#[cfg(test)]
379use std::io::{Read, Write};
380#[cfg(test)]
381use subscription_wire::protocol_error;
382
383#[cfg(test)]
384#[path = "subscription_tests.rs"]
385mod tests;
386
387#[cfg(test)]
388#[path = "subscription_mailbox_tests.rs"]
389mod mailbox_tests;