[codex] Fix relay WebSocket admission limits (#1682)

This commit is contained in:
Jordan Mecom
2026-07-17 16:23:45 +00:00
committed by GitHub
parent fb0a4d5e74
commit d3ce971fc7
4 changed files with 209 additions and 4 deletions
Generated
+1
View File
@@ -1165,6 +1165,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.29.0",
"tokio-util",
"tower",
"tower-http",
+1
View File
@@ -88,3 +88,4 @@ mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag
buzz-core = { workspace = true, features = ["test-utils"] }
buzz-auth = { workspace = true, features = ["dev"] }
reqwest = { workspace = true }
tokio-tungstenite = { workspace = true }
+131 -2
View File
@@ -25,7 +25,7 @@ use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use nostr::{EventBuilder, Kind, Tag};
use serde::Deserialize;
use tokio::sync::mpsc;
use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
@@ -46,6 +46,11 @@ const MAX_AUDIO_FRAME_BYTES: usize = 4096;
/// Maximum text frame size: 8 KB bounds auth/control JSON parsing.
const MAX_TEXT_FRAME_BYTES: usize = 8192;
/// Parser-level cap for this route. Text auth/control frames are the largest
/// message type audio accepts; binary Opus frames are bounded more tightly
/// after parsing.
const MAX_WEBSOCKET_MESSAGE_BYTES: usize = MAX_TEXT_FRAME_BYTES;
/// Heartbeat interval.
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
@@ -81,7 +86,36 @@ pub async fn ws_audio_handler(
.into_response();
}
};
ws.on_upgrade(move |socket| handle_audio_connection(socket, state, tenant, channel_id))
let permit = match acquire_audio_connection_permit(&state.conn_semaphore) {
Some(permit) => permit,
None => {
warn!(channel_id = %channel_id, "Connection limit reached, rejecting audio WebSocket");
return (
StatusCode::SERVICE_UNAVAILABLE,
"relay: connection limit reached",
)
.into_response();
}
};
// Keep the parser boundary at the largest message this route accepts. The
// checks in the receive loop still distinguish text from binary policy, but
// they run after tungstenite has assembled a message.
limit_audio_websocket(ws).on_upgrade(move |socket| {
handle_audio_connection(socket, state, tenant, channel_id, permit)
})
}
fn acquire_audio_connection_permit(
conn_semaphore: &Arc<Semaphore>,
) -> Option<OwnedSemaphorePermit> {
Arc::clone(conn_semaphore).try_acquire_owned().ok()
}
fn limit_audio_websocket<F>(ws: WebSocketUpgrade<F>) -> WebSocketUpgrade<F> {
ws.max_message_size(MAX_WEBSOCKET_MESSAGE_BYTES)
.max_frame_size(MAX_WEBSOCKET_MESSAGE_BYTES)
}
/// Highest huddle audio protocol version this relay understands. Clients are
@@ -112,6 +146,7 @@ async fn handle_audio_connection(
state: Arc<AppState>,
tenant: TenantContext,
channel_id: Uuid,
_permit: OwnedSemaphorePermit,
) {
let cancel = CancellationToken::new();
let community_id = tenant.community();
@@ -1299,3 +1334,97 @@ async fn emit_participant_event(
);
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use axum::{routing::get, Router};
use futures_util::SinkExt;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use super::*;
#[test]
fn audio_connection_permits_share_the_global_websocket_budget() {
let semaphore = Arc::new(Semaphore::new(1));
let first = acquire_audio_connection_permit(&semaphore).expect("first permit");
assert!(
acquire_audio_connection_permit(&semaphore).is_none(),
"audio connections must stop when the global WebSocket budget is exhausted"
);
drop(first);
assert!(
acquire_audio_connection_permit(&semaphore).is_some(),
"dropping an audio connection must return its global permit"
);
}
async fn handler_receives_message_of_size(size: usize) -> bool {
let (received_tx, received_rx) = oneshot::channel();
let received_tx = Arc::new(Mutex::new(Some(received_tx)));
let app = Router::new().route(
"/",
get({
let received_tx = Arc::clone(&received_tx);
move |ws: WebSocketUpgrade| {
let received_tx = Arc::clone(&received_tx);
async move {
limit_audio_websocket(ws).on_upgrade(move |mut socket| async move {
let received = matches!(socket.recv().await, Some(Ok(_)));
if let Some(tx) =
received_tx.lock().expect("result lock poisoned").take()
{
let _ = tx.send(received);
}
})
}
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test WebSocket listener");
let addr = listener.local_addr().expect("test listener address");
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test WebSocket server");
});
let (mut client, _) = connect_async(format!("ws://{addr}/"))
.await
.expect("connect test WebSocket client");
client
.send(Message::Text("x".repeat(size).into()))
.await
.expect("send test WebSocket message");
let received = tokio::time::timeout(Duration::from_secs(2), received_rx)
.await
.expect("server should process the test message")
.expect("server should report whether it received the message");
server.abort();
let _ = server.await;
received
}
#[tokio::test]
async fn audio_websocket_parser_rejects_oversized_messages_before_handler_reads_them() {
assert!(
handler_receives_message_of_size(MAX_WEBSOCKET_MESSAGE_BYTES).await,
"messages at the audio route limit should still reach the handler"
);
assert!(
!handler_receives_message_of_size(MAX_WEBSOCKET_MESSAGE_BYTES + 1).await,
"oversized messages must be rejected by the WebSocket parser before the handler sees them"
);
}
}
+76 -2
View File
@@ -240,8 +240,9 @@ async fn nip11_or_ws_handler(
}
};
let max_frame_bytes = state.config.max_frame_bytes;
match WebSocketUpgrade::from_request(req, &state).await {
Ok(ws) => ws
Ok(ws) => limit_relay_websocket(ws, max_frame_bytes)
.on_upgrade(move |socket| handle_connection(socket, state, addr, tenant))
.into_response(),
Err(_) => {
@@ -262,6 +263,16 @@ async fn nip11_or_ws_handler(
}
}
fn limit_relay_websocket<F>(
ws: WebSocketUpgrade<F>,
max_frame_bytes: usize,
) -> WebSocketUpgrade<F> {
// recv_loop keeps the application-level check as defense in depth, but
// parser limits must be set before tungstenite assembles the message.
ws.max_message_size(max_frame_bytes)
.max_frame_size(max_frame_bytes)
}
async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, "ok")
}
@@ -354,7 +365,13 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer {
#[cfg(test)]
mod tests {
use super::{is_git_web_gui_path, is_invite_landing_path, should_serve_spa};
use axum::{routing::get, Router};
use futures_util::SinkExt;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use super::*;
#[test]
fn invite_landing_path_requires_exactly_one_nonempty_code_segment() {
@@ -385,4 +402,61 @@ mod tests {
assert!(should_serve_spa("/repos/example", true));
assert!(!should_serve_spa("/arbitrary", true));
}
async fn handler_receives_message_with_limit(limit: usize, size: usize) -> bool {
let (received_tx, mut received_rx) = mpsc::unbounded_channel();
let app = Router::new().route(
"/",
get(move |ws: WebSocketUpgrade| {
let received_tx = received_tx.clone();
async move {
limit_relay_websocket(ws, limit).on_upgrade(move |mut socket| async move {
let _ = received_tx.send(matches!(socket.recv().await, Some(Ok(_))));
})
}
}),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test WebSocket listener");
let addr = listener.local_addr().expect("test listener address");
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test WebSocket server");
});
let (mut client, _) = connect_async(format!("ws://{addr}/"))
.await
.expect("connect test WebSocket client");
client
.send(Message::Text("x".repeat(size).into()))
.await
.expect("send test WebSocket message");
let received = tokio::time::timeout(std::time::Duration::from_secs(2), received_rx.recv())
.await
.expect("server should process the test message")
.expect("server should report whether it received the message");
server.abort();
let _ = server.await;
received
}
#[tokio::test]
async fn relay_websocket_parser_rejects_oversized_messages_before_handler_reads_them() {
let limit = 64;
assert!(
handler_receives_message_with_limit(limit, limit).await,
"messages at the relay limit should still reach the handler"
);
assert!(
!handler_receives_message_with_limit(limit, limit + 1).await,
"oversized messages must be rejected by the WebSocket parser before the handler sees them"
);
}
}