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