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;