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        Self::open_with_auth(address, channel, accepted_schemas, &[])
119    }
120
121    /// Connects to the `ws://` address, performs the liminal handshake
122    /// carrying `auth_token`, subscribes to `channel`, and starts the
123    /// background reader.
124    ///
125    /// A subscription owns a dedicated connection (the v1 shape), so it
126    /// presents its own credential in its own `Connect` frame; the token a
127    /// request/response transport was built with lives on that transport's
128    /// socket and cannot travel here. Additive to [`open`]: an empty token is
129    /// exactly the open-access handshake `open` performs, so an ungated server
130    /// sees byte-identical bytes either way. TCP parity with
131    /// [`SubscriptionStream::open_with_auth`].
132    ///
133    /// # Errors
134    ///
135    /// Returns [`SdkError::Connection`] when the client unit refuses the open,
136    /// the socket cannot be opened, or the token is rejected, and
137    /// [`SdkError::Protocol`] when the subscribe is rejected.
138    ///
139    /// [`open`]: Self::open
140    /// [`SubscriptionStream::open_with_auth`]: crate::SubscriptionStream::open_with_auth
141    pub fn open_with_auth(
142        address: &str,
143        channel: &str,
144        accepted_schemas: Vec<SchemaId>,
145        auth_token: &[u8],
146    ) -> Result<Self, SdkError> {
147        let message_bound = liminal_ws_message_bound()?;
148        let mut binding = WebSocketAuthorityBinding::new();
149        match binding.request_open() {
150            OpenRequestDecision::Authorized { .. } => {}
151            OpenRequestDecision::Refused(refusal) => {
152                return Err(connection_error(&format!(
153                    "client authority refused the subscription open: {refusal:?}"
154                )));
155            }
156        }
157        match Self::open_link(
158            address,
159            channel,
160            accepted_schemas,
161            message_bound,
162            auth_token,
163        ) {
164            Ok((socket, driver, subscription_id, pending)) => {
165                match binding.connection_established() {
166                    AttemptFateOutcome::Recorded { .. } => {}
167                    AttemptFateOutcome::Refused(refusal) => {
168                        return Err(SdkError::Protocol {
169                            description: format!(
170                                "client authority refused the Connected fate for the \
171                                 subscription open: {refusal:?}"
172                            ),
173                        });
174                    }
175                }
176                Self::start(socket, driver, binding, subscription_id, pending)
177            }
178            Err(error) => match binding.open_failed() {
179                AttemptFateOutcome::Recorded { .. } => Err(error),
180                AttemptFateOutcome::Refused(refusal) => Err(SdkError::Protocol {
181                    description: format!(
182                        "subscription open failed ({error}) and the client authority \
183                             refused the Failed fate: {refusal:?}"
184                    ),
185                }),
186            },
187        }
188    }
189
190    /// Blocks up to `timeout` for the next delivered message.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`SdkError::Connection`] when no message arrives within
195    /// `timeout` or the background reader has stopped.
196    pub fn recv_timeout(&self, timeout: Duration) -> Result<WebSocketDeliveredMessage, SdkError> {
197        self.inbound.recv_timeout(timeout).map_err(|error| {
198            let detail = match error {
199                RecvTimeoutError::Timeout => "no delivery arrived within the timeout",
200                RecvTimeoutError::Disconnected => {
201                    "the subscription reader stopped before a delivery arrived"
202                }
203            };
204            connection_error(&format!("websocket subscription receive failed: {detail}"))
205        })
206    }
207
208    /// The server-assigned id for this subscription.
209    #[must_use]
210    pub const fn subscription_id(&self) -> u64 {
211        self.subscription_id
212    }
213
214    /// The client unit's reconnect state for this subscription's connection.
215    #[must_use]
216    pub fn reconnect_state(&self) -> ReconnectState {
217        self.binding.lock().reconnect_state()
218    }
219
220    /// Performs the socket open, handshake carrying `auth_token`, and subscribe
221    /// exchange.
222    fn open_link(
223        address: &str,
224        channel: &str,
225        accepted_schemas: Vec<SchemaId>,
226        message_bound: usize,
227        auth_token: &[u8],
228    ) -> Result<
229        (
230            WsSocket,
231            WebSocketFrameDriver,
232            u64,
233            Vec<WebSocketDeliveredMessage>,
234        ),
235        SdkError,
236    > {
237        let mut driver = WebSocketFrameDriver::new();
238        let command = driver
239            .command_open()
240            .map_err(|refusal| SdkError::Protocol {
241                description: format!("subscription driver refused its first open: {refusal:?}"),
242            })?;
243        if command != SocketCommand::Open {
244            return Err(SdkError::Protocol {
245                description: "subscription driver emitted a non-open first command".to_string(),
246            });
247        }
248        let mut socket = WsSocket::connect(address, message_bound)?;
249        // Name the deadline this reader gives a synchronous control-frame reply
250        // rather than inheriting the socket layer's unnamed default. One value,
251        // shared by all three readers; `start` takes it back off before the
252        // background reader ever blocks on this socket.
253        socket.set_read_timeout(Some(SETUP_TIMEOUT))?;
254        let step = driver.handle_event(SocketEvent::Opened);
255        if step.output != DriverOutput::Opened {
256            return Err(SdkError::Protocol {
257                description: format!("subscription driver refused the opened socket: {step:?}"),
258            });
259        }
260
261        let mut pending = Vec::new();
262        let connect = Frame::Connect {
263            flags: 0,
264            min_version: CLIENT_MIN_VERSION,
265            max_version: CLIENT_MAX_VERSION,
266            auth_token: auth_token.to_vec(),
267        };
268        match setup_exchange(&mut socket, &mut driver, &connect, &mut pending)? {
269            Frame::ConnectAck { .. } => {}
270            Frame::ConnectError {
271                reason_code,
272                message,
273                ..
274            } => {
275                return Err(connection_error(&format!(
276                    "server rejected subscription connection (reason {reason_code}): {}",
277                    message.unwrap_or_else(|| "no detail".to_string())
278                )));
279            }
280            other => {
281                return Err(unexpected_setup_frame("ConnectAck", &other));
282            }
283        }
284
285        let subscribe = Frame::Subscribe {
286            flags: 0,
287            stream_id: SUBSCRIPTION_STREAM_ID,
288            channel: channel.to_string(),
289            accepted_schemas,
290            max_in_flight: SUBSCRIBE_MAX_IN_FLIGHT,
291        };
292        let subscription_id =
293            match setup_exchange(&mut socket, &mut driver, &subscribe, &mut pending)? {
294                Frame::SubscribeAck {
295                    subscription_id, ..
296                } => subscription_id,
297                Frame::SubscribeError {
298                    reason_code,
299                    message,
300                    ..
301                } => {
302                    return Err(SdkError::Protocol {
303                        description: format!(
304                            "server rejected subscribe (reason {reason_code}): {}",
305                            message.unwrap_or_else(|| "no detail".to_string())
306                        ),
307                    });
308                }
309                other => {
310                    return Err(unexpected_setup_frame("SubscribeAck", &other));
311                }
312            };
313        Ok((socket, driver, subscription_id, pending))
314    }
315
316    /// Starts the background reader over the established link.
317    fn start(
318        socket: WsSocket,
319        driver: WebSocketFrameDriver,
320        binding: WebSocketAuthorityBinding,
321        subscription_id: u64,
322        pending: Vec<WebSocketDeliveredMessage>,
323    ) -> Result<Self, SdkError> {
324        // The control exchange is over, so its deadline comes off: the reader
325        // blocks on socket input with no read window. Teardown shuts the socket
326        // down, which surfaces as a typed terminal event. This is the shape the
327        // two TCP readers were generalized toward — it must not regress into a
328        // cadence, whatever period that cadence would carry.
329        socket.set_read_timeout(None)?;
330        let shutdown = socket.try_clone_stream()?;
331        let binding = Arc::new(Mutex::new(binding));
332        let reader_binding = Arc::clone(&binding);
333        let (sender, inbound) = mpsc::channel();
334        let reader = std::thread::Builder::new()
335            .name("liminal-ws-subscription-reader".to_string())
336            .spawn(move || run_reader(socket, driver, &reader_binding, pending, &sender))
337            .map_err(|source| SdkError::Protocol {
338                description: format!(
339                    "failed to start websocket subscription reader thread: {source}"
340                ),
341            })?;
342        Ok(Self {
343            shutdown,
344            subscription_id,
345            inbound,
346            binding,
347            reader: Some(reader),
348        })
349    }
350}
351
352impl Drop for WebSocketSubscriptionStream {
353    fn drop(&mut self) {
354        // Best-effort teardown: shutting the shared socket down surfaces a
355        // typed terminal to the blocked reader, which records it and exits.
356        // The server frees the subscription on the connection's terminal fate
357        // (R1.3), so no unsubscribe write is required from this half.
358        self.shutdown.shutdown(std::net::Shutdown::Both).ok();
359        if let Some(reader) = self.reader.take() {
360            reader.join().ok();
361        }
362    }
363}
364
365/// Sends one setup request and blocks for its correlated control reply,
366/// retaining (never dropping) any deliveries that arrive first.
367fn setup_exchange(
368    socket: &mut WsSocket,
369    driver: &mut WebSocketFrameDriver,
370    request: &Frame,
371    pending: &mut Vec<WebSocketDeliveredMessage>,
372) -> Result<Frame, SdkError> {
373    let bytes = super::encode_frame(request)?;
374    let command = driver
375        .command_send(bytes, ResponseExpectation::Correlated)
376        .map_err(|refusal| SdkError::Protocol {
377            description: format!("subscription driver refused the setup send: {refusal:?}"),
378        })?;
379    let SocketCommand::SendBinary(payload) = command else {
380        return Err(SdkError::Protocol {
381            description: "subscription driver emitted a non-send command for a send".to_string(),
382        });
383    };
384    if let Err(failure) = socket.send_binary(payload) {
385        let step = driver.handle_event(SocketEvent::Failed(failure));
386        if step.command == Some(SocketCommand::Close) {
387            socket.execute_close();
388        }
389        return Err(connection_error(&format!(
390            "failed to send subscription setup frame: {}",
391            socket
392                .last_failure_detail()
393                .unwrap_or("websocket send failed")
394        )));
395    }
396    loop {
397        let event = match socket.read_event() {
398            SocketRead::TimedOut => {
399                return Err(connection_error(
400                    "subscription connection timed out waiting for a control-frame reply",
401                ));
402            }
403            SocketRead::Event(event) => event,
404        };
405        let step = driver.handle_event(event);
406        if step.command == Some(SocketCommand::Close) {
407            socket.execute_close();
408        }
409        match step.output {
410            DriverOutput::Frame { bytes, correlation } => {
411                let frame = decode_message(&bytes)?;
412                match correlation {
413                    FrameCorrelation::UnsolicitedDelivery => {
414                        if let Some(message) = delivered_message(frame) {
415                            pending.push(message);
416                        }
417                    }
418                    FrameCorrelation::CorrelatedResponse | FrameCorrelation::UnsolicitedFrame => {
419                        return Ok(frame);
420                    }
421                }
422            }
423            DriverOutput::Terminal(terminal) => {
424                return Err(connection_error(&format!(
425                    "subscription connection terminated during setup: {terminal:?}"
426                )));
427            }
428            DriverOutput::Opened
429            | DriverOutput::PostTerminalIgnored(_)
430            | DriverOutput::Refused(_) => {
431                return Err(SdkError::Protocol {
432                    description: format!(
433                        "subscription driver produced an unexpected setup output: {:?}",
434                        step.output
435                    ),
436                });
437            }
438        }
439    }
440}
441
442/// Background loop: feeds every socket fact through the driver and surfaces
443/// each delivery. Ends on the link's typed terminal fate (recorded into the
444/// client unit) or when the receiver is dropped.
445fn run_reader(
446    mut socket: WsSocket,
447    mut driver: WebSocketFrameDriver,
448    binding: &Mutex<WebSocketAuthorityBinding>,
449    pending: Vec<WebSocketDeliveredMessage>,
450    sender: &Sender<WebSocketDeliveredMessage>,
451) {
452    for message in pending {
453        if sender.send(message).is_err() {
454            close_link(&mut socket, &mut driver);
455            return;
456        }
457    }
458    loop {
459        let event = match socket.read_event() {
460            // No read window is armed on the reader socket; a timeout here
461            // means the OS returned early, and re-entering the blocking read
462            // is the only correct continuation (not a poll: no interval).
463            SocketRead::TimedOut => continue,
464            SocketRead::Event(event) => event,
465        };
466        let step = driver.handle_event(event);
467        if step.command == Some(SocketCommand::Close) {
468            socket.execute_close();
469        }
470        match step.output {
471            DriverOutput::Frame { bytes, correlation } => match correlation {
472                FrameCorrelation::UnsolicitedDelivery => {
473                    let Ok(frame) = decode_message(&bytes) else {
474                        // Malformed input closes the connection.
475                        close_link(&mut socket, &mut driver);
476                        continue;
477                    };
478                    if let Some(message) = delivered_message(frame) {
479                        if sender.send(message).is_err() {
480                            close_link(&mut socket, &mut driver);
481                            return;
482                        }
483                    }
484                }
485                FrameCorrelation::CorrelatedResponse | FrameCorrelation::UnsolicitedFrame => {
486                    match decode_message(&bytes) {
487                        Ok(Frame::Disconnect { .. }) => {
488                            // A server Disconnect ends the subscription
489                            // cleanly; commanding close lets the echoed close
490                            // event mint the one typed terminal below.
491                            close_link(&mut socket, &mut driver);
492                        }
493                        // Any other frame on a subscription connection is
494                        // unexpected; it is ignored (TCP parity) so a stray
495                        // frame cannot silently drop subsequent deliveries.
496                        Ok(_) => {}
497                        Err(_) => {
498                            close_link(&mut socket, &mut driver);
499                        }
500                    }
501                }
502            },
503            DriverOutput::Terminal(terminal) => {
504                // The one typed fate of this link enters the client unit;
505                // the dropped sender signals consumers that the pump ended.
506                let _outcome = binding.lock().established_terminal(&terminal);
507                return;
508            }
509            DriverOutput::PostTerminalIgnored(_) => return,
510            DriverOutput::Opened | DriverOutput::Refused(_) => {}
511        }
512    }
513}
514
515/// Commands a close (when still legal) and executes it on the socket.
516fn close_link(socket: &mut WsSocket, driver: &mut WebSocketFrameDriver) {
517    if driver.command_close().is_ok() {
518        socket.execute_close();
519    }
520}
521
522/// Decodes one driver-validated message into its canonical frame.
523fn decode_message(bytes: &[u8]) -> Result<Frame, SdkError> {
524    match decode(bytes) {
525        Ok((frame, consumed)) if consumed == bytes.len() => Ok(frame),
526        Ok((_, consumed)) => Err(SdkError::Protocol {
527            description: format!(
528                "subscription decode consumed {consumed} of {} message bytes",
529                bytes.len()
530            ),
531        }),
532        Err(error) => Err(SdkError::Protocol {
533            description: format!("subscription wire codec error: {error}"),
534        }),
535    }
536}
537
538/// Maps a `Deliver` frame to its delivered message; other frames map to none.
539fn delivered_message(frame: Frame) -> Option<WebSocketDeliveredMessage> {
540    match frame {
541        Frame::Deliver {
542            delivery_seq,
543            envelope,
544            ..
545        } => Some(WebSocketDeliveredMessage {
546            delivery_seq,
547            schema_id: envelope.schema_id,
548            payload: envelope.payload,
549        }),
550        _ => None,
551    }
552}
553
554/// Builds a protocol error describing an unexpected setup response frame.
555fn unexpected_setup_frame(expected: &str, actual: &Frame) -> SdkError {
556    SdkError::Protocol {
557        description: format!(
558            "expected {expected} during subscription setup, received {:?}",
559            actual.frame_type()
560        ),
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::SETUP_TIMEOUT;
567    use core::time::Duration;
568
569    /// TOMBSTONE (SDK-010 R5) — the WebSocket subscription reader is the shape
570    /// the two TCP readers were generalized toward, and it must stay that shape:
571    /// no reader poll family, no stop flag, no cadence in steady state. Its
572    /// disarm (`socket.set_read_timeout(None)` before the reader spawns) is
573    /// exercised behaviorally by the landed `sdk_ws_e2e` and
574    /// `ws_transport_parity` suites, which open a real subscription over a real
575    /// server; this guard holds the source itself.
576    #[test]
577    fn websocket_subscription_source_has_no_retired_reader_poll_family() {
578        const SOURCE: &str = include_str!("subscription.rs");
579        let production = SOURCE.split("#[cfg(test)]").next().unwrap_or(SOURCE);
580        for forbidden in [
581            "READER_POLL_TIMEOUT",
582            "AtomicBool",
583            "stop.load",
584            "stop.store",
585            "re-check the stop flag",
586            "poll the stop flag",
587        ] {
588            assert!(
589                !production.contains(forbidden),
590                "retired websocket-subscription-reader poll-family source \
591                 `{forbidden}` reappeared"
592            );
593        }
594    }
595
596    /// The one named deadline (SDK-010 R3): 5 s, shared by all three readers and
597    /// generalized from the estate's already-ratified value rather than
598    /// re-chosen. Pinned here so a per-reader fork of the value fails loudly.
599    #[test]
600    fn the_named_setup_deadline_is_the_ratified_five_seconds() {
601        assert_eq!(SETUP_TIMEOUT, Duration::from_secs(5));
602    }
603}