From 7f139f631e0f9dca08dff97cfaa31a504f721327 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 22 Sep 2026 07:35:17 +0200 Subject: [PATCH] 0.3.5-alpha.8 --- Cargo.toml | 5 +- README.md | 8 +- .../Cargo.toml | 22 ++ .../src/main.rs | 314 ++++++++++++++++++ .../unit_tests/fallback.rs | 66 ++++ deltas/0.3.5/alpha.8.md | 212 ++++++++++++ .../005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md | 30 +- history/0.3.5/alpha.7.fix.2.md | 93 ++++++ scripts/audit_distribution_layout.py | 39 ++- 9 files changed, 771 insertions(+), 18 deletions(-) create mode 100644 crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml create mode 100644 crates/apps/game-realtime-transport-fallback-smoke/src/main.rs create mode 100644 crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs create mode 100644 deltas/0.3.5/alpha.8.md create mode 100644 history/0.3.5/alpha.7.fix.2.md diff --git a/Cargo.toml b/Cargo.toml index d8a8cb4..78c310c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 105 +# version: 106 [workspace] resolver = "3" @@ -9,6 +9,7 @@ members = [ "crates/engines/engine-v1-sdl", "crates/games/game-reflex-poc", "crates/games/game-snake-poc", + "crates/apps/game-realtime-transport-fallback-smoke", "crates/apps/game-realtime-websocket-smoke", "crates/apps/game-realtime-webtransport-browser-smoke", "crates/apps/game-realtime-webtransport-smoke", @@ -27,7 +28,7 @@ members = [ ] [workspace.package] -version = "0.3.5-alpha.7.fix.2" +version = "0.3.5-alpha.8" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/games" diff --git a/README.md b/README.md index 69cfaf6..5e4df04 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - + # games.sasedev @@ -27,9 +27,9 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale Version stable de référence : `0.3.4`. -Version active : `0.3.5-alpha.7.fix.2`. `0.3.2` reste différée. +Version active : `0.3.5-alpha.8`. `0.3.2` reste différée. -La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.7.fix.2` conserve le backend WebTransport natif et son smoke `PASS`, valide le client `wasm32-unknown-unknown`, applique aussi les deadlines côté navigateur et ajoute un smoke browser technique Vite/TypeScript + adapter Rust/WASM vers un serveur Rust local avec pin SHA-256 exact. Les deux fixes corrigent uniquement la résolution du build externe depuis le host `Web/` : chemin de l'artefact `wasm-bindgen`, puis chemin TypeScript vers les déclarations générées. Le fallback et les datagrams restent réservés aux tranches suivantes. +La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.8` conserve les chemins WebTransport natif et navigateur validés, puis ajoute une preuve de composition WebTransport-first avec fallback WebSocket strictement classifié. Le fallback n'est autorisé que pour `Timeout`/`Io`; les erreurs ambiguës ou sensibles, notamment `Connect`, `InvalidConfiguration` et `Protocol`, restent visibles et ne déclenchent aucun downgrade silencieux. Les datagrams et les mesures comparatives restent réservés aux tranches suivantes. Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme. @@ -45,4 +45,4 @@ Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-sna ## Diagnostics et tests -Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite, et `game-realtime-webtransport-lib`, backend WebTransport/QUIC candidat dont le chemin natif couvre TLS/pinning, établissement de session et stream fiable principal, tandis que son chemin client WASM compile contre l'API WebTransport du navigateur avec le même framing fiable et le même contrat commun. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` et `game-realtime-webtransport-smoke` fournissent les preuves runtime localhost hors harness des deux backends fiables natifs ; `game-realtime-webtransport-browser-smoke` et son host sous `Web/` portent la preuve navigateur WebTransport réelle sans couplage gameplay. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`. +Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite, et `game-realtime-webtransport-lib`, backend WebTransport/QUIC candidat dont le chemin natif couvre TLS/pinning, établissement de session et stream fiable principal, tandis que son chemin client WASM compile contre l'API WebTransport du navigateur avec le même framing fiable et le même contrat commun. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` et `game-realtime-webtransport-smoke` fournissent les preuves runtime localhost hors harness des deux backends fiables natifs ; `game-realtime-webtransport-browser-smoke` et son host sous `Web/` portent la preuve navigateur WebTransport réelle sans couplage gameplay ; `game-realtime-transport-fallback-smoke` prouve séparément la composition WebTransport-first et le fallback WebSocket classifié sans introduire de manager générique. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`. diff --git a/crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml b/crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml new file mode 100644 index 0000000..da16778 --- /dev/null +++ b/crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml @@ -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 diff --git a/crates/apps/game-realtime-transport-fallback-smoke/src/main.rs b/crates/apps/game-realtime-transport-fallback-smoke/src/main.rs new file mode 100644 index 0000000..858e2c1 --- /dev/null +++ b/crates/apps/game-realtime-transport-fallback-smoke/src/main.rs @@ -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 { + WebSocket(WebSocketConnection), + WebTransport(WebTransportConnection), +} + +impl SelectedConnection { + 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::::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( + webtransport_attempt: WebTransportAttempt, + websocket_attempt: WebSocketAttempt, +) -> std::result::Result, game_realtime_transport_lib::TransportError> +where + WebTransportAttempt: std::ops::FnOnce() -> WebTransportFuture, + WebTransportFuture: core::future::Future>, + WebSocketAttempt: std::ops::FnOnce() -> WebSocketFuture, + WebSocketFuture: core::future::Future>, +{ + 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( + 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; diff --git a/crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs b/crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs new file mode 100644 index 0000000..436825c --- /dev/null +++ b/crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs @@ -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::::Ok(7); + }, + || async { + return std::result::Result::::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::::Err(game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Timeout, + "forced timeout", + )); + }, + || async { + return std::result::Result::::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::::Err(test_error("visible primary error")); + }, + || async { + return std::result::Result::::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); +} diff --git a/deltas/0.3.5/alpha.8.md b/deltas/0.3.5/alpha.8.md new file mode 100644 index 0000000..2b9bdc8 --- /dev/null +++ b/deltas/0.3.5/alpha.8.md @@ -0,0 +1,212 @@ + + + +# Delta 0.3.5-alpha.8 + +## Base requise + +`0.3.5-alpha.7.fix.2`, validée par l'utilisateur le 2026-09-22. + +La gate finale confirme fmt, audits, `cargo check --workspace`, Clippy strict, build WASM de l'adapter browser, génération `wasm-bindgen`, build TypeScript/Vite et smoke WebTransport navigateur réel terminé par : + +```text +game-realtime-webtransport-browser-smoke: PASS +``` + +La preuve complète est enregistrée dans `history/0.3.5/alpha.7.fix.2.md`. + +## Objectif + +Prouver le fallback WebSocket au niveau composition sans modifier le contrat transport-neutral ni masquer les erreurs sensibles : + +- essayer WebTransport en premier ; +- conserver WebTransport lorsqu'il est disponible ; +- basculer vers WebSocket uniquement pour des erreurs explicitement classifiées ; +- forcer une branche fallback déterministe ; +- garder une erreur non-fallback visible ; +- ne créer ni manager générique, ni registry, ni couplage gameplay. + +## Version + +La version workspace passe à : + +```text +0.3.5-alpha.8 +``` + +## Launcher de composition + +Nouveau package technique : + +```text +crates/apps/game-realtime-transport-fallback-smoke +``` + +Il dépend directement des deux backends candidats et du contrat commun, mais d'aucun engine ni gameplay. + +Le sélecteur reste privé au launcher : + +```text +WebTransport attempt + -> success -> WebTransport + -> classified Timeout/Io -> WebSocket attempt + -> any other error -> visible error +``` + +Aucune API n'est ajoutée à `game-realtime-transport-lib`, `game-realtime-websocket-lib` ou `game-realtime-webtransport-lib`. + +## Classification conservative + +Le backend WebTransport natif mappe actuellement les erreurs amont d'établissement sous une catégorie `Connect` trop large pour distinguer de manière sûre une indisponibilité réseau d'un problème TLS/pinning. + +Pour éviter un downgrade silencieux, `alpha.8` autorise le fallback uniquement pour : + +```text +Timeout +Io +``` + +et refuse explicitement le fallback pour : + +```text +InvalidConfiguration +Connect +Bind +Accept +MessageTooLarge +Backpressure +Closed +Protocol +Aborted +``` + +Cette politique est volontairement plus restrictive qu'un fallback générique sur toute erreur de connexion. Une évolution future pourra élargir la matrice uniquement si le backend expose une classification suffisamment précise pour préserver les erreurs de sécurité/protocole. + +## Smoke runtime + +Le launcher exécute trois scénarios séquentiels sous une deadline globale de 10 s. + +### 1. WebTransport disponible + +- serveur WebTransport loopback réel sur port éphémère ; +- identité P-256 en mémoire et pin SHA-256 exact ; +- sélection WebTransport-first ; +- fallback WebSocket volontairement inutilisable si appelé ; +- round-trip binaire dans les deux sens ; +- fermeture propre dans les deux sens. + +### 2. Fallback WebSocket forcé + +- erreur WebTransport `Timeout` injectée explicitement au point de composition ; +- listener WebSocket loopback réel sur port éphémère ; +- sélection de la branche fallback ; +- round-trip binaire dans les deux sens ; +- fermeture propre dans les deux sens. + +L'injection ne simule pas le backend WebSocket : elle force uniquement la cause WebTransport classifiée afin que la branche de sélection soit déterministe et rapide. La connexion fallback et son échange restent réels. + +### 3. Erreur non-fallback visible + +- vraie `WebTransportClientConfig` avec `connect_timeout = 0` ; +- `game-realtime-webtransport-lib::connect()` retourne `InvalidConfiguration` avant I/O ; +- la branche WebSocket ne doit pas être utilisée ; +- l'erreur exacte `connect_timeout must be greater than zero` doit rester visible. + +Le verdict global attendu est : + +```text +game-realtime-transport-fallback-smoke: PASS +``` + +## Tests unitaires + +Le binary contient également des tests ciblés qui vérifient : + +- la matrice `TransportErrorKind` complète ; +- `Connect` explicitement non éligible ; +- succès WebTransport prioritaire ; +- `Timeout` déclenchant la valeur WebSocket ; +- `InvalidConfiguration` restant inchangée et visible. + +## Audit de distribution + +`scripts/audit_distribution_layout.py` ajoute les chemins du launcher et vérifie : + +- dépendance directe aux deux backends ; +- aucune dépendance engine/gameplay ; +- classification limitée à `Timeout`/`Io` ; +- exclusion explicite de `Connect` ; +- verdict `PASS` déterministe. + +## Fichiers modifiés + +```text +Cargo.toml +README.md +docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md +scripts/audit_distribution_layout.py +``` + +Nouveaux fichiers : + +```text +crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml +crates/apps/game-realtime-transport-fallback-smoke/src/main.rs +crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs +deltas/0.3.5/alpha.8.md +history/0.3.5/alpha.7.fix.2.md +``` + +## Validation attendue + +```bash +cargo fmt --all +cargo fmt --all -- --check + +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history +python3 scripts/audit_distribution_layout.py + +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings + +cargo test -p game-realtime-transport-lib --all-targets --all-features +cargo test -p game-realtime-websocket-lib --all-targets --all-features +cargo test -p game-realtime-webtransport-lib --all-targets --all-features +cargo test -p game-realtime-transport-fallback-smoke --all-targets --all-features + +cargo run -p game-realtime-websocket-smoke +cargo run -p game-realtime-webtransport-smoke +cargo run -p game-realtime-transport-fallback-smoke + +cargo tree -p game-realtime-transport-fallback-smoke --edges normal +cargo tree -i game-realtime-websocket-lib --workspace --edges normal +cargo tree -i game-realtime-webtransport-lib --workspace --edges normal +``` + +Le nouveau smoke doit terminer par : + +```text +game-realtime-transport-fallback-smoke: PASS +``` + +Les graphes inverses peuvent contenir les launchers techniques, mais aucun engine ni crate gameplay ne doit dépendre des backends concrets. + +## Contrôles statiques avant livraison + +Le candidat a été vérifié côté générateur sans attribuer de résultat Cargo non exécuté : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +games.sasedev workspace audit: clean +Markdown table audit: clean (5 table(s), 286 file(s)) +Distribution layout audit: clean (61 required path(s), 8 forbidden path(s) absent) +Cargo.toml parse: clean +``` + +La compilation, Clippy, les tests et les smokes restent à exécuter dans la gate utilisateur ci-dessus. + +## Après validation + +Créer `history/0.3.5/alpha.8.md` à partir des sorties réellement fournies, puis ouvrir `0.3.5-alpha.9` pour le POC datagram isolé si cette tranche reste justifiée par l'API/backend réellement disponibles. diff --git a/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md b/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md index 5033231..83db074 100644 --- a/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md +++ b/docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md @@ -1,11 +1,11 @@ - + # Plan 0.3.5 — POC WebTransport/QUIC et fallback WebSocket ## Statut -Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`, puis réconcilié pour l'implémentation native de `0.3.5-alpha.2`, son correctif de validation `0.3.5-alpha.2.fix.1`, le chemin fiable validé de `0.3.5-alpha.3`, la robustesse validée par `0.3.5-alpha.4.fix.1`, le smoke natif validé de `0.3.5-alpha.5`, le chemin client WASM validé de `0.3.5-alpha.6`, le smoke navigateur candidat de `0.3.5-alpha.7` et ses correctifs de build `0.3.5-alpha.7.fix.1` puis `0.3.5-alpha.7.fix.2`. +Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`, puis réconcilié pour l'implémentation native de `0.3.5-alpha.2`, son correctif de validation `0.3.5-alpha.2.fix.1`, le chemin fiable validé de `0.3.5-alpha.3`, la robustesse validée par `0.3.5-alpha.4.fix.1`, le smoke natif validé de `0.3.5-alpha.5`, le chemin client WASM validé de `0.3.5-alpha.6`, le smoke navigateur validé par `0.3.5-alpha.7.fix.2` et la composition/fallback WebSocket candidate de `0.3.5-alpha.8`. Le cadrage détaillé et la comparaison des stacks actuelles sont conservés dans `docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`. Le présent document porte les décisions opérationnelles, le scope, les gates et le forecast vivant de la version. @@ -199,7 +199,7 @@ WebTransport attempt -> classified unavailable/establishment failure: WebSocket ``` -Le test doit pouvoir forcer les deux branches. +Le test doit pouvoir forcer les deux branches. La classification est volontairement conservative : tant que le backend WebTransport natif agrège sous `TransportErrorKind::Connect` des causes d'établissement qui peuvent inclure un échec TLS/pinning, `Connect` ne doit pas déclencher un downgrade silencieux. `alpha.8` autorise uniquement `Timeout` et `Io` comme causes de fallback ; `InvalidConfiguration`, `Connect`, `Protocol`, `Aborted`, `Closed`, `Bind`, `Accept`, `MessageTooLarge` et `Backpressure` restent visibles. Pas de `TransportManager`, registry de plugins ou sélection dynamique générique dans `game-realtime-transport-lib`. @@ -515,17 +515,25 @@ Cette tranche reste volontairement séparée de `alpha.8` : le smoke WebTranspor Correctif `0.3.5-alpha.7.fix.1` : la première gate utilisateur a validé fmt, audits, check/Clippy natifs, tests, smoke natif, check/Clippy/build WASM et la compilation de l'adapter navigateur, puis a échoué uniquement dans `npm run build` lorsque `wasm-bindgen` cherchait l'artefact Cargo sous `../../builds/...`. Depuis `Web/game-realtime-webtransport-browser-smoke`, le target-dir workspace `../builds/sasedev-games/target` se trouve en réalité sous `../../../builds/...`. Le fix corrige les scripts `wasm:dev`/`wasm:build` et l'audit de distribution associé, sans changement Rust/runtime. -Correctif `0.3.5-alpha.7.fix.2` : la gate du premier fix valide fmt, audits, check/Clippy natifs et le build WASM de l'adapter, puis `npm run build` atteint TypeScript et échoue uniquement parce que l'alias `@webtransport-browser-smoke-wasm` cherche encore les déclarations wasm-bindgen sous `../../builds/...`. Le chemin `paths` du `tsconfig.json` est aligné sur `../../../builds/...`; l'audit de distribution ancre désormais aussi cette résolution. Les erreurs `implicit any` observées sur les callbacks sont une conséquence de ce module non résolu et disparaissent lorsque la déclaration générée est chargee. Aucun code Rust/runtime ni protocole n'est modifié. +Correctif `0.3.5-alpha.7.fix.2` : la gate du premier fix valide fmt, audits, check/Clippy natifs et le build WASM de l'adapter, puis `npm run build` atteint TypeScript et échoue uniquement parce que l'alias `@webtransport-browser-smoke-wasm` cherche encore les déclarations wasm-bindgen sous `../../builds/...`. Le chemin `paths` du `tsconfig.json` est aligné sur `../../../builds/...`; l'audit de distribution ancre désormais aussi cette résolution. Les erreurs `implicit any` observées sur les callbacks sont une conséquence de ce module non résolu et disparaissent lorsque la déclaration générée est chargée. Aucun code Rust/runtime ni protocole n'est modifié. + +La gate utilisateur de `alpha.7.fix.2` valide ensuite fmt, audits, check/Clippy workspace, build WASM de l'adapter, `npm run build`, génération `wasm-bindgen`, TypeScript/Vite et le smoke navigateur réel. Une première tentative interactive a atteint la deadline browser de 5 s sans défaut reproductible établi ; une tentative suivante contre le même peer local a été acceptée et les deux côtés ont terminé par `game-realtime-webtransport-browser-smoke: PASS`. La preuve est conservée dans `history/0.3.5/alpha.7.fix.2.md`. ### `0.3.5-alpha.8` — fallback WebSocket au niveau composition -- tentative WebTransport ; -- fallback WebSocket uniquement sur erreurs classifiées ; -- branche WebTransport forcée ; -- branche fallback forcée ; -- erreur non-fallback visible ; -- aucun couplage gameplay ; -- pas de registry/framework générique. +Tranche candidate matérialisée avec : + +- nouveau launcher technique `game-realtime-transport-fallback-smoke`, sans modification du contrat commun ; +- sélecteur privé WebTransport-first, non réutilisé comme framework produit ; +- branche réelle WebTransport disponible avec round-trip et fermeture propres ; +- branche fallback forcée par un `Timeout` WebTransport classifié, puis vraie connexion WebSocket loopback avec round-trip et fermeture propres ; +- branche non-fallback utilisant une vraie configuration WebTransport invalide (`connect_timeout = 0`) et vérifiant que l'erreur `InvalidConfiguration` reste visible ; +- politique volontairement étroite : seuls `Timeout` et `Io` autorisent le fallback ; +- `Connect` explicitement exclu tant que le backend WebTransport ne distingue pas suffisamment les échecs réseau des échecs TLS/pinning ; +- tests unitaires du sélecteur et de la matrice des `TransportErrorKind` ; +- aucun couplage engine/gameplay, aucune registry et aucun `TransportManager`. + +Le smoke navigateur `alpha.7` reste inchangé et continue d'échouer explicitement si WebTransport échoue ; le fallback n'existe que dans le launcher de composition `alpha.8`. ### `0.3.5-alpha.9` — datagram POC isolé diff --git a/history/0.3.5/alpha.7.fix.2.md b/history/0.3.5/alpha.7.fix.2.md new file mode 100644 index 0000000..eeb8e83 --- /dev/null +++ b/history/0.3.5/alpha.7.fix.2.md @@ -0,0 +1,93 @@ + + + +# Historique 0.3.5-alpha.7.fix.2 + +## Statut + +`0.3.5-alpha.7.fix.2` a été validée par l'utilisateur le 2026-09-22. La tranche navigateur WebTransport est fermée : compilation native/WASM, build Vite/TypeScript, génération `wasm-bindgen`, chargement WASM, connexion WebTransport browser -> Rust avec pin SHA-256 exact, round-trip et fermeture propre ont abouti. + +Une première tentative interactive du navigateur a atteint la deadline de connexion de 5 s. Aucun changement de code n'a été appliqué pour ce symptôme isolé ; une tentative suivante contre le même peer local a été acceptée et le smoke s'est terminé par `PASS`. Aucun défaut reproductible de transport n'a donc été établi par cette première tentative. + +## Gates statiques et compilation + +Les commandes utilisateur ont terminé proprement : + +```text +cargo fmt --all +cargo fmt --all -- --check +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history +python3 scripts/audit_distribution_layout.py +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown +``` + +La sortie confirme : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +games.sasedev workspace audit: clean +Markdown table audit: clean (5 table(s), 300 file(s)) +Distribution layout audit: clean (58 required path(s), 8 forbidden path(s) absent) +cargo check --workspace: clean +cargo clippy --workspace --all-targets --all-features -- -D warnings: clean +browser smoke WASM library build: clean +``` + +## Build frontend + +La commande : + +```bash +(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build) +``` + +termine proprement. `wasm-bindgen` lit désormais le bon artefact externe, TypeScript résout les déclarations générées et Vite produit le bundle sous le build root externe : + +```text +vite v8.3.0 building client environment for production... +5 modules transformed +main.html +*.wasm +main-*.js +built in 41ms +``` + +`npm install` signale `0 vulnerabilities` dans cette gate. + +## Smoke navigateur réel + +Le peer natif a été lancé par : + +```bash +cargo run -p game-realtime-webtransport-browser-smoke +``` + +Il a bind un endpoint éphémère `https://127.0.0.1:/browser-smoke`, publié son hash SHA-256 exact et l'URL `OPEN=...` du host Vite. + +Le host a été lancé par : + +```bash +(cd Web/game-realtime-webtransport-browser-smoke && npm run dev) +``` + +La page a confirmé le secure context, la présence de WebTransport et le chargement du WASM. Une tentative suivante a été acceptée par le serveur : + +```text +WebTransport peer accepted peer=127.0.0.1: +game-realtime-webtransport-browser-smoke: PASS +browser WebTransport smoke passed +``` + +Le smoke navigateur WebTransport réel est donc validé sans fallback WebSocket. + +## Graphe cible + +Les graphes fournis confirment que `game-realtime-webtransport-browser-smoke` consomme `game-realtime-webtransport-lib` et que `gloo-timers` reste sur le chemin WASM. La séparation native/WASM établie en `alpha.6` reste intacte. + +## Conséquence + +`alpha.8` peut maintenant exercer séparément la composition WebTransport-first et le fallback WebSocket. Le smoke navigateur validé reste une preuve WebTransport pure et ne doit pas recevoir de fallback silencieux. diff --git a/scripts/audit_distribution_layout.py b/scripts/audit_distribution_layout.py index 8c34dea..1662945 100644 --- a/scripts/audit_distribution_layout.py +++ b/scripts/audit_distribution_layout.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # file: scripts/audit_distribution_layout.py -# version: 21 +# version: 22 """Audit the static distribution layout expected by supported POCs.""" @@ -24,6 +24,9 @@ FORBIDDEN_PATHS = ( REQUIRED_PATHS = ( + "crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml", + "crates/apps/game-realtime-transport-fallback-smoke/src/main.rs", + "crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs", "crates/apps/game-realtime-websocket-smoke/Cargo.toml", "crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml", "crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs", @@ -356,6 +359,39 @@ def audit_webtransport_browser_smoke(root: pathlib.Path) -> list[str]: violations.append(message) return violations + +def audit_realtime_fallback_smoke(root: pathlib.Path) -> list[str]: + """Validate the technical WebTransport-first/WebSocket fallback composition boundary.""" + + violations: list[str] = [] + manifest_path = root / "crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml" + main_path = root / "crates/apps/game-realtime-transport-fallback-smoke/src/main.rs" + unit_path = root / "crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs" + if not manifest_path.exists() or not main_path.exists() or not unit_path.exists(): + return violations + manifest = manifest_path.read_text(encoding="utf-8") + main = main_path.read_text(encoding="utf-8") + unit = unit_path.read_text(encoding="utf-8") + required_fragments = ( + (manifest, 'game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }', "DIST-LAYOUT-087: fallback smoke must consume the WebSocket backend directly at composition level"), + (manifest, 'game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }', "DIST-LAYOUT-088: fallback smoke must consume the WebTransport backend directly at composition level"), + (main, 'kind == game_realtime_transport_lib::TransportErrorKind::Timeout || kind == game_realtime_transport_lib::TransportErrorKind::Io', "DIST-LAYOUT-089: fallback smoke classification must remain limited to Timeout/Io"), + (unit, 'assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Connect));', "DIST-LAYOUT-090: ambiguous WebTransport Connect failures must not trigger fallback"), + (main, 'game-realtime-transport-fallback-smoke: PASS', "DIST-LAYOUT-091: fallback smoke must expose a deterministic PASS verdict"), + ) + for content, fragment, message in required_fragments: + if fragment not in content: + violations.append(message) + forbidden_fragments = ( + "engine-v1-", + "game-reflex-poc", + "game-snake-poc", + ) + for fragment in forbidden_fragments: + if fragment in manifest: + violations.append(f"DIST-LAYOUT-092: fallback smoke must not depend on engine/gameplay package: {fragment}") + return violations + def audit_android_native_gradle(root: pathlib.Path) -> list[str]: """Validate the minimum Gradle contract and multi-ABI native Rust consumers.""" @@ -446,6 +482,7 @@ def main() -> int: contract_violations.extend(audit_snake_tauri_android_integration(root)) contract_violations.extend(audit_snake_tauri_generated_android(root)) contract_violations.extend(audit_android_native_gradle(root)) + contract_violations.extend(audit_realtime_fallback_smoke(root)) contract_violations.extend(audit_webtransport_browser_smoke(root)) if missing: for relative in missing: