diff --git a/Cargo.toml b/Cargo.toml index d4ab77a..113bddb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 106 +# version: 107 [workspace] resolver = "3" @@ -12,6 +12,7 @@ members = [ "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-datagram-smoke", "crates/apps/game-realtime-webtransport-smoke", "crates/apps/game-reflex-poc-desktop", "crates/apps/game-snake-poc-desktop", @@ -28,7 +29,7 @@ members = [ ] [workspace.package] -version = "0.3.5-alpha.8.fix.1" +version = "0.3.5-alpha.9" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/games" diff --git a/README.md b/README.md index 4aba6fb..33eb706 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.8.fix.1`. `0.3.2` reste différée. +Version active : `0.3.5-alpha.9`. `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.8.fix.1` 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. +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.9` conserve les chemins WebTransport natif et navigateur validés, la composition WebTransport-first avec fallback WebSocket strictement classifié, puis ajoute un POC datagram backend-spécifique isolé. Les datagrams restent hors du contrat `RealtimeConnection` : ils sont non fiables, non ordonnés et bornés par la taille négociée de la session. Les mesures comparatives restent réservées à la tranche suivante. 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 ; `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/`. +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 ; `game-realtime-webtransport-datagram-smoke` exerce enfin la capacité datagram WebTransport native sans l'ajouter au contrat fiable commun. 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-webtransport-datagram-smoke/Cargo.toml b/crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml new file mode 100644 index 0000000..b1af6bf --- /dev/null +++ b/crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml @@ -0,0 +1,20 @@ +# file: crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml +# version: 1 + +[package] +name = "game-realtime-webtransport-datagram-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-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-webtransport-datagram-smoke/src/main.rs b/crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs new file mode 100644 index 0000000..a4fab36 --- /dev/null +++ b/crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs @@ -0,0 +1,101 @@ +// file: crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Executable localhost smoke for the backend-specific WebTransport datagram capability. + +const CLIENT_DATAGRAM: &[u8] = b"games.sasedev-webtransport-client-datagram"; +const DATAGRAM_RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const SERVER_DATAGRAM: &[u8] = b"games.sasedev-webtransport-server-datagram"; +const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const TRACING_TARGET: &str = "games::realtime::webtransport::datagram-smoke"; + +#[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 WebTransport datagram smoke tracing: {error}"); + return std::process::ExitCode::FAILURE; + }, + }; + tracing::info!(target: TRACING_TARGET, "WebTransport datagram 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, "WebTransport datagram smoke passed"); + println!("game-realtime-webtransport-datagram-smoke: PASS"); + std::process::ExitCode::SUCCESS + }, + std::result::Result::Ok(std::result::Result::Err(error)) => { + tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "WebTransport datagram smoke failed"); + eprintln!("game-realtime-webtransport-datagram-smoke: FAIL: {error}"); + std::process::ExitCode::FAILURE + }, + std::result::Result::Err(_) => { + tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "WebTransport datagram smoke timed out"); + eprintln!("game-realtime-webtransport-datagram-smoke: FAIL: smoke timed out"); + std::process::ExitCode::FAILURE + }, + }; +} + +async fn run_smoke() -> 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!("loopback 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!("listener bind failed: {error}")), + }; + let endpoint = format!("https://{}/datagram-smoke", 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!("client configuration failed: {error}")), + }; + let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config)); + let server_session = match server_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(format!("server accept failed: {error}")), + }; + let client_session = match client_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(format!("client connect failed: {error}")), + }; + let client_max = client_session.max_datagram_size(); + let server_max = server_session.max_datagram_size(); + if CLIENT_DATAGRAM.len() > client_max || SERVER_DATAGRAM.len() > server_max { + return std::result::Result::Err(format!("negotiated datagram capacity too small: client_max={client_max}, server_max={server_max}")); + } + tracing::info!(target: TRACING_TARGET, client_max, server_max, "WebTransport datagram capacities negotiated"); + if let std::result::Result::Err(error) = client_session.send_datagram(CLIENT_DATAGRAM) { + return std::result::Result::Err(format!("client datagram send failed: {error}")); + } + let server_received = match tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, server_session.receive_datagram()).await { + std::result::Result::Ok(std::result::Result::Ok(value)) => value, + std::result::Result::Ok(std::result::Result::Err(error)) => return std::result::Result::Err(format!("server datagram receive failed: {error}")), + std::result::Result::Err(_) => return std::result::Result::Err(String::from("server datagram receive timed out; datagram delivery is not guaranteed")), + }; + if server_received.as_slice() != CLIENT_DATAGRAM { + return std::result::Result::Err(String::from("server received an unexpected datagram payload")); + } + if let std::result::Result::Err(error) = server_session.send_datagram(SERVER_DATAGRAM) { + return std::result::Result::Err(format!("server datagram send failed: {error}")); + } + let client_received = match tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, client_session.receive_datagram()).await { + std::result::Result::Ok(std::result::Result::Ok(value)) => value, + std::result::Result::Ok(std::result::Result::Err(error)) => return std::result::Result::Err(format!("client datagram receive failed: {error}")), + std::result::Result::Err(_) => return std::result::Result::Err(String::from("client datagram receive timed out; datagram delivery is not guaranteed")), + }; + if client_received.as_slice() != SERVER_DATAGRAM { + return std::result::Result::Err(String::from("client received an unexpected datagram payload")); + } + return std::result::Result::Ok(()); +} diff --git a/crates/common/game-realtime-webtransport-lib/README.md b/crates/common/game-realtime-webtransport-lib/README.md index 0b80329..3a9cb31 100644 --- a/crates/common/game-realtime-webtransport-lib/README.md +++ b/crates/common/game-realtime-webtransport-lib/README.md @@ -1,5 +1,5 @@ - + # game-realtime-webtransport-lib @@ -29,6 +29,7 @@ La frontière fiable disponible couvre désormais : - tracing sous `games::realtime::webtransport`; - client WASM avec endpoint HTTPS, hash certificat SHA-256 explicite, établissement de session navigateur et ouverture du stream bidirectionnel primaire; - adaptation WASM du framing `u32` big-endian et des traits realtime, y compris FIN, reset/STOP et réception incrémentale. +- capacité datagram WebTransport backend-spécifique sur les sessions native et navigateur, sans extension de `RealtimeConnection`. ## Chemin navigateur/WASM @@ -40,6 +41,16 @@ Le pin SHA-256 est transmis à `WebTransportOptions.serverCertificateHashes`; au À partir de `alpha.7`, les deadlines `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont aussi matérialisées côté navigateur par des timers WASM. Une deadline navigateur doit tenir dans la plage `u32` millisecondes ; une valeur supérieure est rejetée comme `InvalidConfiguration`. Une réception idle reste volontairement sans timeout implicite, comme sur le chemin natif. Le smoke navigateur technique séparé prouve ensuite cette surface avec un vrai navigateur sans fallback WebSocket. +## Datagrams backend-spécifiques + +`WebTransportSession` expose `max_datagram_size()`, `send_datagram(...)` et `receive_datagram()` sur les chemins natif et navigateur. Cette surface reste volontairement propre au backend WebTransport : un datagram n'est ni fiable ni ordonné et ne peut donc pas satisfaire le contrat `RealtimeConnection`. + +La taille maximale dépend de la session et du chemin réseau. Le backend rejette localement un payload supérieur à `max_datagram_size()` avec `TransportErrorKind::MessageTooLarge`; cette valeur peut évoluer entre sessions et ne doit pas être traitée comme une constante produit. + +`receive_datagram()` n'impose aucun timeout. Le consommateur doit borner l'attente selon son scénario. Le smoke `game-realtime-webtransport-datagram-smoke` utilise une deadline locale précisément parce que la perte d'un datagram est un résultat autorisé par le protocole. Il vérifie un échange loopback dans les deux sens sans en déduire de garantie de livraison ou d'ordre. + +Le chemin navigateur compile la même capacité via `web-transport-wasm`; `alpha.9` ne réouvre pas le smoke navigateur interactif et limite la preuve runtime datagram au backend natif. + ## TLS de développement `WebTransportServerIdentity::generate_loopback()` crée une identité en mémoire. La clé privée n'est ni écrite ni versionnée. Le certificat est valide sept jours, avec une petite marge de clock skew, et son SHA-256 est exposé à travers `WebTransportCertificateHash` afin que le client puisse utiliser le pinning fourni par `web-transport-quinn`. diff --git a/crates/common/game-realtime-webtransport-lib/USAGE.md b/crates/common/game-realtime-webtransport-lib/USAGE.md index 3960462..65516c3 100644 --- a/crates/common/game-realtime-webtransport-lib/USAGE.md +++ b/crates/common/game-realtime-webtransport-lib/USAGE.md @@ -1,9 +1,9 @@ - + # Utilisation de game-realtime-webtransport-lib -Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés par `game-realtime-webtransport-lib`. Il ne décrit ni gameplay, ni protocole wire métier, ni datagrams. +Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés par `game-realtime-webtransport-lib`, ainsi que la capacité datagram backend-spécifique. Il ne décrit ni gameplay ni protocole wire métier. ## Configuration transport @@ -157,3 +157,25 @@ Ces deux méthodes sont backend-spécifiques : elles ne sont pas ajoutées au co Dropper un sender actif ou annuler une future `send()` en cours provoque un reset explicite. Dropper un receiver actif provoque un stop explicite. Cela évite qu'une cancellation d'écriture partielle soit interprétée comme une fermeture propre ou qu'une frame suivante reprenne au mauvais offset. Une future `receive()` peut en revanche être annulée puis relancée : le backend conserve l'état partiel du header/payload et reprend le framing à l'octet correct. + +## Datagrams WebTransport + +Les datagrams sont une capacité propre à `WebTransportSession`; ils ne passent pas par `RealtimeConnection`. Toujours consulter la taille courante avant émission : + +```rust +let max_payload = session.max_datagram_size(); +if payload.len() <= max_payload { + session.send_datagram(payload)?; +} +``` + +La réception est asynchrone : + +```rust +let payload = session.receive_datagram().await?; +``` + +Ces appels ne donnent aucune garantie de livraison ni d'ordre. Une perte n'est pas une violation du transport datagram. Toute deadline de réception appartient donc au scénario consommateur ; elle n'est pas injectée dans `WebTransportConfig`. + +Le chemin navigateur expose les mêmes noms, avec `send_datagram(...).await`. La surface reste backend-spécifique afin de ne pas imposer une sémantique non fiable à WebSocket ou au contrat fiable commun. + diff --git a/crates/common/game-realtime-webtransport-lib/src/webtransport.rs b/crates/common/game-realtime-webtransport-lib/src/webtransport.rs index 1ccce5f..6dd6da6 100644 --- a/crates/common/game-realtime-webtransport-lib/src/webtransport.rs +++ b/crates/common/game-realtime-webtransport-lib/src/webtransport.rs @@ -1,5 +1,5 @@ // file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs -// version: 4 +// version: 5 const CERTIFICATE_HASH_SIZE: usize = 32; const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60); @@ -250,6 +250,36 @@ impl WebTransportSession { None => None, }; } + + /// Returns the maximum payload size accepted by the backend-specific unreliable datagram path. + #[must_use] + pub fn max_datagram_size(&self) -> usize { + return self.inner.max_datagram_size(); + } + + /// Sends one backend-specific unreliable and unordered WebTransport datagram. + /// + /// This capability is intentionally not part of `RealtimeConnection`; delivery and ordering are not guaranteed. + pub fn send_datagram(&self, payload: &[u8]) -> Result<(), game_realtime_transport_lib::TransportError> { + let max_datagram_size = self.max_datagram_size(); + if payload.len() > max_datagram_size { + return Err(datagram_too_large(payload.len(), max_datagram_size)); + } + return match self.inner.send_datagram(payload.to_vec().into()) { + Ok(()) => Ok(()), + Err(error) => Err(transport_error(map_session_error_kind(&error), error.to_string())), + }; + } + + /// Receives one backend-specific unreliable and unordered WebTransport datagram. + /// + /// The caller is responsible for applying any operation deadline required by its use case. + pub async fn receive_datagram(&self) -> Result, game_realtime_transport_lib::TransportError> { + return match self.inner.read_datagram().await { + Ok(payload) => Ok(payload.to_vec()), + Err(error) => Err(transport_error(map_session_error_kind(&error), error.to_string())), + }; + } } /// Established WebTransport realtime connection carried by one primary reliable bidirectional stream. @@ -771,6 +801,13 @@ fn map_write_error(error: web_transport_quinn::WriteError) -> game_realtime_tran return transport_error(kind, error.to_string()); } +fn datagram_too_large(payload_len: usize, max_datagram_size: usize) -> game_realtime_transport_lib::TransportError { + return transport_error( + game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, + format!("WebTransport datagram payload size {payload_len} exceeds current session maximum {max_datagram_size} bytes"), + ); +} + fn message_too_large(payload_len: usize, max_message_size: usize) -> game_realtime_transport_lib::TransportError { return transport_error( game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, diff --git a/crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs b/crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs index 3508bd5..70ad8cf 100644 --- a/crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs +++ b/crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs @@ -1,5 +1,5 @@ // file: crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs -// version: 2 +// version: 3 const CERTIFICATE_HASH_SIZE: usize = 32; const FRAME_PROTOCOL_ERROR_CODE: u32 = 0x10; @@ -123,6 +123,36 @@ impl WebTransportSession { pub fn endpoint(&self) -> &str { return self.inner.url().as_str(); } + + /// Returns the maximum payload size accepted by the browser WebTransport datagram path. + #[must_use] + pub fn max_datagram_size(&self) -> usize { + return self.inner.max_datagram_size(); + } + + /// Sends one browser WebTransport datagram without reliability or ordering guarantees. + /// + /// This capability is intentionally not part of `RealtimeConnection`. + pub async fn send_datagram(&self, payload: &[u8]) -> Result<(), game_realtime_transport_lib::TransportError> { + let max_datagram_size = self.max_datagram_size(); + if payload.len() > max_datagram_size { + return Err(datagram_too_large(payload.len(), max_datagram_size)); + } + return match self.inner.send_datagram(payload.to_vec().into()).await { + Ok(()) => Ok(()), + Err(error) => Err(map_write_error(error)), + }; + } + + /// Receives one browser WebTransport datagram without reliability or ordering guarantees. + /// + /// The caller is responsible for applying any operation deadline required by its use case. + pub async fn receive_datagram(&self) -> Result, game_realtime_transport_lib::TransportError> { + return match self.inner.recv_datagram().await { + Ok(payload) => Ok(payload.to_vec()), + Err(error) => Err(map_read_error(error)), + }; + } } /// Established browser WebTransport connection carrying the single reliable primary stream. @@ -588,6 +618,13 @@ fn map_write_error(error: web_transport_wasm::Error) -> game_realtime_transport_ return transport_error(kind, error.to_string()); } +fn datagram_too_large(payload_len: usize, max_datagram_size: usize) -> game_realtime_transport_lib::TransportError { + return transport_error( + game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, + format!("WebTransport datagram payload size {payload_len} exceeds current session maximum {max_datagram_size} bytes"), + ); +} + fn message_too_large(payload_len: usize, max_message_size: usize) -> game_realtime_transport_lib::TransportError { return transport_error( game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, diff --git a/crates/common/game-realtime-webtransport-lib/tests/datagrams.rs b/crates/common/game-realtime-webtransport-lib/tests/datagrams.rs new file mode 100644 index 0000000..934cbee --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/tests/datagrams.rs @@ -0,0 +1,109 @@ +// file: crates/common/game-realtime-webtransport-lib/tests/datagrams.rs +// version: 1 + +#![cfg(not(target_arch = "wasm32"))] + +const CLIENT_DATAGRAM: &[u8] = b"client-datagram"; +const RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); +const SERVER_DATAGRAM: &[u8] = b"server-datagram"; + +#[tokio::test(flavor = "current_thread")] +async fn backend_specific_datagrams_round_trip_without_entering_the_common_contract() { + let identity_result = game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback(); + assert!(identity_result.is_ok()); + let identity = match identity_result { + Ok(value) => value, + Err(error) => panic!("identity generation failed unexpectedly: {error}"), + }; + let certificate_hash = identity.certificate_hash().clone(); + let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity); + let listener_result = game_realtime_webtransport_lib::WebTransportListener::bind(server_config); + assert!(listener_result.is_ok()); + let mut listener = match listener_result { + Ok(value) => value, + Err(error) => panic!("listener bind failed unexpectedly: {error}"), + }; + let endpoint = format!("https://{}/datagrams", listener.local_addr()); + let client_config_result = game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash); + assert!(client_config_result.is_ok()); + let client_config = match client_config_result { + Ok(value) => value, + Err(error) => panic!("client config failed unexpectedly: {error}"), + }; + let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config)); + assert!(server_result.is_ok()); + assert!(client_result.is_ok()); + let server_session = match server_result { + Ok(value) => value, + Err(error) => panic!("server accept failed unexpectedly: {error}"), + }; + let client_session = match client_result { + Ok(value) => value, + Err(error) => panic!("client connect failed unexpectedly: {error}"), + }; + assert!(client_session.max_datagram_size() >= CLIENT_DATAGRAM.len()); + assert!(server_session.max_datagram_size() >= SERVER_DATAGRAM.len()); + assert!(client_session.send_datagram(CLIENT_DATAGRAM).is_ok()); + let server_receive = tokio::time::timeout(RECEIVE_TIMEOUT, server_session.receive_datagram()).await; + assert!(server_receive.is_ok()); + let server_payload = match server_receive { + Ok(Ok(value)) => value, + Ok(Err(error)) => panic!("server datagram receive failed unexpectedly: {error}"), + Err(_) => panic!("server datagram receive timed out"), + }; + assert_eq!(server_payload.as_slice(), CLIENT_DATAGRAM); + assert!(server_session.send_datagram(SERVER_DATAGRAM).is_ok()); + let client_receive = tokio::time::timeout(RECEIVE_TIMEOUT, client_session.receive_datagram()).await; + assert!(client_receive.is_ok()); + let client_payload = match client_receive { + Ok(Ok(value)) => value, + Ok(Err(error)) => panic!("client datagram receive failed unexpectedly: {error}"), + Err(_) => panic!("client datagram receive timed out"), + }; + assert_eq!(client_payload.as_slice(), SERVER_DATAGRAM); +} + +#[tokio::test(flavor = "current_thread")] +async fn oversized_datagram_is_rejected_before_backend_send() { + let identity_result = game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback(); + assert!(identity_result.is_ok()); + let identity = match identity_result { + Ok(value) => value, + Err(error) => panic!("identity generation failed unexpectedly: {error}"), + }; + let certificate_hash = identity.certificate_hash().clone(); + let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity); + let listener_result = game_realtime_webtransport_lib::WebTransportListener::bind(server_config); + assert!(listener_result.is_ok()); + let mut listener = match listener_result { + Ok(value) => value, + Err(error) => panic!("listener bind failed unexpectedly: {error}"), + }; + let endpoint = format!("https://{}/datagrams-limit", listener.local_addr()); + let client_config_result = game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash); + assert!(client_config_result.is_ok()); + let client_config = match client_config_result { + Ok(value) => value, + Err(error) => panic!("client config failed unexpectedly: {error}"), + }; + let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config)); + assert!(server_result.is_ok()); + assert!(client_result.is_ok()); + let client_session = match client_result { + Ok(value) => value, + Err(error) => panic!("client connect failed unexpectedly: {error}"), + }; + let oversized_len = match client_session.max_datagram_size().checked_add(1) { + Some(value) => value, + None => panic!("datagram maximum unexpectedly reached usize::MAX"), + }; + assert!(oversized_len <= 1024 * 1024); + let payload = vec![0_u8; oversized_len]; + let error_result = client_session.send_datagram(payload.as_slice()); + assert!(error_result.is_err()); + let error = match error_result { + Ok(()) => panic!("oversized datagram unexpectedly succeeded"), + Err(value) => value, + }; + assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge); +} diff --git a/deltas/0.3.5/alpha.9.md b/deltas/0.3.5/alpha.9.md new file mode 100644 index 0000000..817d196 --- /dev/null +++ b/deltas/0.3.5/alpha.9.md @@ -0,0 +1,180 @@ + + + +# Delta 0.3.5-alpha.9 + +## Base requise + +`0.3.5-alpha.8.fix.1`, validée par l'utilisateur le 2026-09-22. + +La gate finale confirme fmt, audits, `cargo check --workspace`, Clippy strict, les deux tests du launcher fallback et le smoke réel terminé par : + +```text +game-realtime-transport-fallback-smoke: PASS +``` + +La preuve est enregistrée dans `history/0.3.5/alpha.8.fix.1.md`. + +## Objectif + +Challenger les datagrams WebTransport comme capacité backend-spécifique sans modifier le contrat fiable transport-neutral : + +- émission/réception native réelle ; +- surface équivalente compilable côté navigateur/WASM ; +- taille maximale de datagram obtenue depuis la session ; +- rejet local des payloads trop grands ; +- preuve runtime locale bornée ; +- aucune garantie de livraison ou d'ordre ajoutée ; +- aucune API datagram dans `game-realtime-transport-lib`. + +## Version + +La version workspace passe à : + +```text +0.3.5-alpha.9 +``` + +## Capacité backend-spécifique + +`WebTransportSession` expose désormais sur les chemins natif et navigateur : + +```text +max_datagram_size() +send_datagram(...) +receive_datagram() +``` + +Cette surface reste propre à `game-realtime-webtransport-lib`. + +Les datagrams WebTransport sont non fiables et non ordonnés. Ils ne satisfont donc pas les invariants de `RealtimeConnection`, qui reste le chemin fiable ordonné commun à WebSocket et WebTransport. + +Le backend vérifie la taille avant émission et retourne `TransportErrorKind::MessageTooLarge` lorsque le payload dépasse `max_datagram_size()` pour la session courante. + +`receive_datagram()` n'introduit aucune deadline implicite. Une attente bornée relève du consommateur/POC, car la perte d'un datagram est un résultat valide et ne doit pas être transformée en erreur de protocole commune. + +## Natif + +Le chemin natif utilise directement les primitives de `web-transport-quinn 0.12.1` : + +```text +Session::max_datagram_size +Session::send_datagram +Session::read_datagram +``` + +Un test d'intégration couvre : + +- session loopback avec pin SHA-256 exact ; +- datagram client -> serveur ; +- datagram serveur -> client ; +- attente bornée côté test ; +- rejet local d'un payload supérieur à la taille négociée. + +## WASM + +Le chemin `wasm32-unknown-unknown` expose la même capacité via `web-transport-wasm 0.6.0` : + +```text +Session::max_datagram_size +Session::send_datagram +Session::recv_datagram +``` + +`send_datagram(...)` est asynchrone côté navigateur conformément à la surface du wrapper. + +La tranche ne réouvre pas le smoke navigateur interactif : `alpha.7.fix.2` reste la preuve navigateur du chemin fiable. La gate WASM de `alpha.9` vérifie que la nouvelle capacité compile sur la cible navigateur. + +## Smoke datagram natif + +Nouveau launcher technique : + +```text +crates/apps/game-realtime-webtransport-datagram-smoke +``` + +Le smoke : + +1. génère une identité P-256 loopback en mémoire ; +2. bind un serveur WebTransport sur port éphémère ; +3. établit client et serveur avec pin SHA-256 exact ; +4. lit les tailles maximales datagram des deux sessions ; +5. envoie un datagram client -> serveur ; +6. attend sa réception sous une deadline locale de 2 s ; +7. envoie un datagram serveur -> client ; +8. attend sa réception sous la même deadline ; +9. termine par un verdict déterministe. + +Résultat attendu : + +```text +game-realtime-webtransport-datagram-smoke: PASS +``` + +Le smoke ne teste pas l'ordre et ne prétend pas garantir la livraison. Un timeout de réception est reporté comme échec du smoke local, avec un message rappelant explicitement que la livraison datagram n'est pas garantie. + +## Frontières + +Le delta ne modifie pas : + +- `game-realtime-transport-lib` ; +- `RealtimeConnection` / `RealtimeSender` / `RealtimeReceiver` ; +- `game-realtime-websocket-lib` ; +- le fallback `alpha.8` ; +- les engines ; +- les crates gameplay. + +La décision de `alpha.9` est donc : **capacité WebTransport backend-spécifique utile à conserver pour expérimentation/mesure, sans promotion au contrat commun**. + +## Fichiers principaux + +Nouveaux : + +```text +crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml +crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs +crates/common/game-realtime-webtransport-lib/tests/datagrams.rs +deltas/0.3.5/alpha.9.md +history/0.3.5/alpha.8.fix.1.md +``` + +Modifiés : + +```text +Cargo.toml +README.md +crates/common/game-realtime-webtransport-lib/README.md +crates/common/game-realtime-webtransport-lib/USAGE.md +crates/common/game-realtime-webtransport-lib/src/webtransport.rs +crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs +docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md +scripts/audit_distribution_layout.py +``` + +## 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-webtransport-lib --all-targets --all-features +cargo run -p game-realtime-webtransport-datagram-smoke + +cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown +cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings +cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown + +cargo tree -p game-realtime-webtransport-datagram-smoke --edges normal +cargo tree -i game-realtime-webtransport-lib --workspace --edges normal +``` + +## Après validation + +Créer `history/0.3.5/alpha.9.md` à partir des sorties réellement fournies puis ouvrir `0.3.5-alpha.10` pour les mesures comparatives bornées WebSocket / WebTransport fiable, avec datagram uniquement comme capacité WebTransport séparée. 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 39abe74..898f1ce 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 validé par `0.3.5-alpha.7.fix.2` et la composition/fallback WebSocket corrigée par `0.3.5-alpha.8.fix.1`. +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`, la composition/fallback WebSocket validée par `0.3.5-alpha.8.fix.1` et le POC datagram backend-spécifique de `0.3.5-alpha.9`. 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. @@ -539,15 +539,17 @@ Correctif `0.3.5-alpha.8.fix.1` : la gate utilisateur de `alpha.8` valide fmt, a ### `0.3.5-alpha.9` — datagram POC isolé -Uniquement si le backend fiable et le navigateur sont suffisamment stables : +Le backend fiable natif et le navigateur étant désormais validés, la tranche est conservée avec un scope strict : -- émission/réception datagram ; -- pertes/ordre non garantis documentés ; -- preuve locale bornée ; -- aucune modification du contrat commun ; -- décision explicite : utile pour future capability ou simple constat. +- `WebTransportSession` expose `max_datagram_size()`, `send_datagram(...)` et `receive_datagram()` comme capacité backend-spécifique ; +- le natif utilise directement les datagrams de `web-transport-quinn` ; +- le chemin WASM expose la même capacité via `web-transport-wasm` et reste couvert par une gate de compilation ; +- un smoke natif séparé effectue un échange datagram bidirectionnel sur loopback sous deadlines locales ; +- un test d'intégration couvre le round-trip et le rejet local d'un payload supérieur à la taille courante de session ; +- aucune garantie de livraison ou d'ordre n'est introduite ni testée comme invariant ; +- aucune modification n'est apportée à `game-realtime-transport-lib` ou `RealtimeConnection`. -Si la valeur est déjà claire sans API supplémentaire, cette tranche peut être fusionnée avec la mesure ou supprimée. +Le smoke navigateur interactif n'est pas rouvert dans cette tranche : sa preuve fiable `alpha.7.fix.2` reste intacte. La valeur retenue est **future capability backend-spécifique utile**, à mesurer en `alpha.10`, sans promotion vers le contrat commun. ### `0.3.5-alpha.10` — mesures comparatives bornées diff --git a/history/0.3.5/alpha.8.fix.1.md b/history/0.3.5/alpha.8.fix.1.md new file mode 100644 index 0000000..2f0f174 --- /dev/null +++ b/history/0.3.5/alpha.8.fix.1.md @@ -0,0 +1,55 @@ + + + +# Historique 0.3.5-alpha.8.fix.1 + +## Statut + +`0.3.5-alpha.8.fix.1` a été validée par l'utilisateur le 2026-09-22. La tranche de composition WebTransport-first / fallback WebSocket est fermée sans modification du contrat transport-neutral. + +## Gates + +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 test -p game-realtime-transport-fallback-smoke --all-targets --all-features +cargo run -p game-realtime-transport-fallback-smoke +``` + +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), 303 file(s)) +Distribution layout audit: clean (61 required path(s), 8 forbidden path(s) absent) +cargo check --workspace: clean +cargo clippy --workspace --all-targets --all-features -- -D warnings: clean +fallback selector tests: 2 passed; 0 failed +``` + +## Smoke de composition + +Le launcher réel confirme successivement : + +```text +preferred transport branch passed selected="webtransport" +WebTransport unavailable; attempting classified WebSocket fallback +classified fallback branch passed selected="websocket" +non-fallback error remained visible kind=invalid configuration +game-realtime-transport-fallback-smoke: PASS +``` + +Le fallback reste donc limité à `Timeout`/`Io`. Les erreurs ambiguës `Connect` et les erreurs de configuration/protocole ne sont pas transformées en downgrade silencieux. + +## Conséquence + +`alpha.9` peut challenger séparément la capacité datagram WebTransport. Cette capacité reste backend-spécifique et ne doit pas modifier `RealtimeConnection`, qui représente le chemin fiable ordonné commun à WebSocket et WebTransport. diff --git a/scripts/audit_distribution_layout.py b/scripts/audit_distribution_layout.py index 1662945..06cf765 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: 22 +# version: 23 """Audit the static distribution layout expected by supported POCs.""" @@ -29,6 +29,9 @@ REQUIRED_PATHS = ( "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-datagram-smoke/Cargo.toml", + "crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs", + "crates/common/game-realtime-webtransport-lib/tests/datagrams.rs", "crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs", "crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs", "crates/apps/game-reflex-poc-desktop/Cargo.toml", @@ -392,6 +395,44 @@ def audit_realtime_fallback_smoke(root: pathlib.Path) -> list[str]: violations.append(f"DIST-LAYOUT-092: fallback smoke must not depend on engine/gameplay package: {fragment}") return violations + +def audit_webtransport_datagram_smoke(root: pathlib.Path) -> list[str]: + """Validate the isolated backend-specific WebTransport datagram proof.""" + + violations: list[str] = [] + manifest_path = root / "crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml" + main_path = root / "crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs" + native_path = root / "crates/common/game-realtime-webtransport-lib/src/webtransport.rs" + wasm_path = root / "crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs" + common_path = root / "crates/common/game-realtime-transport-lib/src/lib.rs" + required = (manifest_path, main_path, native_path, wasm_path, common_path) + if not all(path.exists() for path in required): + return violations + manifest = manifest_path.read_text(encoding="utf-8") + main = main_path.read_text(encoding="utf-8") + native = native_path.read_text(encoding="utf-8") + wasm = wasm_path.read_text(encoding="utf-8") + common = common_path.read_text(encoding="utf-8") + required_fragments = ( + (manifest, 'game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }', "DIST-LAYOUT-093: datagram smoke must consume the WebTransport backend directly"), + (main, 'max_datagram_size()', "DIST-LAYOUT-094: datagram smoke must inspect the negotiated backend payload bound"), + (main, 'send_datagram(', "DIST-LAYOUT-095: datagram smoke must send backend-specific datagrams"), + (main, 'receive_datagram()', "DIST-LAYOUT-096: datagram smoke must receive backend-specific datagrams"), + (main, 'game-realtime-webtransport-datagram-smoke: PASS', "DIST-LAYOUT-097: datagram smoke must expose a deterministic PASS verdict"), + (native, 'pub fn send_datagram(&self, payload: &[u8])', "DIST-LAYOUT-098: native WebTransport session must own the backend-specific datagram send capability"), + (wasm, 'pub async fn send_datagram(&self, payload: &[u8])', "DIST-LAYOUT-099: browser WebTransport session must expose the backend-specific datagram send capability"), + ) + for content, fragment, message in required_fragments: + if fragment not in content: + violations.append(message) + if "datagram" in common.lower(): + violations.append("DIST-LAYOUT-100: transport-neutral realtime contract must remain free of datagram semantics") + for fragment in ("engine-v1-", "game-reflex-poc", "game-snake-poc"): + if fragment in manifest: + violations.append(f"DIST-LAYOUT-101: datagram 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.""" @@ -484,6 +525,7 @@ def main() -> int: contract_violations.extend(audit_android_native_gradle(root)) contract_violations.extend(audit_realtime_fallback_smoke(root)) contract_violations.extend(audit_webtransport_browser_smoke(root)) + contract_violations.extend(audit_webtransport_datagram_smoke(root)) if missing: for relative in missing: print(f"DIST-LAYOUT-001: missing required path: {relative}", file=sys.stderr)