Skip to main content

liminal_sdk/remote/websocket/
subscription.rs

1//! Client-side WebSocket subscription stream: the receive half of the
2//! delivery pump, sibling to the TCP [`SubscriptionStream`].
3//!
4//! One subscription per dedicated connection (the v1 shape). The socket open
5//! is authorized through the client unit exactly like the request/response
6//! transport (R2.2), the background reader blocks on socket input with no
7//! timer or polling loop, and teardown shuts the socket down so the blocked
8//! reader exits on the socket's own typed terminal event (LAW-1: the socket
9//! signals; nothing sweeps).
10//!
11//! [`SubscriptionStream`]: crate::SubscriptionStream
12
13use alloc::format;
14use alloc::string::ToString;
15use alloc::sync::Arc;
16use alloc::vec::Vec;
17
18use core::time::Duration;
19
20use std::net::TcpStream;
21use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
22use std::thread::JoinHandle;
23
24use liminal::protocol::{Frame, SchemaId, decode};
25use liminal_protocol::outcome::ReconnectState;
26use spin::Mutex;
27
28use crate::SdkError;
29use crate::remote::SETUP_TIMEOUT;
30
31use super::binding::{AttemptFateOutcome, OpenRequestDecision, WebSocketAuthorityBinding};
32use super::connection_error;
33use super::core::{
34    DriverOutput, FrameCorrelation, ResponseExpectation, SocketCommand, SocketEvent,
35    WebSocketFrameDriver,
36};
37use super::liminal_ws_message_bound;
38use super::std_socket::{SocketRead, WsSocket};
39
40/// Minimum protocol version this client advertises during the handshake.
41const CLIENT_MIN_VERSION: liminal::protocol::ProtocolVersion =
42    liminal::protocol::ProtocolVersion::new(1, 0);
43/// Maximum protocol version this client advertises during the handshake.
44const CLIENT_MAX_VERSION: liminal::protocol::ProtocolVersion =
45    liminal::protocol::ProtocolVersion::new(1, 0);
46/// The single application stream this subscription's deliveries ride on.
47const SUBSCRIPTION_STREAM_ID: u32 = 1;
48/// In-flight window advertised on subscribe (advisory in v1; TCP parity).
49const SUBSCRIBE_MAX_IN_FLIGHT: u32 = 1024;
50
51/// A message the server delivered on this WebSocket subscription.
52///
53/// Mirrors the TCP [`DeliveredMessage`](crate::DeliveredMessage) surface; the
54/// TCP type keeps its fields private, so the WebSocket sibling carries its own
55/// identical shape.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct WebSocketDeliveredMessage {
58    delivery_seq: u64,
59    schema_id: SchemaId,
60    payload: Vec<u8>,
61}
62
63impl WebSocketDeliveredMessage {
64    /// The per-subscription monotonic delivery sequence (starts at 1).
65    #[must_use]
66    pub const fn delivery_seq(&self) -> u64 {
67        self.delivery_seq
68    }
69
70    /// The schema id the server selected for this subscription's stream.
71    #[must_use]
72    pub const fn schema_id(&self) -> SchemaId {
73        self.schema_id
74    }
75
76    /// The delivered payload bytes.
77    #[must_use]
78    pub fn payload(&self) -> &[u8] {
79        &self.payload
80    }
81
82    /// Consumes the message, returning the owned payload bytes.
83    #[must_use]
84    pub fn into_payload(self) -> Vec<u8> {
85        self.payload
86    }
87}
88
89/// A connected WebSocket subscription whose background reader surfaces
90/// delivered messages.
91#[derive(Debug)]
92pub struct WebSocketSubscriptionStream {
93    /// Shutdown handle for the one socket; used only on drop.
94    shutdown: TcpStream,
95    subscription_id: u64,
96    inbound: Receiver<WebSocketDeliveredMessage>,
97    binding: Arc<Mutex<WebSocketAuthorityBinding>>,
98    reader: Option<JoinHandle<()>>,
99}
100
101impl WebSocketSubscriptionStream {
102    /// Connects to the `ws://` address, performs the liminal handshake,
103    /// subscribes to `channel`, and starts the background reader.
104    ///
105    /// Deliveries the server coalesces with the `SubscribeAck` are retained
106    /// and surfaced first, never dropped.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`SdkError::Connection`] when the client unit refuses the
111    /// open or the socket cannot be opened, and [`SdkError::Protocol`] when
112    /// the handshake or subscribe is rejected.
113    pub fn open(
114        address: &str,
115        channel: &str,
116        accepted_schemas: Vec<SchemaId>,
117    ) -> Result<Self, SdkError> {
118        let message_bound = liminal_ws_message_bound()?;
119        let mut binding = WebSocketAuthorityBinding::new();
120        match binding.request_open() {
121            OpenRequestDecision::Authorized { .. } => {}
122            OpenRequestDecision::Refused(refusal) => {
123                return Err(connection_error(&format!(
124                    "client authority refused the subscription open: {refusal:?}"
125                )));
126            }
127        }
128        match Self::open_link(address, channel, accepted_schemas, message_bound) {
129            Ok((socket, driver, subscription_id, pending)) => {
130                match binding.connection_established() {
131                    AttemptFateOutcome::Recorded { .. } => {}
132                    AttemptFateOutcome::Refused(refusal) => {
133                        return Err(SdkError::Protocol {
134                            description: format!(
135                                "client authority refused the Connected fate for the \
136                                 subscription open: {refusal:?}"
137                            ),
138                        });
139                    }
140                }
141                Self::start(socket, driver, binding, subscription_id, pending)
142            }
143            Err(error) => match binding.open_failed() {
144                AttemptFateOutcome::Recorded { .. } => Err(error),
145                AttemptFateOutcome::Refused(refusal) => Err(SdkError::Protocol {
146                    description: format!(
147                        "subscription open failed ({error}) and the client authority \
148                             refused the Failed fate: {refusal:?}"
149                    ),
150                }),
151            },
152        }
153    }
154
155    /// Blocks up to `timeout` for the next delivered message.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`SdkError::Connection`] when no message arrives within
160    /// `timeout` or the background reader has stopped.
161    pub fn recv_timeout(&self, timeout: Duration) -> Result<WebSocketDeliveredMessage, SdkError> {
162        self.inbound.recv_timeout(timeout).map_err(|error| {
163            let detail = match error {
164                RecvTimeoutError::Timeout => "no delivery arrived within the timeout",
165                RecvTimeoutError::Disconnected => {
166                    "the subscription reader stopped before a delivery arrived"
167                }
168            };
169            connection_error(&format!("websocket subscription receive failed: {detail}"))
170        })
171    }
172
173    /// The server-assigned id for this subscription.
174    #[must_use]
175    pub const fn subscription_id(&self) -> u64 {
176        self.subscription_id
177    }
178
179    /// The client unit's reconnect state for this subscription's connection.
180    #[must_use]
181    pub fn reconnect_state(&self) -> ReconnectState {
182        self.binding.lock().reconnect_state()
183    }
184
185    /// Performs the socket open, handshake, and subscribe exchange.
186    fn open_link(
187        address: &str,
188        channel: &str,
189        accepted_schemas: Vec<SchemaId>,
190        message_bound: usize,
191    ) -> Result<
192        (
193            WsSocket,
194            WebSocketFrameDriver,
195            u64,
196            Vec<WebSocketDeliveredMessage>,
197        ),
198        SdkError,
199    > {
200        let mut driver = WebSocketFrameDriver::new();
201        let command = driver
202            .command_open()
203            .map_err(|refusal| SdkError::Protocol {
204                description: format!("subscription driver refused its first open: {refusal:?}"),
205            })?;
206        if command != SocketCommand::Open {
207            return Err(SdkError::Protocol {
208                description: "subscription driver emitted a non-open first command".to_string(),
209            });
210        }
211        let mut socket = WsSocket::connect(address, message_bound)?;
212        // Name the deadline this reader gives a synchronous control-frame reply
213        // rather than inheriting the socket layer's unnamed default. One value,
214        // shared by all three readers; `start` takes it back off before the
215        // background reader ever blocks on this socket.
216        socket.set_read_timeout(Some(SETUP_TIMEOUT))?;
217        let step = driver.handle_event(SocketEvent::Opened);
218        if step.output != DriverOutput::Opened {
219            return Err(SdkError::Protocol {
220                description: format!("subscription driver refused the opened socket: {step:?}"),
221            });
222        }
223
224        let mut pending = Vec::new();
225        let connect = Frame::Connect {
226            flags: 0,
227            min_version: CLIENT_MIN_VERSION,
228            max_version: CLIENT_MAX_VERSION,
229            auth_token: Vec::new(),
230        };
231        match setup_exchange(&mut socket, &mut driver, &connect, &mut pending)? {
232            Frame::ConnectAck { .. } => {}
233            Frame::ConnectError {
234                reason_code,
235                message,
236                ..
237            } => {
238                return Err(connection_error(&format!(
239                    "server rejected subscription connection (reason {reason_code}): {}",
240                    message.unwrap_or_else(|| "no detail".to_string())
241                )));
242            }
243            other => {
244                return Err(unexpected_setup_frame("ConnectAck", &other));
245            }
246        }
247
248        let subscribe = Frame::Subscribe {
249            flags: 0,
250            stream_id: SUBSCRIPTION_STREAM_ID,
251            channel: channel.to_string(),
252            accepted_schemas,
253            max_in_flight: SUBSCRIBE_MAX_IN_FLIGHT,
254        };
255        let subscription_id =
256            match setup_exchange(&mut socket, &mut driver, &subscribe, &mut pending)? {
257                Frame::SubscribeAck {
258                    subscription_id, ..
259                } => subscription_id,
260                Frame::SubscribeError {
261                    reason_code,
262                    message,
263                    ..
264                } => {
265                    return Err(SdkError::Protocol {
266                        description: format!(
267                            "server rejected subscribe (reason {reason_code}): {}",
268                            message.unwrap_or_else(|| "no detail".to_string())
269                        ),
270                    });
271                }
272                other => {
273                    return Err(unexpected_setup_frame("SubscribeAck", &other));
274                }
275            };
276        Ok((socket, driver, subscription_id, pending))
277    }
278
279    /// Starts the background reader over the established link.
280    fn start(
281        socket: WsSocket,
282        driver: WebSocketFrameDriver,
283        binding: WebSocketAuthorityBinding,
284        subscription_id: u64,
285        pending: Vec<WebSocketDeliveredMessage>,
286    ) -> Result<Self, SdkError> {
287        // The control exchange is over, so its deadline comes off: the reader
288        // blocks on socket input with no read window. Teardown shuts the socket
289        // down, which surfaces as a typed terminal event. This is the shape the
290        // two TCP readers were generalized toward — it must not regress into a
291        // cadence, whatever period that cadence would carry.
292        socket.set_read_timeout(None)?;
293        let shutdown = socket.try_clone_stream()?;
294        let binding = Arc::new(Mutex::new(binding));
295        let reader_binding = Arc::clone(&binding);
296        let (sender, inbound) = mpsc::channel();
297        let reader = std::thread::Builder::new()
298            .name("liminal-ws-subscription-reader".to_string())
299            .spawn(move || run_reader(socket, driver, &reader_binding, pending, &sender))
300            .map_err(|source| SdkError::Protocol {
301                description: format!(
302                    "failed to start websocket subscription reader thread: {source}"
303                ),
304            })?;
305        Ok(Self {
306            shutdown,
307            subscription_id,
308            inbound,
309            binding,
310            reader: Some(reader),
311        })
312    }
313}
314
315impl Drop for WebSocketSubscriptionStream {
316    fn drop(&mut self) {
317        // Best-effort teardown: shutting the shared socket down surfaces a
318        // typed terminal to the blocked reader, which records it and exits.
319        // The server frees the subscription on the connection's terminal fate
320        // (R1.3), so no unsubscribe write is required from this half.
321        self.shutdown.shutdown(std::net::Shutdown::Both).ok();
322        if let Some(reader) = self.reader.take() {
323            reader.join().ok();
324        }
325    }
326}
327
328/// Sends one setup request and blocks for its correlated control reply,
329/// retaining (never dropping) any deliveries that arrive first.
330fn setup_exchange(
331    socket: &mut WsSocket,
332    driver: &mut WebSocketFrameDriver,
333    request: &Frame,
334    pending: &mut Vec<WebSocketDeliveredMessage>,
335) -> Result<Frame, SdkError> {
336    let bytes = super::encode_frame(request)?;
337    let command = driver
338        .command_send(bytes, ResponseExpectation::Correlated)
339        .map_err(|refusal| SdkError::Protocol {
340            description: format!("subscription driver refused the setup send: {refusal:?}"),
341        })?;
342    let SocketCommand::SendBinary(payload) = command else {
343        return Err(SdkError::Protocol {
344            description: "subscription driver emitted a non-send command for a send".to_string(),
345        });
346    };
347    if let Err(failure) = socket.send_binary(payload) {
348        let step = driver.handle_event(SocketEvent::Failed(failure));
349        if step.command == Some(SocketCommand::Close) {
350            socket.execute_close();
351        }
352        return Err(connection_error(&format!(
353            "failed to send subscription setup frame: {}",
354            socket
355                .last_failure_detail()
356                .unwrap_or("websocket send failed")
357        )));
358    }
359    loop {
360        let event = match socket.read_event() {
361            SocketRead::TimedOut => {
362                return Err(connection_error(
363                    "subscription connection timed out waiting for a control-frame reply",
364                ));
365            }
366            SocketRead::Event(event) => event,
367        };
368        let step = driver.handle_event(event);
369        if step.command == Some(SocketCommand::Close) {
370            socket.execute_close();
371        }
372        match step.output {
373            DriverOutput::Frame { bytes, correlation } => {
374                let frame = decode_message(&bytes)?;
375                match correlation {
376                    FrameCorrelation::UnsolicitedDelivery => {
377                        if let Some(message) = delivered_message(frame) {
378                            pending.push(message);
379                        }
380                    }
381                    FrameCorrelation::CorrelatedResponse | FrameCorrelation::UnsolicitedFrame => {
382                        return Ok(frame);
383                    }
384                }
385            }
386            DriverOutput::Terminal(terminal) => {
387                return Err(connection_error(&format!(
388                    "subscription connection terminated during setup: {terminal:?}"
389                )));
390            }
391            DriverOutput::Opened
392            | DriverOutput::PostTerminalIgnored(_)
393            | DriverOutput::Refused(_) => {
394                return Err(SdkError::Protocol {
395                    description: format!(
396                        "subscription driver produced an unexpected setup output: {:?}",
397                        step.output
398                    ),
399                });
400            }
401        }
402    }
403}
404
405/// Background loop: feeds every socket fact through the driver and surfaces
406/// each delivery. Ends on the link's typed terminal fate (recorded into the
407/// client unit) or when the receiver is dropped.
408fn run_reader(
409    mut socket: WsSocket,
410    mut driver: WebSocketFrameDriver,
411    binding: &Mutex<WebSocketAuthorityBinding>,
412    pending: Vec<WebSocketDeliveredMessage>,
413    sender: &Sender<WebSocketDeliveredMessage>,
414) {
415    for message in pending {
416        if sender.send(message).is_err() {
417            close_link(&mut socket, &mut driver);
418            return;
419        }
420    }
421    loop {
422        let event = match socket.read_event() {
423            // No read window is armed on the reader socket; a timeout here
424            // means the OS returned early, and re-entering the blocking read
425            // is the only correct continuation (not a poll: no interval).
426            SocketRead::TimedOut => continue,
427            SocketRead::Event(event) => event,
428        };
429        let step = driver.handle_event(event);
430        if step.command == Some(SocketCommand::Close) {
431            socket.execute_close();
432        }
433        match step.output {
434            DriverOutput::Frame { bytes, correlation } => match correlation {
435                FrameCorrelation::UnsolicitedDelivery => {
436                    let Ok(frame) = decode_message(&bytes) else {
437                        // Malformed input closes the connection.
438                        close_link(&mut socket, &mut driver);
439                        continue;
440                    };
441                    if let Some(message) = delivered_message(frame) {
442                        if sender.send(message).is_err() {
443                            close_link(&mut socket, &mut driver);
444                            return;
445                        }
446                    }
447                }
448                FrameCorrelation::CorrelatedResponse | FrameCorrelation::UnsolicitedFrame => {
449                    match decode_message(&bytes) {
450                        Ok(Frame::Disconnect { .. }) => {
451                            // A server Disconnect ends the subscription
452                            // cleanly; commanding close lets the echoed close
453                            // event mint the one typed terminal below.
454                            close_link(&mut socket, &mut driver);
455                        }
456                        // Any other frame on a subscription connection is
457                        // unexpected; it is ignored (TCP parity) so a stray
458                        // frame cannot silently drop subsequent deliveries.
459                        Ok(_) => {}
460                        Err(_) => {
461                            close_link(&mut socket, &mut driver);
462                        }
463                    }
464                }
465            },
466            DriverOutput::Terminal(terminal) => {
467                // The one typed fate of this link enters the client unit;
468                // the dropped sender signals consumers that the pump ended.
469                let _outcome = binding.lock().established_terminal(&terminal);
470                return;
471            }
472            DriverOutput::PostTerminalIgnored(_) => return,
473            DriverOutput::Opened | DriverOutput::Refused(_) => {}
474        }
475    }
476}
477
478/// Commands a close (when still legal) and executes it on the socket.
479fn close_link(socket: &mut WsSocket, driver: &mut WebSocketFrameDriver) {
480    if driver.command_close().is_ok() {
481        socket.execute_close();
482    }
483}
484
485/// Decodes one driver-validated message into its canonical frame.
486fn decode_message(bytes: &[u8]) -> Result<Frame, SdkError> {
487    match decode(bytes) {
488        Ok((frame, consumed)) if consumed == bytes.len() => Ok(frame),
489        Ok((_, consumed)) => Err(SdkError::Protocol {
490            description: format!(
491                "subscription decode consumed {consumed} of {} message bytes",
492                bytes.len()
493            ),
494        }),
495        Err(error) => Err(SdkError::Protocol {
496            description: format!("subscription wire codec error: {error}"),
497        }),
498    }
499}
500
501/// Maps a `Deliver` frame to its delivered message; other frames map to none.
502fn delivered_message(frame: Frame) -> Option<WebSocketDeliveredMessage> {
503    match frame {
504        Frame::Deliver {
505            delivery_seq,
506            envelope,
507            ..
508        } => Some(WebSocketDeliveredMessage {
509            delivery_seq,
510            schema_id: envelope.schema_id,
511            payload: envelope.payload,
512        }),
513        _ => None,
514    }
515}
516
517/// Builds a protocol error describing an unexpected setup response frame.
518fn unexpected_setup_frame(expected: &str, actual: &Frame) -> SdkError {
519    SdkError::Protocol {
520        description: format!(
521            "expected {expected} during subscription setup, received {:?}",
522            actual.frame_type()
523        ),
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::SETUP_TIMEOUT;
530    use core::time::Duration;
531
532    /// TOMBSTONE (SDK-010 R5) — the WebSocket subscription reader is the shape
533    /// the two TCP readers were generalized toward, and it must stay that shape:
534    /// no reader poll family, no stop flag, no cadence in steady state. Its
535    /// disarm (`socket.set_read_timeout(None)` before the reader spawns) is
536    /// exercised behaviorally by the landed `sdk_ws_e2e` and
537    /// `ws_transport_parity` suites, which open a real subscription over a real
538    /// server; this guard holds the source itself.
539    #[test]
540    fn websocket_subscription_source_has_no_retired_reader_poll_family() {
541        const SOURCE: &str = include_str!("subscription.rs");
542        let production = SOURCE.split("#[cfg(test)]").next().unwrap_or(SOURCE);
543        for forbidden in [
544            "READER_POLL_TIMEOUT",
545            "AtomicBool",
546            "stop.load",
547            "stop.store",
548            "re-check the stop flag",
549            "poll the stop flag",
550        ] {
551            assert!(
552                !production.contains(forbidden),
553                "retired websocket-subscription-reader poll-family source \
554                 `{forbidden}` reappeared"
555            );
556        }
557    }
558
559    /// The one named deadline (SDK-010 R3): 5 s, shared by all three readers and
560    /// generalized from the estate's already-ratified value rather than
561    /// re-chosen. Pinned here so a per-reader fork of the value fails loudly.
562    #[test]
563    fn the_named_setup_deadline_is_the_ratified_five_seconds() {
564        assert_eq!(SETUP_TIMEOUT, Duration::from_secs(5));
565    }
566}