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