v0.2.7-pre.008
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
@@ -61,6 +61,27 @@ fn reconnect_session_settings(max_retries: u32, backoff: std::time::Duration, re
|
||||
);
|
||||
}
|
||||
|
||||
fn subscription_session_settings(
|
||||
notification_queue_capacity: usize,
|
||||
max_active_subscriptions: usize,
|
||||
reconnect: crate::WsReconnectSettings,
|
||||
) -> crate::WsSessionSettings {
|
||||
let defaults = crate::WsSessionSettings::default();
|
||||
return crate::WsSessionSettings::new(
|
||||
std::time::Duration::from_millis(250),
|
||||
std::time::Duration::from_millis(200),
|
||||
reconnect,
|
||||
crate::WsResubscribePolicy::ActiveSubscriptions,
|
||||
defaults.command_queue_capacity(),
|
||||
notification_queue_capacity,
|
||||
max_active_subscriptions,
|
||||
defaults.max_pending_requests(),
|
||||
defaults.max_message_size_bytes(),
|
||||
defaults.max_frame_size_bytes(),
|
||||
defaults.max_write_buffer_size_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||
let address = listener.local_addr().expect("local listener must expose address");
|
||||
@@ -112,6 +133,39 @@ async fn wait_for_gap_count(session: &crate::WsSession, expected: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_overflow_count(session: &crate::WsSession, expected: u64) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
if session.snapshot().overflow_count() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "session did not reach overflow count {expected}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_subscription_count(session: &crate::WsSession, expected: usize) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
if session.snapshot().subscription_count() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "session did not reach subscription count {expected}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_close_frame(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) {
|
||||
loop {
|
||||
let message = websocket.next().await;
|
||||
match message {
|
||||
std::option::Option::Some(std::result::Result::Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => return,
|
||||
std::option::Option::Some(std::result::Result::Ok(_)) => {},
|
||||
std::option::Option::Some(std::result::Result::Err(_)) | std::option::Option::None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_session_connects_and_round_trips_internal_json_rpc() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
@@ -623,6 +677,7 @@ async fn websocket_resubscribe_never_keeps_session_but_fails_logical_subscriptio
|
||||
.expect("root subscribe must succeed");
|
||||
wait_for_gap_count(&session, 1).await;
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Failed);
|
||||
assert_eq!(subscription.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_CONNECTION_FAILED));
|
||||
assert_eq!(session.snapshot().subscription_count(), 0);
|
||||
let result = session.execute_json_rpc("afterNeverPolicy", std::vec::Vec::new()).await.expect("physical session must reconnect without resubscribe");
|
||||
assert_eq!(result, serde_json::json!(true));
|
||||
@@ -719,6 +774,7 @@ async fn websocket_resubscribe_application_error_fails_only_one_subscription() {
|
||||
.expect("logs notification must decode");
|
||||
assert_eq!(notification, serde_json::json!({"restored": true}));
|
||||
assert_eq!(account.state(), crate::WsSubscriptionState::Failed);
|
||||
assert_eq!(account.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_RPC_APPLICATION_ERROR));
|
||||
assert_eq!(logs.state(), crate::WsSubscriptionState::Active);
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
assert_eq!(session.snapshot().continuity_gap_count(), 1);
|
||||
@@ -867,6 +923,9 @@ async fn websocket_notification_method_mismatch_fails_only_the_logical_subscript
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(5)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 5, serde_json::json!(1)).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotUnsubscribe"));
|
||||
assert_eq!(cleanup.get("params"), std::option::Option::Some(&serde_json::json!([5])));
|
||||
let request = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &request, serde_json::json!(true)).await;
|
||||
});
|
||||
@@ -878,6 +937,7 @@ async fn websocket_notification_method_mismatch_fails_only_the_logical_subscript
|
||||
.await
|
||||
.expect("slot subscribe must succeed");
|
||||
wait_for_subscription_state(&subscription, crate::WsSubscriptionState::Failed).await;
|
||||
assert_eq!(subscription.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_PROTOCOL_ERROR));
|
||||
assert!(subscription.recv().await.is_none());
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let result = session.execute_json_rpc("afterMismatch", std::vec::Vec::new()).await.expect("physical session must remain usable");
|
||||
@@ -894,6 +954,9 @@ async fn websocket_typed_notification_decode_failure_fails_only_one_subscription
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(8)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 8, serde_json::json!("not-a-slot")).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootUnsubscribe"));
|
||||
assert_eq!(cleanup.get("params"), std::option::Option::Some(&serde_json::json!([8])));
|
||||
let request = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &request, serde_json::json!(true)).await;
|
||||
});
|
||||
@@ -912,8 +975,162 @@ async fn websocket_typed_notification_decode_failure_fails_only_one_subscription
|
||||
let error = subscription.recv().await.expect("decode error must be delivered").expect_err("fixture payload must fail typed decoder");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
||||
wait_for_subscription_state(&subscription, crate::WsSubscriptionState::Failed).await;
|
||||
assert_eq!(subscription.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_INVALID_RESPONSE));
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let result = session.execute_json_rpc("afterDecodeFailure", std::vec::Vec::new()).await.expect("physical session must remain usable");
|
||||
assert_eq!(result, serde_json::json!(true));
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_notification_queue_overflow_fails_only_slow_subscription_and_cleans_remote_binding() {
|
||||
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 slow_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(slow_subscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotSubscribe"));
|
||||
send_result(&mut websocket, &slow_subscribe, serde_json::json!(41)).await;
|
||||
let healthy_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(healthy_subscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootSubscribe"));
|
||||
send_result(&mut websocket, &healthy_subscribe, serde_json::json!(42)).await;
|
||||
send_notification(&mut websocket, "slotNotification", 41, serde_json::json!(1)).await;
|
||||
send_notification(&mut websocket, "slotNotification", 41, serde_json::json!(2)).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotUnsubscribe"));
|
||||
assert_eq!(cleanup.get("params"), std::option::Option::Some(&serde_json::json!([41])));
|
||||
send_notification(&mut websocket, "rootNotification", 42, serde_json::json!(99)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let reconnect = crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10));
|
||||
let settings = subscription_session_settings(1, 2, reconnect);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let mut slow = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("slow subscription must register");
|
||||
let mut healthy = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("healthy subscription must register");
|
||||
wait_for_subscription_state(&slow, crate::WsSubscriptionState::Failed).await;
|
||||
wait_for_overflow_count(&session, 1).await;
|
||||
assert_eq!(slow.terminal_error_code(), std::option::Option::Some(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW));
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
assert_eq!(session.snapshot().subscription_count(), 1);
|
||||
let first_slow = slow.recv().await.expect("first queued notification must remain observable").expect("first queued notification must decode");
|
||||
assert_eq!(first_slow, serde_json::json!(1));
|
||||
assert!(slow.recv().await.is_none());
|
||||
let healthy_value = healthy.recv().await.expect("healthy notification must remain available").expect("healthy notification must decode");
|
||||
assert_eq!(healthy_value, serde_json::json!(99));
|
||||
assert_eq!(healthy.state(), crate::WsSubscriptionState::Active);
|
||||
assert_eq!(healthy.terminal_error_code(), std::option::Option::None);
|
||||
session.close().await.expect("session close must remain bounded");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_active_subscription_limit_rejects_excess_without_leaking_capacity() {
|
||||
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 first_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(first_subscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotSubscribe"));
|
||||
send_result(&mut websocket, &first_subscribe, serde_json::json!(11)).await;
|
||||
let first_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(first_unsubscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotUnsubscribe"));
|
||||
assert_eq!(first_unsubscribe.get("params"), std::option::Option::Some(&serde_json::json!([11])));
|
||||
send_result(&mut websocket, &first_unsubscribe, serde_json::json!(true)).await;
|
||||
let replacement_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(replacement_subscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootSubscribe"));
|
||||
send_result(&mut websocket, &replacement_subscribe, serde_json::json!(12)).await;
|
||||
let replacement_unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(replacement_unsubscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootUnsubscribe"));
|
||||
assert_eq!(replacement_unsubscribe.get("params"), std::option::Option::Some(&serde_json::json!([12])));
|
||||
send_result(&mut websocket, &replacement_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let reconnect = crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10));
|
||||
let settings = subscription_session_settings(1, 1, reconnect);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let mut first = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("first subscription must register");
|
||||
let excess = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect_err("subscription above configured active limit must be rejected");
|
||||
assert_eq!(excess.code(), crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW);
|
||||
assert_eq!(session.snapshot().overflow_count(), 0);
|
||||
assert_eq!(session.snapshot().subscription_count(), 1);
|
||||
assert!(first.unsubscribe().await.expect("first unsubscribe must succeed"));
|
||||
assert_eq!(first.state(), crate::WsSubscriptionState::Closed);
|
||||
assert_eq!(first.terminal_error_code(), std::option::Option::None);
|
||||
wait_for_subscription_count(&session, 0).await;
|
||||
let mut replacement = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("capacity must become reusable after terminal cleanup");
|
||||
assert_eq!(replacement.id().get(), 2);
|
||||
assert!(replacement.unsubscribe().await.expect("replacement unsubscribe must succeed"));
|
||||
wait_for_subscription_count(&session, 0).await;
|
||||
session.close().await.expect("session close must remain bounded");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_dropped_notification_receiver_triggers_remote_cleanup_and_releases_capacity() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let (trigger_tx, trigger_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_subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &first_subscribe, serde_json::json!(71)).await;
|
||||
trigger_rx.await.expect("client must signal receiver drop");
|
||||
send_notification(&mut websocket, "slotNotification", 71, serde_json::json!(1)).await;
|
||||
let cleanup = read_request(&mut websocket).await;
|
||||
assert_eq!(cleanup.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotUnsubscribe"));
|
||||
assert_eq!(cleanup.get("params"), std::option::Option::Some(&serde_json::json!([71])));
|
||||
let replacement_subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(replacement_subscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("rootSubscribe"));
|
||||
send_result(&mut websocket, &replacement_subscribe, serde_json::json!(72)).await;
|
||||
let replacement_unsubscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &replacement_unsubscribe, serde_json::json!(true)).await;
|
||||
wait_for_close_frame(&mut websocket).await;
|
||||
});
|
||||
let reconnect = crate::WsReconnectSettings::new(0, std::time::Duration::from_millis(10), std::time::Duration::from_millis(10));
|
||||
let settings = subscription_session_settings(1, 1, reconnect);
|
||||
let session = crate::WsSession::connect(local_endpoint_with_session(url.as_str(), settings)).await.expect("client handshake must succeed");
|
||||
let subscription = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("first subscription must register");
|
||||
drop(subscription);
|
||||
trigger_tx.send(()).expect("receiver-drop trigger must send");
|
||||
wait_for_subscription_count(&session, 0).await;
|
||||
assert_eq!(session.snapshot().overflow_count(), 0);
|
||||
let mut replacement = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("capacity must be reusable after dropped receiver cleanup");
|
||||
assert!(replacement.unsubscribe().await.expect("replacement unsubscribe must succeed"));
|
||||
session.close().await.expect("session close must remain bounded");
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user