mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 16:01:00 +00:00
430 lines
15 KiB
Rust
430 lines
15 KiB
Rust
//! Browser session state on top of the native frame transport.
|
|
use crate::{
|
|
Command, Event, FrameOpcode,
|
|
commands::{
|
|
CommandReceiver, MAX_QUEUED_BYTES, MAX_QUEUED_MESSAGES, QueuedCommand, Reservation,
|
|
},
|
|
events::{EventResult, EventSender, send_event},
|
|
frames::{Assembler, Received, close_payload},
|
|
};
|
|
use moli_curl::websocket::{
|
|
CurlWebSocketConnection, CurlWebSocketEvent, CurlWebSocketSend, MAX_SEND_FRAME_BYTES, WsFlags,
|
|
};
|
|
use std::{collections::VecDeque, future::Future, pin::Pin};
|
|
use tokio::time::{Duration, Instant};
|
|
|
|
type Delivery = Pin<Box<dyn Future<Output = EventResult> + Send>>;
|
|
type Sending = Pin<Box<dyn Future<Output = Result<Flight, String>> + Send>>;
|
|
const CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
// Browser delivery budget, independent of native queue capacity.
|
|
const MAX_PENDING_INCOMING_MESSAGES: usize = 9;
|
|
|
|
#[cfg(test)]
|
|
mod pong_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
struct Outgoing {
|
|
data: Vec<u8>,
|
|
opcode: FrameOpcode,
|
|
offset: usize,
|
|
_reservation: Reservation,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum Flight {
|
|
Data { count: usize, last: bool },
|
|
Pong,
|
|
Close,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Closing {
|
|
requested: Option<Vec<u8>>,
|
|
sent: bool,
|
|
received: Option<(u16, String)>,
|
|
deadline: Option<Instant>,
|
|
}
|
|
|
|
enum Activity {
|
|
Sent(Result<Flight, String>),
|
|
Delivered(EventResult),
|
|
Command(Option<QueuedCommand>),
|
|
Native(Option<CurlWebSocketEvent>),
|
|
CloseTimeout,
|
|
}
|
|
|
|
struct Session {
|
|
socket_id: u64,
|
|
outbox: VecDeque<Event>,
|
|
outgoing: VecDeque<Outgoing>,
|
|
pending_pong: Option<Vec<u8>>,
|
|
assembler: Assembler,
|
|
closing: Closing,
|
|
terminal: bool,
|
|
}
|
|
|
|
pub(super) async fn run_open_session(
|
|
socket_id: u64,
|
|
mut connection: CurlWebSocketConnection,
|
|
mut commands: CommandReceiver,
|
|
event_tx: EventSender,
|
|
) -> EventResult {
|
|
let sender = connection.sender();
|
|
let mut reading = true;
|
|
sender.set_reading(reading);
|
|
let mut session = Session {
|
|
socket_id,
|
|
outbox: VecDeque::new(),
|
|
outgoing: VecDeque::new(),
|
|
pending_pong: None,
|
|
assembler: Assembler::default(),
|
|
closing: Closing::default(),
|
|
terminal: false,
|
|
};
|
|
let mut delivery: Option<Delivery> = None;
|
|
let mut sending: Option<Sending> = None;
|
|
while !session.terminal {
|
|
if delivery.is_none()
|
|
&& let Some(event) = session.outbox.pop_front()
|
|
{
|
|
let sink = event_tx.clone();
|
|
delivery = Some(Box::pin(async move { send_event(&sink, event).await }));
|
|
}
|
|
if sending.is_none()
|
|
&& let Some((frame, next_flight)) = session.next_frame()
|
|
{
|
|
let sender = sender.clone();
|
|
sending = Some(Box::pin(async move {
|
|
sender
|
|
.send_frame(frame)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(next_flight)
|
|
}));
|
|
}
|
|
// Pause reads at the source while application delivery is backpressured.
|
|
let should_read = (delivery.is_none() && session.outbox.is_empty())
|
|
|| session.closing.requested.is_some();
|
|
if reading != should_read {
|
|
reading = should_read;
|
|
sender.set_reading(reading);
|
|
}
|
|
let deadline = session
|
|
.closing
|
|
.deadline
|
|
.unwrap_or_else(|| Instant::now() + CLOSE_TIMEOUT);
|
|
let activity = tokio::select! {
|
|
// Already completed writes precede newly observed incoming messages.
|
|
// Other sources remain fair; completed message residence is bounded.
|
|
biased;
|
|
result = async { sending.as_mut().expect("send exists").await }, if sending.is_some() => Activity::Sent(result),
|
|
activity = async {
|
|
tokio::select! {
|
|
result = async { delivery.as_mut().expect("delivery exists").await }, if delivery.is_some() => Activity::Delivered(result),
|
|
command = commands.recv() => Activity::Command(command),
|
|
event = connection.recv(), if should_read => Activity::Native(event),
|
|
_ = tokio::time::sleep_until(deadline), if session.closing.deadline.is_some() => Activity::CloseTimeout,
|
|
}
|
|
} => activity,
|
|
};
|
|
// The owner publishes a frame's completion before reading its echo. It
|
|
// can finish between the select's send poll and its receive poll, so
|
|
// recheck completion before turning native input into browser events.
|
|
if matches!(activity, Activity::Native(_))
|
|
&& let Some(pending) = sending.as_mut()
|
|
{
|
|
let completion =
|
|
std::future::poll_fn(|cx| std::task::Poll::Ready(pending.as_mut().poll(cx))).await;
|
|
if let std::task::Poll::Ready(result) = completion {
|
|
sending = None;
|
|
match result {
|
|
Ok(completed) => session.sent(completed),
|
|
Err(error) => session.fail(error),
|
|
}
|
|
if session.terminal {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
match activity {
|
|
Activity::Sent(result) => {
|
|
sending = None;
|
|
match result {
|
|
Ok(completed) => session.sent(completed),
|
|
Err(error) => session.fail(error),
|
|
}
|
|
}
|
|
Activity::Delivered(result) => {
|
|
delivery = None;
|
|
result?;
|
|
}
|
|
Activity::Command(Some(command)) => session.command(command),
|
|
Activity::Command(None) => return Ok(()),
|
|
Activity::Native(Some(CurlWebSocketEvent::Chunk { data, frame })) => {
|
|
match session.assembler.push(data, frame) {
|
|
Ok(Some(received)) => session.received(received),
|
|
Ok(None) => {}
|
|
Err(error) => session.fail(error),
|
|
}
|
|
}
|
|
Activity::Native(Some(CurlWebSocketEvent::Closed { result })) => {
|
|
// Closure can race the first poll above. Terminal delivery
|
|
// settles the pending write and rejects new sends before this await.
|
|
if let Some(pending) = sending.take()
|
|
&& let Ok(completed) = pending.await
|
|
{
|
|
session.sent(completed);
|
|
}
|
|
if !session.terminal {
|
|
session.fail(result.err().unwrap_or_else(|| {
|
|
"WebSocket closed without completing the closing handshake".to_owned()
|
|
}));
|
|
}
|
|
}
|
|
Activity::Native(Some(CurlWebSocketEvent::Handshake { .. })) => {
|
|
session.fail("WebSocket received a duplicate handshake".to_owned())
|
|
}
|
|
Activity::Native(None) => session.fail("WebSocket transport stopped".to_owned()),
|
|
Activity::CloseTimeout => {
|
|
session.fail("WebSocket closing handshake timed out".to_owned())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Network termination ends admission immediately, even when the browser's
|
|
// last events remain backpressured. Only delivery and the outbox survive.
|
|
let outbox = std::mem::take(&mut session.outbox);
|
|
drop(commands);
|
|
drop(connection);
|
|
drop(sender);
|
|
drop(sending);
|
|
drop(session);
|
|
if let Some(delivery) = delivery {
|
|
delivery.await?;
|
|
}
|
|
for event in outbox {
|
|
send_event(&event_tx, event).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl Session {
|
|
fn command(&mut self, queued: QueuedCommand) {
|
|
let QueuedCommand {
|
|
command,
|
|
reservation,
|
|
} = queued;
|
|
match command {
|
|
Command::SendText(text) if self.closing.requested.is_none() => {
|
|
self.outgoing.push_back(Outgoing {
|
|
data: text.into_bytes(),
|
|
opcode: FrameOpcode::Text,
|
|
offset: 0,
|
|
_reservation: reservation,
|
|
})
|
|
}
|
|
Command::SendBinary(data) if self.closing.requested.is_none() => {
|
|
self.outgoing.push_back(Outgoing {
|
|
data,
|
|
opcode: FrameOpcode::Binary,
|
|
offset: 0,
|
|
_reservation: reservation,
|
|
})
|
|
}
|
|
Command::Close { code, reason } if self.closing.requested.is_none() => {
|
|
match close_payload(code, reason) {
|
|
Ok(payload) => self.begin_close(payload, true),
|
|
Err(error) => self.fail(error),
|
|
}
|
|
}
|
|
Command::Fail(message) => self.fail(message),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn begin_close(&mut self, payload: Vec<u8>, publish_closing: bool) {
|
|
if self.closing.requested.is_none() {
|
|
self.closing.requested = Some(payload);
|
|
if publish_closing {
|
|
self.outbox.push_back(Event::Closing {
|
|
socket_id: self.socket_id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn next_frame(&mut self) -> Option<(CurlWebSocketSend, Flight)> {
|
|
if let Some(data) = self.pending_pong.take() {
|
|
return Some((
|
|
CurlWebSocketSend {
|
|
flags: WsFlags::PONG,
|
|
data,
|
|
},
|
|
Flight::Pong,
|
|
));
|
|
}
|
|
if let Some(message) = self.outgoing.front() {
|
|
// Native completion releases the data reservation, but its browser
|
|
// notifications can still be blocked. Bound that separate residence
|
|
// at message boundaries so a fragmented message always finishes.
|
|
if message.offset == 0
|
|
&& self
|
|
.outbox
|
|
.iter()
|
|
.filter(|event| matches!(event, Event::SendCompleted { .. }))
|
|
.count()
|
|
>= MAX_QUEUED_MESSAGES
|
|
{
|
|
return None;
|
|
}
|
|
let end = (message.offset + MAX_SEND_FRAME_BYTES).min(message.data.len());
|
|
let last = end == message.data.len();
|
|
let mut flags = match message.opcode {
|
|
FrameOpcode::Text => WsFlags::TEXT,
|
|
FrameOpcode::Binary => WsFlags::BINARY,
|
|
};
|
|
if !last {
|
|
flags |= WsFlags::CONT;
|
|
}
|
|
let data = message.data[message.offset..end].to_vec();
|
|
return Some((
|
|
CurlWebSocketSend { flags, data },
|
|
Flight::Data {
|
|
count: end - message.offset,
|
|
last,
|
|
},
|
|
));
|
|
}
|
|
if !self.closing.sent
|
|
&& let Some(data) = self.closing.requested.clone()
|
|
{
|
|
// Local close waits for previously accepted data. Only submitting
|
|
// Close starts its handshake deadline, not draining that data.
|
|
self.closing
|
|
.deadline
|
|
.get_or_insert_with(|| Instant::now() + CLOSE_TIMEOUT);
|
|
return Some((
|
|
CurlWebSocketSend {
|
|
flags: WsFlags::CLOSE,
|
|
data,
|
|
},
|
|
Flight::Close,
|
|
));
|
|
}
|
|
None
|
|
}
|
|
|
|
fn received(&mut self, received: Received) {
|
|
let socket_id = self.socket_id;
|
|
match received {
|
|
Received::Text(data) => self.queue_message(Event::TextMessage { socket_id, data }),
|
|
Received::Binary(data) => self.queue_message(Event::BinaryMessage { socket_id, data }),
|
|
Received::Ping(data) if !self.closing.sent => {
|
|
// RFC 6455 permits replying only to the latest pending Ping.
|
|
// A Pong already submitted to native I/O owns its payload.
|
|
self.pending_pong = Some(data);
|
|
}
|
|
Received::Close {
|
|
code,
|
|
reason,
|
|
payload,
|
|
} => {
|
|
self.closing.received = Some((code, reason));
|
|
// A peer Close already starts the handshake, even if a data
|
|
// frame is still in flight. Do not extend an existing deadline.
|
|
self.closing
|
|
.deadline
|
|
.get_or_insert_with(|| Instant::now() + CLOSE_TIMEOUT);
|
|
self.outgoing.clear();
|
|
self.pending_pong = None;
|
|
self.begin_close(payload, false);
|
|
self.finish_close();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn queue_message(&mut self, event: Event) {
|
|
fn message_size(event: &Event) -> Option<usize> {
|
|
match event {
|
|
Event::TextMessage { data, .. } => Some(data.len()),
|
|
Event::BinaryMessage { data, .. } => Some(data.len()),
|
|
_ => None,
|
|
}
|
|
}
|
|
// Preserve chunks already admitted when native reads paused. Closing
|
|
// also keeps reading control frames; its message backlog stays bounded.
|
|
let (count, bytes) = self
|
|
.outbox
|
|
.iter()
|
|
.filter_map(message_size)
|
|
.fold((0, 0usize), |(count, bytes), len| (count + 1, bytes + len));
|
|
let size = message_size(&event).expect("only message events use this queue");
|
|
if count >= MAX_PENDING_INCOMING_MESSAGES || bytes.saturating_add(size) > MAX_QUEUED_BYTES {
|
|
self.fail("WebSocket delivery queue capacity exceeded".to_owned());
|
|
} else {
|
|
self.outbox.push_back(event);
|
|
}
|
|
}
|
|
|
|
fn sent(&mut self, flight: Flight) {
|
|
match flight {
|
|
Flight::Data { count, last } => {
|
|
// A remote Close may have discarded the rest of this message.
|
|
if let Some(message) = self.outgoing.front_mut() {
|
|
message.offset += count;
|
|
if last {
|
|
let message = self
|
|
.outgoing
|
|
.pop_front()
|
|
.expect("completed message is resident");
|
|
self.outbox.push_back(Event::SendCompleted {
|
|
socket_id: self.socket_id,
|
|
opcode: message.opcode,
|
|
payload_length: message.data.len(),
|
|
});
|
|
// The admission reservation drops only at native completion.
|
|
}
|
|
}
|
|
}
|
|
Flight::Close => {
|
|
self.closing.sent = true;
|
|
self.finish_close();
|
|
}
|
|
Flight::Pong => {}
|
|
}
|
|
}
|
|
|
|
fn finish_close(&mut self) {
|
|
if self.closing.sent
|
|
&& let Some((code, reason)) = self.closing.received.take()
|
|
{
|
|
self.terminal = true;
|
|
self.outbox.push_back(Event::Close {
|
|
socket_id: self.socket_id,
|
|
code,
|
|
reason,
|
|
was_clean: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn fail(&mut self, message: String) {
|
|
if self.terminal {
|
|
return;
|
|
}
|
|
self.terminal = true;
|
|
self.outbox.push_back(Event::Error {
|
|
socket_id: self.socket_id,
|
|
message,
|
|
});
|
|
self.outbox.push_back(Event::Close {
|
|
socket_id: self.socket_id,
|
|
code: 1006,
|
|
reason: String::new(),
|
|
was_clean: false,
|
|
});
|
|
}
|
|
}
|