liminal_sdk/remote/websocket/core.rs
1//! Transport-neutral, event-driven WebSocket liminal driver (R3.1).
2//!
3//! The driver's inputs are the closed socket events an adapter observes
4//! ([`SocketEvent`]) and its outputs are the closed commands an adapter
5//! executes ([`SocketCommand`]) plus typed transport fates. It owns
6//! canonical-frame validation (exactly one self-delimiting liminal frame per
7//! binary message, bounded by the named product limit) and the in-flight wire
8//! correlation state (at most one outstanding correlated exchange, the Q-B
9//! rule). It is `no_std + alloc` and contains no platform type: the blocking
10//! std adapter and the later browser adapter drive the same commands, so
11//! neither owns reconnect, retry, replay, or correlation policy.
12//!
13//! F3 terminal discipline: the FIRST terminal event mints exactly one typed
14//! [`TransportTerminal`]; every later terminal (the browser's `close` echo
15//! after `error`, a duplicate close, a late message) is a typed no-op
16//! ([`DriverOutput::PostTerminalIgnored`]) and never reaches the client unit.
17
18use alloc::vec::Vec;
19
20use liminal_protocol::wire::{FRAME_MAX, GENERIC_HEADER_LEN};
21
22/// Wire discriminant of the server `Deliver` frame in the canonical registry.
23///
24/// The driver is `no_std` and cannot depend on the std-bound `liminal` crate
25/// that owns `FrameType`; the parity suite pins this byte against
26/// `FrameType::Deliver` so drift is impossible without a red test.
27const FRAME_TYPE_DELIVER: u8 = 0x19;
28
29/// Byte offset of the big-endian `u32` payload length in the generic header.
30const PAYLOAD_LENGTH_OFFSET: usize = 6;
31
32/// Closed socket-failure classes an adapter may report.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum SocketFailure {
35 /// The socket failed at the transport layer (I/O or WebSocket protocol).
36 Transport,
37 /// The peer sent a text message; the wire contract admits only binary.
38 UnsupportedTextMessage,
39 /// The peer declared a message beyond the pinned reassembly bound (F2).
40 MessageBeyondBound,
41}
42
43/// Closed socket events an adapter feeds into the driver.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub enum SocketEvent {
46 /// The socket completed its transport-level open.
47 Opened,
48 /// One complete, reassembled binary message.
49 Binary(Vec<u8>),
50 /// The socket closed cleanly.
51 Closed,
52 /// The socket failed with a typed failure class.
53 Failed(SocketFailure),
54}
55
56/// Closed commands the driver emits for an adapter to execute.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum SocketCommand {
59 /// Open the transport-level socket.
60 Open,
61 /// Send one complete binary message.
62 SendBinary(Vec<u8>),
63 /// Close the transport-level socket.
64 Close,
65}
66
67/// Typed canonical-frame violations the driver detects on inbound messages.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum FrameViolation {
70 /// The binary message carried no bytes at all.
71 EmptyMessage,
72 /// The message ended before the ten-byte generic header completed.
73 TruncatedHeader {
74 /// Bytes actually present.
75 length: usize,
76 },
77 /// The declared complete frame exceeds the active liminal frame bound.
78 DeclaredBeyondBound {
79 /// Declared complete-frame size in bytes.
80 declared_total: u64,
81 /// The active bound the declaration exceeded.
82 bound: u64,
83 },
84 /// The message ended before the declared body completed.
85 TruncatedBody {
86 /// Declared complete-frame size in bytes.
87 declared_total: u64,
88 /// Bytes actually present.
89 actual: u64,
90 },
91 /// The message carried bytes past the declared frame (including a second
92 /// concatenated frame).
93 TrailingBytes {
94 /// Declared complete-frame size in bytes.
95 declared_total: u64,
96 /// Bytes actually present.
97 actual: u64,
98 },
99 /// The framing was valid but the canonical codec refused the body.
100 ///
101 /// This variant is minted by the codec-owning adapter layer, never by the
102 /// `no_std` driver (which validates the self-delimiting header shape and
103 /// deliberately does not decode bodies).
104 UndecodableBody,
105}
106
107/// The one typed terminal fate a driver lifetime mints (F3).
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum TransportTerminal {
110 /// The peer closed the connection cleanly without a local close command.
111 PeerClosed,
112 /// A locally commanded close completed with the echoed close event.
113 CloseCompleted,
114 /// The socket failed with a typed failure class.
115 SocketFailed(SocketFailure),
116 /// An inbound message violated the canonical-frame contract.
117 ProtocolViolation(FrameViolation),
118}
119
120/// Driver lifecycle phase, exposed read-only for adapters and tests.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum DriverPhase {
123 /// No open has been commanded.
124 Idle,
125 /// The open command was emitted and no `Opened` event has arrived.
126 Opening,
127 /// The socket is established.
128 Established,
129 /// A close was commanded and its echoed close event is pending.
130 Closing,
131 /// A terminal fate was minted; every further event is a typed no-op.
132 Terminated,
133}
134
135/// Correlation class of one validated inbound frame.
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub enum FrameCorrelation {
138 /// The frame resolves the single outstanding correlated exchange.
139 CorrelatedResponse,
140 /// A server `Deliver` frame, never correlated to an exchange.
141 UnsolicitedDelivery,
142 /// A non-`Deliver` frame that arrived with no exchange outstanding.
143 UnsolicitedFrame,
144}
145
146/// Kind of event observed after the terminal fate was already minted.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum PostTerminalEvent {
149 /// A late `Opened` event.
150 Opened,
151 /// A late binary message.
152 Binary,
153 /// A late close event (the F3 `error`-then-`close` echo).
154 Closed,
155 /// A late failure event.
156 Failed,
157}
158
159/// Typed refusal for an event that is illegal in the current phase.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum EventRefusal {
162 /// `Opened` arrived while no open command was outstanding.
163 OpenedWithoutOpenCommand,
164 /// A binary message arrived before the socket was established.
165 BinaryBeforeEstablished,
166}
167
168/// Whether an outbound send expects a correlated response frame.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum ResponseExpectation {
171 /// Fire-and-forget: no response frame is correlated to this send.
172 None,
173 /// The next non-`Deliver` inbound frame resolves this exchange.
174 Correlated,
175}
176
177/// Typed refusal for a command that is illegal in the current driver state.
178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179pub enum CommandRefusal {
180 /// The command is not legal in the current phase.
181 InvalidPhase {
182 /// The refusing phase.
183 phase: DriverPhase,
184 },
185 /// A correlated exchange is already outstanding (the Q-B rule).
186 ExchangeOutstanding,
187}
188
189/// One decision output of the driver.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub enum DriverOutput {
192 /// The socket proved open; the binding may record `Connected`.
193 Opened,
194 /// One validated canonical frame with its correlation class.
195 Frame {
196 /// The exact canonical frame bytes (header plus declared body).
197 bytes: Vec<u8>,
198 /// Correlation class of this frame.
199 correlation: FrameCorrelation,
200 },
201 /// The one typed terminal fate of this driver lifetime.
202 Terminal(TransportTerminal),
203 /// A typed post-terminal no-op (F3); never reaches the client unit.
204 PostTerminalIgnored(PostTerminalEvent),
205 /// A typed refusal of an event illegal in the current phase.
206 Refused(EventRefusal),
207}
208
209/// One driver step: the decision output plus at most one emitted command.
210#[derive(Clone, Debug, PartialEq, Eq)]
211#[must_use]
212pub struct DriverStep {
213 /// The decision output for the handled event.
214 pub output: DriverOutput,
215 /// A command the adapter must execute, when one was emitted.
216 pub command: Option<SocketCommand>,
217}
218
219impl DriverStep {
220 const fn output(output: DriverOutput) -> Self {
221 Self {
222 output,
223 command: None,
224 }
225 }
226
227 const fn with_command(output: DriverOutput, command: SocketCommand) -> Self {
228 Self {
229 output,
230 command: Some(command),
231 }
232 }
233}
234
235/// Transport-neutral WebSocket liminal driver.
236///
237/// One driver owns one socket lifetime: it opens at most once and mints at
238/// most one terminal fate. Reconnecting means a fresh driver under a fresh
239/// aggregate-issued authorization — the driver itself can never re-open.
240#[derive(Debug)]
241pub struct WebSocketFrameDriver {
242 phase: DriverPhase,
243 exchange_outstanding: bool,
244}
245
246impl WebSocketFrameDriver {
247 /// Creates an idle driver bound to the canonical liminal frame bound.
248 #[must_use]
249 pub const fn new() -> Self {
250 Self {
251 phase: DriverPhase::Idle,
252 exchange_outstanding: false,
253 }
254 }
255
256 /// Current lifecycle phase.
257 #[must_use]
258 pub const fn phase(&self) -> DriverPhase {
259 self.phase
260 }
261
262 /// Reports whether one correlated exchange is outstanding.
263 #[must_use]
264 pub const fn has_outstanding_exchange(&self) -> bool {
265 self.exchange_outstanding
266 }
267
268 /// Emits the single open command of this driver lifetime.
269 ///
270 /// # Errors
271 ///
272 /// Returns [`CommandRefusal::InvalidPhase`] unless the driver is idle.
273 pub const fn command_open(&mut self) -> Result<SocketCommand, CommandRefusal> {
274 match self.phase {
275 DriverPhase::Idle => {
276 self.phase = DriverPhase::Opening;
277 Ok(SocketCommand::Open)
278 }
279 DriverPhase::Opening
280 | DriverPhase::Established
281 | DriverPhase::Closing
282 | DriverPhase::Terminated => Err(CommandRefusal::InvalidPhase { phase: self.phase }),
283 }
284 }
285
286 /// Emits a send command for one canonical frame image.
287 ///
288 /// # Errors
289 ///
290 /// Returns [`CommandRefusal::InvalidPhase`] unless the socket is
291 /// established, and [`CommandRefusal::ExchangeOutstanding`] when a
292 /// correlated exchange is already in flight (the Q-B rule).
293 pub fn command_send(
294 &mut self,
295 bytes: Vec<u8>,
296 expectation: ResponseExpectation,
297 ) -> Result<SocketCommand, CommandRefusal> {
298 if self.phase != DriverPhase::Established {
299 return Err(CommandRefusal::InvalidPhase { phase: self.phase });
300 }
301 match expectation {
302 ResponseExpectation::Correlated => {
303 if self.exchange_outstanding {
304 return Err(CommandRefusal::ExchangeOutstanding);
305 }
306 self.exchange_outstanding = true;
307 }
308 ResponseExpectation::None => {}
309 }
310 Ok(SocketCommand::SendBinary(bytes))
311 }
312
313 /// Emits the close command, moving the driver into the closing phase.
314 ///
315 /// The commanded close is not itself terminal: the echoed close event
316 /// mints [`TransportTerminal::CloseCompleted`] (F3).
317 ///
318 /// # Errors
319 ///
320 /// Returns [`CommandRefusal::InvalidPhase`] unless the socket is opening
321 /// or established.
322 pub const fn command_close(&mut self) -> Result<SocketCommand, CommandRefusal> {
323 match self.phase {
324 DriverPhase::Opening | DriverPhase::Established => {
325 self.phase = DriverPhase::Closing;
326 Ok(SocketCommand::Close)
327 }
328 DriverPhase::Idle | DriverPhase::Closing | DriverPhase::Terminated => {
329 Err(CommandRefusal::InvalidPhase { phase: self.phase })
330 }
331 }
332 }
333
334 /// Handles one closed socket event and returns the driver's decision.
335 pub fn handle_event(&mut self, event: SocketEvent) -> DriverStep {
336 if self.phase == DriverPhase::Terminated {
337 let kind = match event {
338 SocketEvent::Opened => PostTerminalEvent::Opened,
339 SocketEvent::Binary(_) => PostTerminalEvent::Binary,
340 SocketEvent::Closed => PostTerminalEvent::Closed,
341 SocketEvent::Failed(_) => PostTerminalEvent::Failed,
342 };
343 return DriverStep::output(DriverOutput::PostTerminalIgnored(kind));
344 }
345 match event {
346 SocketEvent::Opened => self.handle_opened(),
347 SocketEvent::Binary(bytes) => self.handle_binary(bytes),
348 SocketEvent::Closed => self.handle_closed(),
349 SocketEvent::Failed(failure) => self.handle_failed(failure),
350 }
351 }
352
353 const fn handle_opened(&mut self) -> DriverStep {
354 match self.phase {
355 DriverPhase::Opening => {
356 self.phase = DriverPhase::Established;
357 DriverStep::output(DriverOutput::Opened)
358 }
359 DriverPhase::Idle | DriverPhase::Established | DriverPhase::Closing => {
360 DriverStep::output(DriverOutput::Refused(
361 EventRefusal::OpenedWithoutOpenCommand,
362 ))
363 }
364 DriverPhase::Terminated => unreachable_terminated(),
365 }
366 }
367
368 fn handle_binary(&mut self, bytes: Vec<u8>) -> DriverStep {
369 match self.phase {
370 DriverPhase::Established | DriverPhase::Closing => match validate_frame(&bytes) {
371 Ok(()) => {
372 let correlation = self.classify_frame(&bytes);
373 DriverStep::output(DriverOutput::Frame { bytes, correlation })
374 }
375 Err(violation) => self.mint_terminal(
376 TransportTerminal::ProtocolViolation(violation),
377 // A violation from the established phase must actively
378 // close the socket; after a commanded close it is already
379 // closing, so no second close command is emitted.
380 self.phase == DriverPhase::Established,
381 ),
382 },
383 DriverPhase::Idle | DriverPhase::Opening => {
384 DriverStep::output(DriverOutput::Refused(EventRefusal::BinaryBeforeEstablished))
385 }
386 DriverPhase::Terminated => unreachable_terminated(),
387 }
388 }
389
390 const fn handle_closed(&mut self) -> DriverStep {
391 let terminal = match self.phase {
392 DriverPhase::Closing => TransportTerminal::CloseCompleted,
393 DriverPhase::Idle | DriverPhase::Opening | DriverPhase::Established => {
394 TransportTerminal::PeerClosed
395 }
396 DriverPhase::Terminated => return unreachable_terminated(),
397 };
398 self.mint_terminal(terminal, false)
399 }
400
401 const fn handle_failed(&mut self, failure: SocketFailure) -> DriverStep {
402 let emit_close = !matches!(self.phase, DriverPhase::Closing);
403 self.mint_terminal(TransportTerminal::SocketFailed(failure), emit_close)
404 }
405
406 /// Mints the single terminal fate and optionally commands a socket close.
407 const fn mint_terminal(&mut self, terminal: TransportTerminal, emit_close: bool) -> DriverStep {
408 self.phase = DriverPhase::Terminated;
409 self.exchange_outstanding = false;
410 if emit_close {
411 DriverStep::with_command(DriverOutput::Terminal(terminal), SocketCommand::Close)
412 } else {
413 DriverStep::output(DriverOutput::Terminal(terminal))
414 }
415 }
416
417 fn classify_frame(&mut self, bytes: &[u8]) -> FrameCorrelation {
418 if bytes.first().copied() == Some(FRAME_TYPE_DELIVER) {
419 return FrameCorrelation::UnsolicitedDelivery;
420 }
421 if self.exchange_outstanding {
422 self.exchange_outstanding = false;
423 return FrameCorrelation::CorrelatedResponse;
424 }
425 FrameCorrelation::UnsolicitedFrame
426 }
427}
428
429impl Default for WebSocketFrameDriver {
430 fn default() -> Self {
431 Self::new()
432 }
433}
434
435/// The terminated phase is filtered before per-event handling; reaching a
436/// per-event handler terminated would be a driver defect, and reporting it as
437/// a typed post-terminal no-op keeps the closed output contract intact.
438const fn unreachable_terminated() -> DriverStep {
439 DriverStep::output(DriverOutput::PostTerminalIgnored(PostTerminalEvent::Failed))
440}
441
442/// Validates that `bytes` hold exactly one canonical liminal frame.
443///
444/// The generic frame is self-delimiting: a ten-byte header whose bytes
445/// `6..10` carry the big-endian `u32` payload length, so one complete frame
446/// occupies exactly `10 + payload_length` bytes. The complete size is bounded
447/// by the named product limit [`FRAME_MAX`].
448fn validate_frame(bytes: &[u8]) -> Result<(), FrameViolation> {
449 if bytes.is_empty() {
450 return Err(FrameViolation::EmptyMessage);
451 }
452 if bytes.len() < GENERIC_HEADER_LEN {
453 return Err(FrameViolation::TruncatedHeader {
454 length: bytes.len(),
455 });
456 }
457 let Some(length_bytes) = bytes.get(PAYLOAD_LENGTH_OFFSET..GENERIC_HEADER_LEN) else {
458 return Err(FrameViolation::TruncatedHeader {
459 length: bytes.len(),
460 });
461 };
462 let Ok(length_array) = <[u8; 4]>::try_from(length_bytes) else {
463 return Err(FrameViolation::TruncatedHeader {
464 length: bytes.len(),
465 });
466 };
467 let declared_payload = u64::from(u32::from_be_bytes(length_array));
468 let Ok(header_len) = u64::try_from(GENERIC_HEADER_LEN) else {
469 // The ten-byte header length always fits `u64`; a target where it did
470 // not could not validate any frame, so the message is refused typed.
471 return Err(FrameViolation::TruncatedHeader {
472 length: bytes.len(),
473 });
474 };
475 let declared_total = declared_payload.saturating_add(header_len);
476 if declared_total > FRAME_MAX {
477 return Err(FrameViolation::DeclaredBeyondBound {
478 declared_total,
479 bound: FRAME_MAX,
480 });
481 }
482 // A message longer than `u64::MAX` bytes cannot exist on any real target;
483 // saturating keeps the comparison honest (it exceeds every declaration).
484 let actual = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
485 if actual < declared_total {
486 return Err(FrameViolation::TruncatedBody {
487 declared_total,
488 actual,
489 });
490 }
491 if actual > declared_total {
492 return Err(FrameViolation::TrailingBytes {
493 declared_total,
494 actual,
495 });
496 }
497 Ok(())
498}