0.3.5-alpha.8
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# file: crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "game-realtime-transport-fallback-smoke"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||
game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }
|
||||
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
314
crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
Normal file
314
crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
// file: crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Executable localhost smoke for WebTransport-first realtime composition with classified WebSocket fallback.
|
||||
|
||||
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||
|
||||
const CLIENT_PAYLOAD: &[u8] = b"games.sasedev-fallback-client-smoke";
|
||||
const FORCED_FALLBACK_DETAIL: &str = "forced WebTransport unavailability for fallback smoke";
|
||||
const NON_FALLBACK_ENDPOINT: &str = "https://127.0.0.1:9/non-fallback";
|
||||
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-fallback-server-smoke";
|
||||
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const TRACING_TARGET: &str = "games::realtime::transport::fallback-smoke";
|
||||
const UNUSED_WEBSOCKET_ENDPOINT: &str = "https://fallback-must-not-run.invalid/";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum SelectedTransport {
|
||||
WebSocket,
|
||||
WebTransport,
|
||||
}
|
||||
|
||||
enum SelectedConnection<WebTransportConnection, WebSocketConnection> {
|
||||
WebSocket(WebSocketConnection),
|
||||
WebTransport(WebTransportConnection),
|
||||
}
|
||||
|
||||
impl<WebTransportConnection, WebSocketConnection> SelectedConnection<WebTransportConnection, WebSocketConnection> {
|
||||
fn transport(&self) -> SelectedTransport {
|
||||
return match self {
|
||||
Self::WebSocket(_) => SelectedTransport::WebSocket,
|
||||
Self::WebTransport(_) => SelectedTransport::WebTransport,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> std::process::ExitCode {
|
||||
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||
std::result::Result::Ok(guard) => guard,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("failed to initialize realtime fallback smoke tracing: {error}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, "realtime transport fallback smoke started");
|
||||
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
|
||||
return match result {
|
||||
std::result::Result::Ok(std::result::Result::Ok(())) => {
|
||||
tracing::info!(target: TRACING_TARGET, "realtime transport fallback smoke passed");
|
||||
println!("game-realtime-transport-fallback-smoke: PASS");
|
||||
std::process::ExitCode::SUCCESS
|
||||
},
|
||||
std::result::Result::Ok(std::result::Result::Err(error)) => {
|
||||
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "realtime transport fallback smoke failed");
|
||||
eprintln!("game-realtime-transport-fallback-smoke: FAIL: {error}");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
std::result::Result::Err(_) => {
|
||||
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime transport fallback smoke timed out");
|
||||
eprintln!("game-realtime-transport-fallback-smoke: FAIL: smoke timed out");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async fn run_smoke() -> std::result::Result<(), String> {
|
||||
if let std::result::Result::Err(error) = run_webtransport_preferred_branch().await {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = run_websocket_fallback_branch().await {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = run_non_fallback_error_branch().await {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn run_webtransport_preferred_branch() -> std::result::Result<(), String> {
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred identity generation failed: {error}")),
|
||||
};
|
||||
let certificate_hash = identity.certificate_hash().clone();
|
||||
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
|
||||
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred listener bind failed: {error}")),
|
||||
};
|
||||
let endpoint = format!("https://{}/fallback-preferred", listener.local_addr());
|
||||
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred client configuration failed: {error}")),
|
||||
};
|
||||
let server = async {
|
||||
let session = match listener.accept().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return session.accept_primary_connection().await;
|
||||
};
|
||||
let client = select_preferred_transport(
|
||||
|| async {
|
||||
let session = match game_realtime_webtransport_lib::connect(&client_config).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return session.open_primary_connection().await;
|
||||
},
|
||||
|| async {
|
||||
return game_realtime_websocket_lib::connect(UNUSED_WEBSOCKET_ENDPOINT).await;
|
||||
},
|
||||
);
|
||||
let (server_result, client_result) = tokio::join!(server, client);
|
||||
let server_connection = match server_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred server establishment failed: {error}")),
|
||||
};
|
||||
let client_connection = match client_result {
|
||||
std::result::Result::Ok(SelectedConnection::WebTransport(value)) => value,
|
||||
std::result::Result::Ok(SelectedConnection::WebSocket(_)) => {
|
||||
return std::result::Result::Err(String::from("WebTransport preferred branch unexpectedly selected WebSocket"));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred selection failed: {error}")),
|
||||
};
|
||||
if let std::result::Result::Err(error) = exercise_round_trip(client_connection, server_connection).await {
|
||||
return std::result::Result::Err(format!("WebTransport preferred round-trip failed: {error}"));
|
||||
}
|
||||
tracing::info!(target: TRACING_TARGET, selected = "webtransport", "preferred transport branch passed");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn run_websocket_fallback_branch() -> std::result::Result<(), String> {
|
||||
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback listener bind failed: {error}")),
|
||||
};
|
||||
let endpoint = format!("ws://{}/fallback", listener.local_addr());
|
||||
let server = listener.accept();
|
||||
let client = select_preferred_transport(
|
||||
|| async {
|
||||
return std::result::Result::<game_realtime_webtransport_lib::WebTransportConnection, game_realtime_transport_lib::TransportError>::Err(
|
||||
game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Timeout, FORCED_FALLBACK_DETAIL),
|
||||
);
|
||||
},
|
||||
|| async {
|
||||
return game_realtime_websocket_lib::connect(endpoint.as_str()).await;
|
||||
},
|
||||
);
|
||||
let (server_result, client_result) = tokio::join!(server, client);
|
||||
let server_connection = match server_result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback server establishment failed: {error}")),
|
||||
};
|
||||
let client_connection = match client_result {
|
||||
std::result::Result::Ok(SelectedConnection::WebSocket(value)) => value,
|
||||
std::result::Result::Ok(SelectedConnection::WebTransport(_)) => {
|
||||
return std::result::Result::Err(String::from("forced fallback branch unexpectedly selected WebTransport"));
|
||||
},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback selection failed: {error}")),
|
||||
};
|
||||
if let std::result::Result::Err(error) = exercise_round_trip(client_connection, server_connection).await {
|
||||
return std::result::Result::Err(format!("WebSocket fallback round-trip failed: {error}"));
|
||||
}
|
||||
tracing::info!(target: TRACING_TARGET, selected = "websocket", "classified fallback branch passed");
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
async fn run_non_fallback_error_branch() -> std::result::Result<(), String> {
|
||||
let certificate_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256([0_u8; 32]);
|
||||
let transport = game_realtime_webtransport_lib::WebTransportConfig::default().with_connect_timeout(std::time::Duration::ZERO);
|
||||
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(NON_FALLBACK_ENDPOINT, certificate_hash) {
|
||||
std::result::Result::Ok(value) => value.with_transport_config(transport),
|
||||
std::result::Result::Err(error) => {
|
||||
return std::result::Result::Err(format!("non-fallback WebTransport config construction failed unexpectedly: {error}"));
|
||||
},
|
||||
};
|
||||
let result = select_preferred_transport(
|
||||
|| async {
|
||||
let session = match game_realtime_webtransport_lib::connect(&client_config).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return session.open_primary_connection().await;
|
||||
},
|
||||
|| async {
|
||||
return game_realtime_websocket_lib::connect("ws://127.0.0.1:1/non-fallback-must-not-run").await;
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return match result {
|
||||
std::result::Result::Err(error)
|
||||
if error.kind() == game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration
|
||||
&& error.detail() == "connect_timeout must be greater than zero" =>
|
||||
{
|
||||
tracing::info!(target: TRACING_TARGET, kind = %error.kind(), "non-fallback error remained visible");
|
||||
std::result::Result::Ok(())
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(format!("non-fallback branch returned the wrong visible error: {error}")),
|
||||
std::result::Result::Ok(selected) => std::result::Result::Err(format!("non-fallback branch unexpectedly selected {:?}", selected.transport())),
|
||||
};
|
||||
}
|
||||
|
||||
async fn select_preferred_transport<WebTransportConnection, WebSocketConnection, WebTransportAttempt, WebTransportFuture, WebSocketAttempt, WebSocketFuture>(
|
||||
webtransport_attempt: WebTransportAttempt,
|
||||
websocket_attempt: WebSocketAttempt,
|
||||
) -> std::result::Result<SelectedConnection<WebTransportConnection, WebSocketConnection>, game_realtime_transport_lib::TransportError>
|
||||
where
|
||||
WebTransportAttempt: std::ops::FnOnce() -> WebTransportFuture,
|
||||
WebTransportFuture: core::future::Future<Output = std::result::Result<WebTransportConnection, game_realtime_transport_lib::TransportError>>,
|
||||
WebSocketAttempt: std::ops::FnOnce() -> WebSocketFuture,
|
||||
WebSocketFuture: core::future::Future<Output = std::result::Result<WebSocketConnection, game_realtime_transport_lib::TransportError>>,
|
||||
{
|
||||
let primary_result = webtransport_attempt().await;
|
||||
return match primary_result {
|
||||
std::result::Result::Ok(connection) => std::result::Result::Ok(SelectedConnection::WebTransport(connection)),
|
||||
std::result::Result::Err(primary_error) if is_fallback_eligible(primary_error.kind()) => {
|
||||
tracing::warn!(
|
||||
target: TRACING_TARGET,
|
||||
primary_kind = %primary_error.kind(),
|
||||
primary_detail = primary_error.detail(),
|
||||
"WebTransport unavailable; attempting classified WebSocket fallback"
|
||||
);
|
||||
return match websocket_attempt().await {
|
||||
std::result::Result::Ok(connection) => std::result::Result::Ok(SelectedConnection::WebSocket(connection)),
|
||||
std::result::Result::Err(fallback_error) => std::result::Result::Err(game_realtime_transport_lib::TransportError::new(
|
||||
fallback_error.kind(),
|
||||
format!("WebTransport attempt failed ({primary_error}); WebSocket fallback failed ({fallback_error})"),
|
||||
)),
|
||||
};
|
||||
},
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn is_fallback_eligible(kind: game_realtime_transport_lib::TransportErrorKind) -> bool {
|
||||
return kind == game_realtime_transport_lib::TransportErrorKind::Timeout || kind == game_realtime_transport_lib::TransportErrorKind::Io;
|
||||
}
|
||||
|
||||
async fn exercise_round_trip<ClientConnection, ServerConnection>(
|
||||
client_connection: ClientConnection,
|
||||
server_connection: ServerConnection,
|
||||
) -> std::result::Result<(), String>
|
||||
where
|
||||
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||
ClientConnection::Receiver: game_realtime_transport_lib::RealtimeReceiver,
|
||||
ClientConnection::Sender: game_realtime_transport_lib::RealtimeSender,
|
||||
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
|
||||
ServerConnection::Receiver: game_realtime_transport_lib::RealtimeReceiver,
|
||||
ServerConnection::Sender: game_realtime_transport_lib::RealtimeSender,
|
||||
{
|
||||
let (mut client_sender, mut client_receiver) = client_connection.split();
|
||||
let (mut server_sender, mut server_receiver) = server_connection.split();
|
||||
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec())).await {
|
||||
return std::result::Result::Err(format!("client send failed: {error}"));
|
||||
}
|
||||
let server_received = match server_receiver.receive().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("server receive failed: {error}")),
|
||||
};
|
||||
if !receive_matches(server_received, CLIENT_PAYLOAD) {
|
||||
return std::result::Result::Err(String::from("server did not receive the expected client payload"));
|
||||
}
|
||||
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec())).await {
|
||||
return std::result::Result::Err(format!("server send failed: {error}"));
|
||||
}
|
||||
let client_received = match client_receiver.receive().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("client receive failed: {error}")),
|
||||
};
|
||||
if !receive_matches(client_received, SERVER_PAYLOAD) {
|
||||
return std::result::Result::Err(String::from("client did not receive the expected server payload"));
|
||||
}
|
||||
if let std::result::Result::Err(error) = client_sender.close().await {
|
||||
return std::result::Result::Err(format!("client close failed: {error}"));
|
||||
}
|
||||
let server_close = match server_receiver.receive().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("server close observation failed: {error}")),
|
||||
};
|
||||
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||
return std::result::Result::Err(String::from("server did not observe the client close"));
|
||||
}
|
||||
if let std::result::Result::Err(error) = server_sender.close().await {
|
||||
return std::result::Result::Err(format!("server close failed: {error}"));
|
||||
}
|
||||
let client_close = match client_receiver.receive().await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(format!("client close observation failed: {error}")),
|
||||
};
|
||||
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||
return std::result::Result::Err(String::from("client did not observe the server close"));
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||
return match receive {
|
||||
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/fallback.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,66 @@
|
||||
// file: crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn fallback_policy_is_narrow_and_excludes_ambiguous_connect_failures() {
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Connect));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Bind));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Accept));
|
||||
assert!(super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Timeout));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::MessageTooLarge));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Backpressure));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Closed));
|
||||
assert!(super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Io));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Protocol));
|
||||
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Aborted));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn selector_prefers_webtransport_and_falls_back_only_for_classified_errors() {
|
||||
let preferred = super::select_preferred_transport(
|
||||
|| async {
|
||||
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(7);
|
||||
},
|
||||
|| async {
|
||||
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(test_error("fallback must not run"));
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(preferred, std::result::Result::Ok(super::SelectedConnection::WebTransport(7))), "WebTransport success must remain preferred");
|
||||
let fallback = super::select_preferred_transport(
|
||||
|| async {
|
||||
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(game_realtime_transport_lib::TransportError::new(
|
||||
game_realtime_transport_lib::TransportErrorKind::Timeout,
|
||||
"forced timeout",
|
||||
));
|
||||
},
|
||||
|| async {
|
||||
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(9);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(fallback, std::result::Result::Ok(super::SelectedConnection::WebSocket(9))), "classified timeout must select WebSocket fallback");
|
||||
let non_fallback = super::select_preferred_transport(
|
||||
|| async {
|
||||
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(test_error("visible primary error"));
|
||||
},
|
||||
|| async {
|
||||
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(11);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
&non_fallback,
|
||||
std::result::Result::Err(error)
|
||||
if error.kind() == game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration
|
||||
&& error.detail() == "visible primary error"
|
||||
),
|
||||
"non-fallback errors must remain visible"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_error(detail: &str) -> game_realtime_transport_lib::TransportError {
|
||||
return game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
||||
}
|
||||
Reference in New Issue
Block a user