v0.2.7-pre.005
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
return local_endpoint_with_session(url, crate::WsSessionSettings::default());
|
||||
}
|
||||
|
||||
fn local_endpoint_with_session(url: &str, session: crate::WsSessionSettings) -> crate::WsEndpointSettings {
|
||||
return crate::WsEndpointSettings::new(
|
||||
"local_ws",
|
||||
true,
|
||||
@@ -12,7 +16,31 @@ fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||
crate::WsClusterName::new("local"),
|
||||
crate::WsProtocolKind::SolanaStandard,
|
||||
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||
crate::WsSessionSettings::default(),
|
||||
session,
|
||||
);
|
||||
}
|
||||
|
||||
fn session_settings(
|
||||
command_timeout: std::time::Duration,
|
||||
close_timeout: std::time::Duration,
|
||||
max_pending_requests: usize,
|
||||
max_message_size_bytes: usize,
|
||||
max_frame_size_bytes: usize,
|
||||
max_write_buffer_size_bytes: usize,
|
||||
) -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
command_timeout,
|
||||
close_timeout,
|
||||
defaults.reconnect().clone(),
|
||||
defaults.resubscribe(),
|
||||
defaults.command_queue_capacity(),
|
||||
defaults.notification_queue_capacity(),
|
||||
defaults.max_active_subscriptions(),
|
||||
max_pending_requests,
|
||||
max_message_size_bytes,
|
||||
max_frame_size_bytes,
|
||||
max_write_buffer_size_bytes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +62,28 @@ async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::n
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||
}
|
||||
|
||||
async fn wait_for_state(session: &crate::WsSession, expected: crate::WsSessionState) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
if session.state() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "session did not reach expected state: {expected:?}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_pending_count(session: &crate::WsSession, expected: usize) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
if session.snapshot().pending_request_count() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "session did not reach pending count {expected}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_session_connects_and_round_trips_internal_json_rpc() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
@@ -149,3 +199,184 @@ async fn websocket_connection_errors_do_not_echo_url_credentials() {
|
||||
assert!(!rendered.contains("password"));
|
||||
assert!(!rendered.contains("private?"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_pending_request_capacity_rejects_only_the_excess_request() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let first = read_request(&mut websocket).await;
|
||||
let _ = first_seen_tx.send(());
|
||||
let _ = release_rx.await;
|
||||
send_result(&mut websocket, &first, serde_json::json!("first")).await;
|
||||
});
|
||||
let settings = session_settings(std::time::Duration::from_secs(1), std::time::Duration::from_millis(200), 1, 64 * 1024, 64 * 1024, 64 * 1024);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let first_session = session.clone();
|
||||
let first = tokio::spawn(async move {
|
||||
return first_session.execute_json_rpc("firstMethod", std::vec::Vec::new()).await;
|
||||
});
|
||||
first_seen_rx.await.expect("server must observe first request");
|
||||
wait_for_pending_count(&session, 1).await;
|
||||
let second_error = session.execute_json_rpc("secondMethod", std::vec::Vec::new()).await.expect_err("second pending request must be rejected");
|
||||
assert_eq!(second_error.code(), crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW);
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
release_tx.send(()).expect("first request release signal must send");
|
||||
assert_eq!(first.await.expect("first request task must join").expect("first request must complete"), serde_json::json!("first"));
|
||||
wait_for_pending_count(&session, 0).await;
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_oversized_outbound_request_is_rejected_before_socket_write() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let request = read_request(&mut websocket).await;
|
||||
assert_eq!(request.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("smallMethod"));
|
||||
send_result(&mut websocket, &request, serde_json::json!(true)).await;
|
||||
});
|
||||
let settings = session_settings(std::time::Duration::from_secs(1), std::time::Duration::from_millis(200), 8, 256, 128, 1024);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let oversized = serde_json::json!({"value":"X".repeat(512)});
|
||||
let error = session.execute_json_rpc("oversizedMethod", std::vec![oversized]).await.expect_err("oversized outbound request must be rejected");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let result = session.execute_json_rpc("smallMethod", std::vec::Vec::new()).await.expect("small request must still succeed");
|
||||
assert_eq!(result, serde_json::json!(true));
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_oversized_inbound_frame_fails_before_json_decode() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text("X".repeat(512).into())).await.expect("oversized fixture message must send");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
});
|
||||
let settings = session_settings(std::time::Duration::from_secs(1), std::time::Duration::from_millis(200), 8, 128, 64, 1024);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
wait_for_state(&session, crate::WsSessionState::Failed).await;
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_pending_request_timeout_purges_capacity_without_failing_session() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let _ = read_request(&mut websocket).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
});
|
||||
let settings = session_settings(std::time::Duration::from_millis(100), std::time::Duration::from_millis(100), 1, 64 * 1024, 64 * 1024, 64 * 1024);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let wait = tokio::time::timeout(std::time::Duration::from_secs(1), session.execute_json_rpc("timeoutMethod", std::vec::Vec::new())).await;
|
||||
let error = wait.expect("request timeout fixture must remain bounded").expect_err("remote silence must time out the request");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_TIMEOUT);
|
||||
wait_for_pending_count(&session, 0).await;
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_ping_flushes_automatic_pong_and_keeps_session_active() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let (pong_tx, pong_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Ping(b"ping-canary".to_vec().into())).await.expect("fixture ping must send");
|
||||
let pong = websocket.next().await.expect("pong frame must arrive").expect("pong frame must decode");
|
||||
match pong {
|
||||
tokio_tungstenite::tungstenite::Message::Pong(payload) => assert_eq!(payload.as_ref(), b"ping-canary"),
|
||||
other => panic!("expected Pong frame, got {other:?}"),
|
||||
}
|
||||
let _ = pong_tx.send(());
|
||||
let request = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &request, serde_json::json!(true)).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), pong_rx)
|
||||
.await
|
||||
.expect("automatic Pong must remain bounded")
|
||||
.expect("fixture server must observe Pong");
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let result = session.execute_json_rpc("afterPing", std::vec::Vec::new()).await.expect("session must remain usable after Ping/Pong");
|
||||
assert_eq!(result, serde_json::json!(true));
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_remote_close_transitions_to_closed_instead_of_failed() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Close(std::option::Option::None)).await.expect("fixture Close must send");
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_millis(200), websocket.next()).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
wait_for_state(&session, crate::WsSessionState::Closed).await;
|
||||
assert!(session.close().await.is_ok());
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_explicit_close_cancels_pending_and_finishes_with_hostile_peer() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let (request_seen_tx, request_seen_rx) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let _ = read_request(&mut websocket).await;
|
||||
let _ = request_seen_tx.send(());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
});
|
||||
let settings = session_settings(std::time::Duration::from_secs(1), std::time::Duration::from_millis(150), 8, 64 * 1024, 64 * 1024, 64 * 1024);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let request_session = session.clone();
|
||||
let pending = tokio::spawn(async move {
|
||||
return request_session.execute_json_rpc("neverRespond", std::vec::Vec::new()).await;
|
||||
});
|
||||
request_seen_rx.await.expect("hostile server must observe request");
|
||||
wait_for_pending_count(&session, 1).await;
|
||||
let close_result = tokio::time::timeout(std::time::Duration::from_millis(300), session.close()).await;
|
||||
close_result.expect("explicit close must remain bounded").expect("explicit close must finish cleanly");
|
||||
assert_eq!(session.state(), crate::WsSessionState::Closed);
|
||||
assert_eq!(session.snapshot().pending_request_count(), 0);
|
||||
let pending_error = pending.await.expect("pending request task must join").expect_err("shutdown must cancel pending request");
|
||||
assert_eq!(pending_error.code(), crate::ERROR_CODE_WS_SESSION_CLOSED);
|
||||
server.await.expect("hostile server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_repeated_connect_close_cycles_are_bounded() {
|
||||
const CYCLES: usize = 8;
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..CYCLES {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let message = websocket.next().await.expect("client Close must arrive").expect("client Close must decode");
|
||||
assert!(matches!(message, tokio_tungstenite::tungstenite::Message::Close(_)));
|
||||
let _ = websocket.flush().await;
|
||||
}
|
||||
});
|
||||
let settings = session_settings(std::time::Duration::from_secs(1), std::time::Duration::from_millis(200), 8, 64 * 1024, 64 * 1024, 64 * 1024);
|
||||
for _ in 0..CYCLES {
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings.clone())).await.expect("client must connect");
|
||||
session.close().await.expect("client close must succeed");
|
||||
assert_eq!(session.state(), crate::WsSessionState::Closed);
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), server)
|
||||
.await
|
||||
.expect("repeated close server must remain bounded")
|
||||
.expect("repeated close server task must join");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user