mirror of
https://github.com/lexmount/moli.git
synced 2026-10-04 16:00:47 +00:00
367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
use std::time::Instant;
|
|
|
|
use axum::extract::ws::{Message, WebSocket};
|
|
use futures_util::{SinkExt, stream::SplitSink};
|
|
use moli_bounded_json::{BoundedJsonError, json_string_between_with_limit, to_string_with_limit};
|
|
use serde_json::Value;
|
|
use tokio::sync::{mpsc, oneshot};
|
|
|
|
use self::pending_bytes::{PendingByteBudget, PendingByteReservation};
|
|
|
|
mod pending_bytes;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
// Keep the outbound boundary bounded. A slow frontend is detached instead of
|
|
// growing protocol output without limit or blocking the target owner.
|
|
const DEFAULT_WRITER_QUEUE_CAPACITY: usize = 1024;
|
|
const DEFAULT_MAX_PENDING_WRITER_BYTES: usize = 64 * 1024 * 1024;
|
|
const DEFAULT_MAX_WRITER_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct CdpSocketSink {
|
|
output_tx: mpsc::Sender<SocketWriterCommand>,
|
|
close_tx: tokio::sync::watch::Sender<SocketCloseSignal>,
|
|
pending_byte_budget: PendingByteBudget,
|
|
max_message_bytes: usize,
|
|
}
|
|
|
|
pub(crate) type CdpSocketWriterFinishedReceiver = oneshot::Receiver<()>;
|
|
|
|
struct ProtocolOutputEnvelope {
|
|
message: String,
|
|
_pending_bytes: PendingByteReservation,
|
|
}
|
|
|
|
enum SocketWriterCommand {
|
|
Output(ProtocolOutputEnvelope),
|
|
CloseAfterFlush,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum SocketCloseSignal {
|
|
Open,
|
|
ImmediateClose,
|
|
PeerCloseReceived,
|
|
}
|
|
|
|
pub(crate) fn spawn_socket_sink(
|
|
socket: SplitSink<WebSocket, Message>,
|
|
) -> (CdpSocketSink, CdpSocketWriterFinishedReceiver) {
|
|
spawn_socket_sink_with_limits(
|
|
socket,
|
|
DEFAULT_WRITER_QUEUE_CAPACITY,
|
|
DEFAULT_MAX_PENDING_WRITER_BYTES,
|
|
DEFAULT_MAX_WRITER_MESSAGE_BYTES,
|
|
)
|
|
}
|
|
|
|
fn spawn_socket_sink_with_limits(
|
|
socket: SplitSink<WebSocket, Message>,
|
|
queue_capacity: usize,
|
|
max_pending_bytes: usize,
|
|
max_message_bytes: usize,
|
|
) -> (CdpSocketSink, CdpSocketWriterFinishedReceiver) {
|
|
let (output_tx, mut output_rx) = mpsc::channel::<SocketWriterCommand>(queue_capacity);
|
|
let (close_tx, mut close_rx) = tokio::sync::watch::channel(SocketCloseSignal::Open);
|
|
let (finished_tx, finished_rx) = oneshot::channel::<()>();
|
|
let pending_byte_budget = PendingByteBudget::new(max_pending_bytes);
|
|
tokio::spawn(async move {
|
|
let mut socket = socket;
|
|
let mut finished_tx = Some(finished_tx);
|
|
let mut close_signal = SocketCloseSignal::Open;
|
|
'writer: loop {
|
|
let command = tokio::select! {
|
|
biased;
|
|
_ = close_rx.changed() => {
|
|
close_signal = *close_rx.borrow();
|
|
break;
|
|
}
|
|
command = output_rx.recv() => {
|
|
let Some(command) = command else {
|
|
break;
|
|
};
|
|
command
|
|
}
|
|
};
|
|
let SocketWriterCommand::Output(envelope) = command else {
|
|
break;
|
|
};
|
|
let ProtocolOutputEnvelope {
|
|
message,
|
|
_pending_bytes,
|
|
} = envelope;
|
|
let trace_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now);
|
|
if trace_started.is_some() {
|
|
tracing::info!(
|
|
target: "moli_cdp_runtime",
|
|
stage = "writer_batch_start",
|
|
messages = 1,
|
|
);
|
|
}
|
|
let send = send_socket_text(&mut socket, message);
|
|
tokio::pin!(send);
|
|
let sent = tokio::select! {
|
|
biased;
|
|
_ = close_rx.changed() => {
|
|
close_signal = *close_rx.borrow();
|
|
if close_signal == SocketCloseSignal::PeerCloseReceived {
|
|
// Do not leave a partially-started SplitSink send in
|
|
// its slot ahead of tungstenite's automatic close reply.
|
|
let _ = send.await;
|
|
}
|
|
false
|
|
},
|
|
sent = &mut send => sent,
|
|
};
|
|
if !sent {
|
|
if close_signal == SocketCloseSignal::Open {
|
|
close_signal = SocketCloseSignal::ImmediateClose;
|
|
}
|
|
break 'writer;
|
|
}
|
|
if let Some(started) = trace_started {
|
|
tracing::info!(
|
|
target: "moli_cdp_runtime",
|
|
stage = "writer_batch_done",
|
|
messages = 1,
|
|
elapsed_us = %started.elapsed().as_micros(),
|
|
);
|
|
}
|
|
}
|
|
match close_signal {
|
|
SocketCloseSignal::PeerCloseReceived => {
|
|
let _ = socket.flush().await;
|
|
}
|
|
SocketCloseSignal::Open | SocketCloseSignal::ImmediateClose => {
|
|
let _ = socket.close().await;
|
|
}
|
|
}
|
|
notify_socket_writer_finished(&mut finished_tx);
|
|
});
|
|
(
|
|
CdpSocketSink {
|
|
output_tx,
|
|
close_tx,
|
|
pending_byte_budget,
|
|
max_message_bytes,
|
|
},
|
|
finished_rx,
|
|
)
|
|
}
|
|
|
|
impl CdpSocketSink {
|
|
pub(crate) fn enqueue_owned_message(&self, message: Value) -> bool {
|
|
let available_pending_bytes = self.pending_byte_budget.available();
|
|
let serialization_limit = self.max_message_bytes.min(available_pending_bytes);
|
|
let message = match serialize_owned_message_with_limit(message, serialization_limit) {
|
|
Ok(message) => message,
|
|
Err(BoundedJsonError::LimitExceeded { .. }) => {
|
|
tracing::warn!(
|
|
max_message_bytes = self.max_message_bytes,
|
|
available_pending_bytes,
|
|
"closing CDP frontend after oversized protocol message"
|
|
);
|
|
self.close();
|
|
return false;
|
|
}
|
|
Err(BoundedJsonError::Serialization(error)) => {
|
|
tracing::warn!(?error, "failed to serialize CDP protocol output");
|
|
self.close();
|
|
return false;
|
|
}
|
|
};
|
|
let Some(pending_bytes) = self.pending_byte_budget.try_reserve(message.len()) else {
|
|
tracing::warn!(
|
|
message_bytes = message.len(),
|
|
max_pending_bytes = self.pending_byte_budget.limit(),
|
|
"closing CDP frontend after outbound byte budget overflow"
|
|
);
|
|
self.close();
|
|
return false;
|
|
};
|
|
let envelope = ProtocolOutputEnvelope {
|
|
message,
|
|
_pending_bytes: pending_bytes,
|
|
};
|
|
if self
|
|
.output_tx
|
|
.try_send(SocketWriterCommand::Output(envelope))
|
|
.is_err()
|
|
{
|
|
self.close();
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(crate) fn close(&self) {
|
|
self.close_tx.send_if_modified(|signal| {
|
|
if *signal == SocketCloseSignal::PeerCloseReceived {
|
|
return false;
|
|
}
|
|
*signal = SocketCloseSignal::ImmediateClose;
|
|
true
|
|
});
|
|
}
|
|
|
|
pub(crate) fn peer_close_received(&self) {
|
|
let _ = self.close_tx.send(SocketCloseSignal::PeerCloseReceived);
|
|
}
|
|
|
|
pub(crate) fn close_after_flush(&self) {
|
|
if self
|
|
.output_tx
|
|
.try_send(SocketWriterCommand::CloseAfterFlush)
|
|
.is_err()
|
|
{
|
|
self.close();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn for_test() -> Self {
|
|
let (output_tx, _output_rx) = mpsc::channel(1);
|
|
let (close_tx, _close_rx) = tokio::sync::watch::channel(SocketCloseSignal::Open);
|
|
Self {
|
|
output_tx,
|
|
close_tx,
|
|
pending_byte_budget: PendingByteBudget::new(DEFAULT_MAX_PENDING_WRITER_BYTES),
|
|
max_message_bytes: DEFAULT_MAX_WRITER_MESSAGE_BYTES,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn with_stalled_writer_for_test(
|
|
queue_capacity: usize,
|
|
) -> (Self, StalledCdpSocketWriter) {
|
|
let (output_tx, output_rx) = mpsc::channel(queue_capacity);
|
|
let (close_tx, close_rx) = tokio::sync::watch::channel(SocketCloseSignal::Open);
|
|
(
|
|
Self {
|
|
output_tx,
|
|
close_tx,
|
|
pending_byte_budget: PendingByteBudget::new(DEFAULT_MAX_PENDING_WRITER_BYTES),
|
|
max_message_bytes: DEFAULT_MAX_WRITER_MESSAGE_BYTES,
|
|
},
|
|
StalledCdpSocketWriter {
|
|
output_rx,
|
|
close_rx,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
struct OwnedOuterHtmlResponse {
|
|
id: u64,
|
|
outer_html: String,
|
|
session_id: Option<String>,
|
|
}
|
|
|
|
fn serialize_owned_message_with_limit(
|
|
message: Value,
|
|
limit: usize,
|
|
) -> Result<String, BoundedJsonError> {
|
|
let response = match take_owned_outer_html_response(message) {
|
|
Ok(response) => response,
|
|
Err(message) => return to_string_with_limit(&message, limit),
|
|
};
|
|
|
|
let prefix = format!("{{\"id\":{},\"result\":{{\"outerHTML\":\"", response.id);
|
|
let suffix = if let Some(session_id) = response.session_id {
|
|
let encoded_session_id = to_string_with_limit(&session_id, limit)?;
|
|
let mut suffix = String::with_capacity(encoded_session_id.len() + 15);
|
|
suffix.push_str("\"},\"sessionId\":");
|
|
suffix.push_str(&encoded_session_id);
|
|
suffix.push('}');
|
|
suffix
|
|
} else {
|
|
"\"}}".to_owned()
|
|
};
|
|
json_string_between_with_limit(response.outer_html, &prefix, &suffix, limit)
|
|
}
|
|
|
|
fn take_owned_outer_html_response(message: Value) -> Result<OwnedOuterHtmlResponse, Value> {
|
|
let Value::Object(object) = &message else {
|
|
return Err(message);
|
|
};
|
|
let Some(id) = object.get("id").and_then(Value::as_u64) else {
|
|
return Err(message);
|
|
};
|
|
let session_id = match object.get("sessionId") {
|
|
Some(Value::String(session_id)) => Some(session_id),
|
|
Some(_) => return Err(message),
|
|
None => None,
|
|
};
|
|
if object.len() != 2 + usize::from(session_id.is_some()) {
|
|
return Err(message);
|
|
}
|
|
let Some(result) = object.get("result").and_then(Value::as_object) else {
|
|
return Err(message);
|
|
};
|
|
if result.len() != 1 || !matches!(result.get("outerHTML"), Some(Value::String(_))) {
|
|
return Err(message);
|
|
}
|
|
|
|
let Value::Object(mut object) = message else {
|
|
unreachable!("the message shape was validated above");
|
|
};
|
|
let session_id = object.remove("sessionId").map(|value| {
|
|
let Value::String(session_id) = value else {
|
|
unreachable!("the session id shape was validated above");
|
|
};
|
|
session_id
|
|
});
|
|
let Value::Object(mut result) = object
|
|
.remove("result")
|
|
.expect("the result field was validated above")
|
|
else {
|
|
unreachable!("the result shape was validated above");
|
|
};
|
|
let Value::String(outer_html) = result
|
|
.remove("outerHTML")
|
|
.expect("the outerHTML field was validated above")
|
|
else {
|
|
unreachable!("the outerHTML shape was validated above");
|
|
};
|
|
Ok(OwnedOuterHtmlResponse {
|
|
id,
|
|
outer_html,
|
|
session_id,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct StalledCdpSocketWriter {
|
|
output_rx: mpsc::Receiver<SocketWriterCommand>,
|
|
close_rx: tokio::sync::watch::Receiver<SocketCloseSignal>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl StalledCdpSocketWriter {
|
|
pub(crate) fn take_message(&mut self) -> serde_json::Value {
|
|
let command = self
|
|
.output_rx
|
|
.try_recv()
|
|
.expect("stalled CDP writer should have queued output");
|
|
let SocketWriterCommand::Output(envelope) = command else {
|
|
panic!("stalled CDP writer received close before output");
|
|
};
|
|
serde_json::from_str(&envelope.message).expect("queued CDP output JSON")
|
|
}
|
|
|
|
pub(crate) fn is_open(&self) -> bool {
|
|
*self.close_rx.borrow() == SocketCloseSignal::Open
|
|
}
|
|
}
|
|
|
|
async fn send_socket_text(socket: &mut SplitSink<WebSocket, Message>, text: String) -> bool {
|
|
socket.send(Message::Text(text.into())).await.is_ok()
|
|
}
|
|
|
|
fn notify_socket_writer_finished(finished_tx: &mut Option<oneshot::Sender<()>>) {
|
|
if let Some(finished_tx) = finished_tx.take() {
|
|
let _ = finished_tx.send(());
|
|
}
|
|
}
|