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