From fd6ffecdf5fd0a9efcf372e541ae563f38083d14 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Mon, 21 Sep 2026 22:55:21 +0200 Subject: [PATCH] 0.3.5-alpha.3 --- Cargo.toml | 4 +- README.md | 8 +- .../game-realtime-webtransport-lib/README.md | 47 +++- .../game-realtime-webtransport-lib/USAGE.md | 89 +++++++ .../game-realtime-webtransport-lib/src/lib.rs | 22 +- .../src/webtransport.rs | 217 +++++++++++++++++- .../tests/realtime_connection.rs | 89 +++++++ .../unit_tests/webtransport.rs | 34 ++- deltas/0.3.5/alpha.3.md | 184 +++++++++++++++ .../005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md | 34 ++- history/0.3.5/alpha.2.fix.1.md | 59 +++++ 11 files changed, 750 insertions(+), 37 deletions(-) create mode 100644 crates/common/game-realtime-webtransport-lib/USAGE.md create mode 100644 crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs create mode 100644 deltas/0.3.5/alpha.3.md create mode 100644 history/0.3.5/alpha.2.fix.1.md diff --git a/Cargo.toml b/Cargo.toml index 660cee8..7e47e4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 97 +# version: 98 [workspace] resolver = "3" @@ -25,7 +25,7 @@ members = [ ] [workspace.package] -version = "0.3.5-alpha.2.fix.1" +version = "0.3.5-alpha.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/games" diff --git a/README.md b/README.md index d008f65..7ba2a00 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.2.fix.1`. `0.3.2` reste différée. +Version active : `0.3.5-alpha.3`. `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.2.fix.1` ferme le test d’établissement du backend WebTransport natif minimal : Quinn/HTTP/3, identité TLS locale ECDSA P-256 courte ou injectée, pin SHA-256 et établissement client/server loopback. Streams applicatifs, framing fiable et adaptation `RealtimeConnection` restent réservés à la tranche suivante. +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.3` ajoute au backend WebTransport natif le stream bidirectionnel fiable principal, un framing privé borné `u32` big-endian + payload et l'adaptation `RealtimeConnection`/sender/receiver, avec round-trip binaire ordonné et fermeture logique de base. Robustesse avancée, navigateur/WASM, smoke public et datagrams 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 la première frontière native couvre TLS/pinning et établissement de session. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test ; le smoke WebTransport public est planifié après le chemin fiable. 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 désormais TLS/pinning, établissement de session et stream fiable principal adapté au contrat commun. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test ; le smoke WebTransport public est planifié après la tranche de robustesse. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`. diff --git a/crates/common/game-realtime-webtransport-lib/README.md b/crates/common/game-realtime-webtransport-lib/README.md index 5010492..ddbb0a8 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 @@ -9,13 +9,18 @@ Backend WebTransport/QUIC candidat pour le realtime de `games.sasedev`. La crate possède le transport WebTransport concret sans introduire de sémantique gameplay, room, joueur, tick ou snapshot. Son chemin natif repose sur `web-transport-quinn` et conserve les erreurs publiques dans `game-realtime-transport-lib`. -La première frontière disponible couvre : +La frontière native disponible couvre désormais : - configuration client HTTPS avec pin SHA-256 exact ; - identité serveur X.509 DER + clé privée PKCS#8 DER injectables ; - génération locale d'une identité self-signed ECDSA P-256 à validité courte pour `localhost`, IPv4 loopback et IPv6 loopback ; - bind UDP/QUIC sur adresse explicite ou port éphémère ; - établissement HTTP/3 WebTransport client/server natif ; +- sélection d'un unique stream bidirectionnel fiable comme chemin realtime principal ; +- framing privé `u32` big-endian + payload binaire ; +- borne POC de 1 MiB vérifiée avant allocation côté réception et avant écriture côté émission ; +- adaptation `RealtimeConnection` / `RealtimeSender` / `RealtimeReceiver` ; +- fermeture propre du chemin logique par FIN du stream primaire ; - tracing sous `games::realtime::webtransport`. ## TLS de développement @@ -26,10 +31,42 @@ Une identité préexistante peut être injectée en DER avec `WebTransportServer Aucune option de désactivation globale de la vérification TLS n'est exposée. +## Stream fiable principal + +Une session WebTransport établie n'est pas encore le contrat realtime lui-même. Le client appelle `WebTransportSession::open_primary_connection()` ; le serveur appelle `WebTransportSession::accept_primary_connection()`. + +Le chemin logique devient ensuite : + +```text +one WebTransport session + -> one primary bidirectional reliable stream + -> u32 big-endian payload length + -> payload bytes +``` + +`open_primary_connection()` écrit déjà l'en-tête de stream WebTransport requis par HTTP/3 avant de retourner. Le pair peut donc terminer `accept_primary_connection()` avant l'envoi de la première frame applicative ; aucun préambule propre à games.sasedev n'est nécessaire. + +`RealtimeConnection::split()` conserve la session WebTransport dans les deux moitiés afin que la session ne soit pas fermée au moment où l'objet connexion est consommé. + +## Limite de frame POC + +La borne actuelle du framing fiable est volontairement interne à cette tranche : 1 MiB par message. Elle empêche une longueur `u32` hostile de provoquer une allocation arbitraire et rejette aussi l'émission hors limite avec `TransportErrorKind::MessageTooLarge`. + +Cette valeur n'est pas encore une configuration produit. La tranche de robustesse suivante doit décider si la limite devient configurable avec les deadlines, la backpressure, les resets, l'abort/cancellation et le mapping d'erreurs détaillé. + ## Frontières actuelles -Cette crate n'adapte pas encore une session vers `RealtimeConnection`. Elle n'ouvre pas encore le stream bidirectionnel applicatif principal et ne définit donc ni framing message, ni limites de payload, ni deadlines applicatives, ni datagram API commune. +La crate ne possède toujours pas : -Ces responsabilités doivent rester séparées de la seule preuve d'établissement natif afin que l'introduction de QUIC/TLS demeure testable indépendamment du futur framing fiable. +- d'API datagram transport-neutral ; +- de deadlines applicatives WebTransport ; +- de politique de backpressure explicite ; +- de reset/abort/cancellation produit ; +- de mapping fin de toutes les erreurs Quinn/WebTransport ; +- de chemin navigateur/WASM ; +- de smoke executable public WebTransport ; +- de benchmark WebSocket/WebTransport. -Le chemin natif s’exécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Le chemin navigateur/WASM est distinct : aucun `cfg` WASM ni dépendance navigateur n'est requis par le backend natif actuel. +Ces responsabilités restent réservées aux tranches suivantes du plan `0.3.5`. + +Le chemin natif s'exécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Le chemin navigateur/WASM est distinct : aucun `cfg` WASM ni dépendance navigateur n'est requis par le backend natif actuel. diff --git a/crates/common/game-realtime-webtransport-lib/USAGE.md b/crates/common/game-realtime-webtransport-lib/USAGE.md new file mode 100644 index 0000000..0a568cd --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/USAGE.md @@ -0,0 +1,89 @@ + + + +# Utilisation de game-realtime-webtransport-lib + +Ce guide décrit le chemin natif fiable actuellement exposé par `game-realtime-webtransport-lib`. Il ne décrit ni gameplay, ni protocole wire métier, ni datagrams. + +## Serveur natif + +Créer d'abord l'identité TLS et le listener : + +```rust +let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() { + Ok(value) => value, + Err(error) => return Err(error), +}; +let config = game_realtime_webtransport_lib::WebTransportServerConfig::new( + std::net::SocketAddr::from(([127, 0, 0, 1], 4433)), + identity, +); +let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(config) { + Ok(value) => value, + Err(error) => return Err(error), +}; +let session = match listener.accept().await { + Ok(value) => value, + Err(error) => return Err(error), +}; +let connection = match session.accept_primary_connection().await { + Ok(value) => value, + Err(error) => return Err(error), +}; +``` + +`open_primary_connection()` écrit l'en-tête WebTransport requis pour identifier le stream avant de retourner. Le serveur peut donc attendre `accept_primary_connection()` puis commencer les échanges applicatifs ; aucune frame artificielle n'est nécessaire pour rendre le stream visible. + +## Client natif + +Le client doit connaître le SHA-256 exact du certificat serveur : + +```rust +let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new( + "https://127.0.0.1:4433/game", + certificate_hash, +) { + Ok(value) => value, + Err(error) => return Err(error), +}; +let session = match game_realtime_webtransport_lib::connect(&config).await { + Ok(value) => value, + Err(error) => return Err(error), +}; +let connection = match session.open_primary_connection().await { + Ok(value) => value, + Err(error) => return Err(error), +}; +``` + +Le pinning est obligatoire dans cette API native ; il n'existe pas de variante qui désactive globalement la vérification TLS. + +## Contrat realtime + +Une fois le stream primaire sélectionné, utiliser uniquement les traits de `game-realtime-transport-lib` : + +```rust +let (mut sender, mut receiver) = game_realtime_transport_lib::RealtimeConnection::split(connection); + +let message = game_realtime_transport_lib::TransportMessage::new(vec![1, 2, 3, 4]); +if let Err(error) = game_realtime_transport_lib::RealtimeSender::send(&mut sender, message).await { + return Err(error); +} + +let received = match game_realtime_transport_lib::RealtimeReceiver::receive(&mut receiver).await { + Ok(value) => value, + Err(error) => return Err(error), +}; + +if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut sender).await { + return Err(error); +} +``` + +Le backend encode chaque `TransportMessage` sous la forme `u32` big-endian + payload. Le consommateur ne doit pas reproduire ce framing lui-même. + +## Fermeture actuelle + +`RealtimeSender::close()` termine proprement la direction d'émission du stream primaire. Le pair observe ensuite `TransportReceive::Closed` lorsqu'il atteint le FIN après les messages déjà écrits. + +La fermeture de session complète, les resets, les aborts, les timeouts et les cas d'annulation appartiennent à la tranche de robustesse suivante et ne doivent pas être simulés par le consommateur. diff --git a/crates/common/game-realtime-webtransport-lib/src/lib.rs b/crates/common/game-realtime-webtransport-lib/src/lib.rs index d4bf048..988f675 100644 --- a/crates/common/game-realtime-webtransport-lib/src/lib.rs +++ b/crates/common/game-realtime-webtransport-lib/src/lib.rs @@ -1,25 +1,31 @@ // file: crates/common/game-realtime-webtransport-lib/src/lib.rs -// version: 1 +// version: 2 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] -//! Native Quinn-backed WebTransport establishment for the games.sasedev realtime transport POC. +//! Native WebTransport/QUIC backend candidate for the transport-neutral realtime contract. mod webtransport; -/// Re-export of one SHA-256 certificate fingerprint accepted by the native WebTransport client. +/// Re-export of the pinned SHA-256 certificate fingerprint used by the native client. pub use self::webtransport::WebTransportCertificateHash; -/// Re-export of native client establishment configuration. +/// Re-export of native WebTransport client configuration. pub use self::webtransport::WebTransportClientConfig; -/// Re-export of a bound native WebTransport server listener. +/// Re-export of an established WebTransport connection adapted to the transport-neutral realtime contract. +pub use self::webtransport::WebTransportConnection; +/// Re-export of the bound native WebTransport listener. pub use self::webtransport::WebTransportListener; -/// Re-export of native WebTransport server establishment configuration. +/// Re-export of the receive half of the primary reliable WebTransport stream. +pub use self::webtransport::WebTransportReceiver; +/// Re-export of the send half of the primary reliable WebTransport stream. +pub use self::webtransport::WebTransportSender; +/// Re-export of native WebTransport server configuration. pub use self::webtransport::WebTransportServerConfig; -/// Re-export of a generated or injected WebTransport server identity. +/// Re-export of native WebTransport server TLS identity material. pub use self::webtransport::WebTransportServerIdentity; /// Re-export of an established native WebTransport session. pub use self::webtransport::WebTransportSession; -/// Re-export of the native WebTransport client session constructor. +/// Re-export of the native WebTransport client establishment function. pub use self::webtransport::connect; diff --git a/crates/common/game-realtime-webtransport-lib/src/webtransport.rs b/crates/common/game-realtime-webtransport-lib/src/webtransport.rs index 04144ad..b6d7ae9 100644 --- a/crates/common/game-realtime-webtransport-lib/src/webtransport.rs +++ b/crates/common/game-realtime-webtransport-lib/src/webtransport.rs @@ -1,9 +1,11 @@ // file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs -// version: 1 +// version: 2 const CERTIFICATE_HASH_SIZE: usize = 32; const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60); const LOCAL_CERTIFICATE_VALIDITY: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60); +const PRIMARY_FRAME_HEADER_SIZE: usize = 4; +const PRIMARY_FRAME_MAX_PAYLOAD_SIZE: usize = 1024 * 1024; const TRACING_TARGET: &str = "games::realtime::webtransport"; /// SHA-256 fingerprint of one certificate accepted by the native WebTransport client. @@ -151,7 +153,7 @@ impl WebTransportServerConfig { } } -/// Established native WebTransport session before application-stream adaptation. +/// Established native WebTransport session before or while the single primary application stream is selected. pub struct WebTransportSession { inner: web_transport_quinn::Session, } @@ -161,6 +163,37 @@ impl WebTransportSession { return Self { inner }; } + /// Accepts the peer-created primary bidirectional stream and adapts it to the transport-neutral realtime contract. + /// + /// The native WebTransport wrapper writes the required stream/session header while opening the stream, so the peer can + /// accept it before the first application frame is sent. + pub async fn accept_primary_connection(self) -> Result { + let (sender, receiver) = match self.inner.accept_bi().await { + Ok(value) => value, + Err(error) => { + let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string()); + tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary bidirectional stream accept failed"); + return Err(mapped); + }, + }; + tracing::debug!(target: TRACING_TARGET, peer = %self.inner.remote_address(), "WebTransport primary bidirectional stream accepted"); + return Ok(WebTransportConnection::new(self.inner, sender, receiver)); + } + + /// Opens the single primary bidirectional stream and adapts it to the transport-neutral realtime contract. + pub async fn open_primary_connection(self) -> Result { + let (sender, receiver) = match self.inner.open_bi().await { + Ok(value) => value, + Err(error) => { + let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string()); + tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary bidirectional stream open failed"); + return Err(mapped); + }, + }; + tracing::debug!(target: TRACING_TARGET, peer = %self.inner.remote_address(), "WebTransport primary bidirectional stream opened"); + return Ok(WebTransportConnection::new(self.inner, sender, receiver)); + } + /// Returns the remote UDP socket address backing the established QUIC connection. #[must_use] pub fn remote_addr(&self) -> std::net::SocketAddr { @@ -177,6 +210,121 @@ impl WebTransportSession { } } +/// Established WebTransport realtime connection carried by one primary reliable bidirectional stream. +pub struct WebTransportConnection { + receiver: web_transport_quinn::RecvStream, + sender: web_transport_quinn::SendStream, + session: web_transport_quinn::Session, +} + +impl WebTransportConnection { + fn new(session: web_transport_quinn::Session, sender: web_transport_quinn::SendStream, receiver: web_transport_quinn::RecvStream) -> Self { + return Self { receiver, sender, session }; + } +} + +impl game_realtime_transport_lib::RealtimeConnection for WebTransportConnection { + type Receiver = crate::WebTransportReceiver; + type Sender = crate::WebTransportSender; + + fn split(self) -> (Self::Sender, Self::Receiver) { + let receiver_session = self.session.clone(); + return ( + crate::WebTransportSender { inner: self.sender, _session: self.session }, + crate::WebTransportReceiver { inner: self.receiver, _session: receiver_session }, + ); + } +} + +/// Receive half of the primary reliable WebTransport stream. +pub struct WebTransportReceiver { + inner: web_transport_quinn::RecvStream, + _session: web_transport_quinn::Session, +} + +impl game_realtime_transport_lib::RealtimeReceiver for WebTransportReceiver { + type ReceiveFuture<'a> + = std::pin::Pin< + Box> + 'a>, + > + where + Self: 'a; + + fn receive(&mut self) -> Self::ReceiveFuture<'_> { + return Box::pin(async move { + let payload_len = match read_frame_payload_len(&mut self.inner).await { + Ok(Some(value)) => value, + Ok(None) => { + tracing::debug!(target: TRACING_TARGET, "remote WebTransport primary stream closed cleanly"); + return Ok(game_realtime_transport_lib::TransportReceive::Closed); + }, + Err(error) => return Err(error), + }; + let payload = match read_frame_payload(&mut self.inner, payload_len).await { + Ok(value) => value, + Err(error) => return Err(error), + }; + tracing::trace!(target: TRACING_TARGET, payload_len = payload.len(), "framed WebTransport payload received"); + return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(payload))); + }); + } +} + +/// Send half of the primary reliable WebTransport stream. +pub struct WebTransportSender { + inner: web_transport_quinn::SendStream, + _session: web_transport_quinn::Session, +} + +impl game_realtime_transport_lib::RealtimeSender for WebTransportSender { + type CloseFuture<'a> + = std::pin::Pin> + 'a>> + where + Self: 'a; + type SendFuture<'a> + = std::pin::Pin> + 'a>> + where + Self: 'a; + + fn close(&mut self) -> Self::CloseFuture<'_> { + return Box::pin(async move { + return match self.inner.finish() { + Ok(()) => { + tracing::debug!(target: TRACING_TARGET, "local WebTransport primary stream close initiated"); + Ok(()) + }, + Err(error) => { + let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string()); + tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary stream close failed"); + Err(mapped) + }, + }; + }); + } + + fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> { + return Box::pin(async move { + let payload_len = message.len(); + let frame_header = match frame_header(payload_len) { + Ok(value) => value, + Err(error) => return Err(error), + }; + if let Err(error) = self.inner.write_all(&frame_header).await { + let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string()); + tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, detail = mapped.detail(), "WebTransport frame header send failed"); + return Err(mapped); + } + if let Err(error) = self.inner.write_all(message.as_bytes()).await { + let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string()); + tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, detail = mapped.detail(), "WebTransport frame payload send failed"); + return Err(mapped); + } + tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "framed WebTransport payload sent"); + return Ok(()); + }); + } +} + /// Bound native WebTransport server endpoint that accepts HTTP/3 WebTransport sessions. pub struct WebTransportListener { server: web_transport_quinn::Server, @@ -270,10 +418,75 @@ fn certificate_hash(certificate_der: &[u8]) -> Result Result<[u8; PRIMARY_FRAME_HEADER_SIZE], game_realtime_transport_lib::TransportError> { + if payload_len > PRIMARY_FRAME_MAX_PAYLOAD_SIZE { + return Err(message_too_large(payload_len)); + } + let payload_len = match u32::try_from(payload_len) { + Ok(value) => value, + Err(_) => return Err(message_too_large(payload_len)), + }; + return Ok(payload_len.to_be_bytes()); +} + fn invalid_configuration(detail: impl Into) -> game_realtime_transport_lib::TransportError { return transport_error(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail); } +fn message_too_large(payload_len: usize) -> game_realtime_transport_lib::TransportError { + return transport_error( + game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, + format!("WebTransport primary frame payload size {payload_len} exceeds {PRIMARY_FRAME_MAX_PAYLOAD_SIZE} bytes"), + ); +} + +async fn read_frame_payload(stream: &mut web_transport_quinn::RecvStream, payload_len: usize) -> Result, game_realtime_transport_lib::TransportError> { + let mut payload = vec![0_u8; payload_len]; + let mut offset = 0_usize; + while offset < payload_len { + let read = match stream.read(&mut payload[offset..]).await { + Ok(Some(value)) => value, + Ok(None) => return Err(protocol_error("WebTransport primary stream closed in the middle of a frame payload")), + Err(error) => return Err(protocol_error(error.to_string())), + }; + if read == 0 { + return Err(protocol_error("WebTransport primary stream returned an empty read in the middle of a frame payload")); + } + offset += read; + } + return Ok(payload); +} + +async fn read_frame_payload_len(stream: &mut web_transport_quinn::RecvStream) -> Result, game_realtime_transport_lib::TransportError> { + let mut header = [0_u8; PRIMARY_FRAME_HEADER_SIZE]; + let mut offset = 0_usize; + while offset < PRIMARY_FRAME_HEADER_SIZE { + let read = match stream.read(&mut header[offset..]).await { + Ok(Some(value)) => value, + Ok(None) => { + if offset == 0 { + return Ok(None); + } + return Err(protocol_error("WebTransport primary stream closed in the middle of a frame header")); + }, + Err(error) => return Err(protocol_error(error.to_string())), + }; + if read == 0 { + return Err(protocol_error("WebTransport primary stream returned an empty read in the middle of a frame header")); + } + offset += read; + } + let payload_len = u32::from_be_bytes(header) as usize; + if payload_len > PRIMARY_FRAME_MAX_PAYLOAD_SIZE { + return Err(message_too_large(payload_len)); + } + return Ok(Some(payload_len)); +} + +fn protocol_error(detail: impl Into) -> game_realtime_transport_lib::TransportError { + return transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, detail); +} + fn transport_error(kind: game_realtime_transport_lib::TransportErrorKind, detail: impl Into) -> game_realtime_transport_lib::TransportError { return game_realtime_transport_lib::TransportError::new(kind, detail); } diff --git a/crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs b/crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs new file mode 100644 index 0000000..b002db1 --- /dev/null +++ b/crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs @@ -0,0 +1,89 @@ +// file: crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs +// version: 1 + +//! Deterministic loopback proof for the primary reliable WebTransport stream and transport-neutral framing contract. + +const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +#[tokio::test(flavor = "current_thread")] +async fn primary_stream_round_trip_is_binary_ordered_and_closes_cleanly() { + let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() { + Ok(value) => value, + Err(error) => panic!("loopback identity generation failed: {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 mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) { + Ok(value) => value, + Err(error) => panic!("WebTransport listener bind failed: {error}"), + }; + let endpoint = format!("https://{}/realtime", listener.local_addr()); + let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) { + Ok(value) => value, + Err(error) => panic!("WebTransport client configuration failed: {error}"), + }; + let sessions = tokio::time::timeout(TEST_TIMEOUT, async { + return tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config)); + }) + .await; + let (server_session, client_session) = match sessions { + Ok((Ok(server), Ok(client))) => (server, client), + Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"), + Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"), + Err(_) => panic!("WebTransport loopback establishment timed out"), + }; + let client_connection = match client_session.open_primary_connection().await { + Ok(value) => value, + Err(error) => panic!("client primary stream open failed: {error}"), + }; + let (mut client_sender, mut client_receiver) = game_realtime_transport_lib::RealtimeConnection::split(client_connection); + let server_connection = match tokio::time::timeout(TEST_TIMEOUT, server_session.accept_primary_connection()).await { + Ok(Ok(connection)) => connection, + Ok(Err(error)) => panic!("server primary stream accept failed: {error}"), + Err(_) => panic!("server primary stream accept timed out"), + }; + let (mut server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection); + let first_payload = Vec::new(); + send_payload(&mut client_sender, first_payload.clone(), "client first send").await; + assert_received_payload(&mut server_receiver, first_payload.clone(), "server first receive").await; + send_payload(&mut server_sender, first_payload.clone(), "server first echo").await; + assert_received_payload(&mut client_receiver, first_payload, "client first echo receive").await; + let remaining_payloads = [vec![0x00, 0x7f, 0x80, 0xff], b"third-message".to_vec()]; + for payload in remaining_payloads { + send_payload(&mut client_sender, payload.clone(), "client ordered send").await; + assert_received_payload(&mut server_receiver, payload.clone(), "server ordered receive").await; + send_payload(&mut server_sender, payload.clone(), "server ordered echo").await; + assert_received_payload(&mut client_receiver, payload, "client ordered echo receive").await; + } + if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut client_sender).await { + panic!("client sender close failed: {error}"); + } + assert_closed(&mut server_receiver, "server remote close").await; + if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut server_sender).await { + panic!("server sender close failed: {error}"); + } + assert_closed(&mut client_receiver, "client remote close").await; +} + +async fn assert_closed(receiver: &mut game_realtime_webtransport_lib::WebTransportReceiver, label: &str) { + let received = match game_realtime_transport_lib::RealtimeReceiver::receive(receiver).await { + Ok(value) => value, + Err(error) => panic!("{label} failed: {error}"), + }; + assert_eq!(received, game_realtime_transport_lib::TransportReceive::Closed); +} + +async fn assert_received_payload(receiver: &mut game_realtime_webtransport_lib::WebTransportReceiver, payload: Vec, label: &str) { + let received = match game_realtime_transport_lib::RealtimeReceiver::receive(receiver).await { + Ok(value) => value, + Err(error) => panic!("{label} failed: {error}"), + }; + assert_eq!(received, game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(payload))); +} + +async fn send_payload(sender: &mut game_realtime_webtransport_lib::WebTransportSender, payload: Vec, label: &str) { + let message = game_realtime_transport_lib::TransportMessage::new(payload); + if let Err(error) = game_realtime_transport_lib::RealtimeSender::send(sender, message).await { + panic!("{label} failed: {error}"); + } +} diff --git a/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs b/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs index 04cf81f..1a85b5e 100644 --- a/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs +++ b/crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs @@ -1,5 +1,5 @@ // file: crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs -// version: 1 +// version: 2 #[test] fn certificate_hash_preserves_exact_sha256_bytes() { @@ -25,6 +25,29 @@ fn client_config_accepts_https_and_rejects_non_secure_schemes() { } } +#[test] +fn frame_header_is_big_endian_and_payload_bound_is_enforced() { + let header = match super::frame_header(0x00_01_02_03) { + Ok(value) => value, + Err(error) => panic!("valid frame header rejected: {error}"), + }; + assert_eq!(header, [0x00, 0x01, 0x02, 0x03]); + let oversized = super::frame_header(super::PRIMARY_FRAME_MAX_PAYLOAD_SIZE + 1); + match oversized { + Ok(_) => panic!("oversized WebTransport frame unexpectedly accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge), + } +} + +#[test] +fn generated_loopback_identity_has_sha256_fingerprint() { + let identity = match super::WebTransportServerIdentity::generate_loopback() { + Ok(value) => value, + Err(error) => panic!("loopback identity generation failed: {error}"), + }; + assert_eq!(identity.certificate_hash().as_bytes().len(), super::CERTIFICATE_HASH_SIZE); +} + #[test] fn injected_identity_rejects_empty_certificate_or_key() { let missing_certificate = super::WebTransportServerIdentity::from_pkcs8_der(Vec::new(), vec![1]); @@ -38,12 +61,3 @@ fn injected_identity_rejects_empty_certificate_or_key() { Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration), } } - -#[test] -fn generated_loopback_identity_has_sha256_fingerprint() { - let identity = match super::WebTransportServerIdentity::generate_loopback() { - Ok(value) => value, - Err(error) => panic!("loopback identity generation failed: {error}"), - }; - assert_eq!(identity.certificate_hash().as_bytes().len(), super::CERTIFICATE_HASH_SIZE); -} diff --git a/deltas/0.3.5/alpha.3.md b/deltas/0.3.5/alpha.3.md new file mode 100644 index 0000000..caf9c87 --- /dev/null +++ b/deltas/0.3.5/alpha.3.md @@ -0,0 +1,184 @@ + + + +# Delta 0.3.5-alpha.3 + +## Base requise + +`0.3.5-alpha.2.fix.1`, validée par l'utilisateur le 2026-09-21 avec fmt, audits, check workspace, Clippy strict et les six tests de `game-realtime-webtransport-lib` entièrement verts. + +La validation réellement fournie est conservée dans `history/0.3.5/alpha.2.fix.1.md`. + +## Objectif + +Fermer la première adaptation fiable WebTransport vers `game-realtime-transport-lib` sans introduire encore la robustesse/lifecycle avancés, le navigateur/WASM, les datagrams ou le smoke public. + +La version passe à : + +```text +0.3.5-alpha.3 +``` + +## Stream primaire + +Une `WebTransportSession` peut désormais être consommée de deux façons : + +```text +client -> open_primary_connection() +server -> accept_primary_connection() +``` + +Chaque chemin sélectionne exactement un stream bidirectionnel fiable et retourne `WebTransportConnection`. + +Le backend `web-transport-quinn` écrit lui-même l'en-tête WebTransport nécessaire pendant `open_bi()`. Le serveur peut donc accepter le stream avant toute frame applicative. Aucun préambule games.sasedev supplémentaire n'est ajouté : après l'en-tête protocolaire géré par la dépendance, le premier octet applicatif appartient directement au framing prévu. + +## Framing fiable privé + +Le framing du stream primaire est : + +```text +u32 big-endian payload length +payload bytes +``` + +Il reste privé à `game-realtime-webtransport-lib` et n'est pas un wire codec gameplay. + +La borne POC actuelle est de 1 MiB par message. Elle est vérifiée : + +- avant écriture côté sender ; +- immédiatement après décodage des quatre octets de longueur et avant toute allocation côté receiver. + +Une longueur hors limite produit `TransportErrorKind::MessageTooLarge`. + +La limite n'est pas encore exposée comme configuration produit. `alpha.4` possède cette décision avec les autres limites et deadlines. + +## Contrat commun + +`WebTransportConnection` implémente `RealtimeConnection` et produit : + +```text +WebTransportSender +WebTransportReceiver +``` + +Les deux moitiés conservent chacune une référence à la session WebTransport. Le `split()` ne ferme donc pas accidentellement la session au moment où l'objet connexion est consommé. + +`WebTransportSender::send(...)` écrit le header puis le payload sur le stream fiable et respecte la backpressure naturelle de QUIC pendant les écritures asynchrones. + +`WebTransportReceiver::receive(...)` reconstruit exactement un `TransportMessage`, y compris les payloads vides et binaires non UTF-8. + +`RealtimeSender::close()` termine proprement la direction locale du stream primaire. Lorsque le pair a consommé les messages précédents puis atteint le FIN, `RealtimeReceiver::receive()` retourne `TransportReceive::Closed`. + +La fermeture complète de session, reset, abort, cancellation, deadlines et mapping fin des erreurs restent hors de cette tranche. + +## API et documentation + +`src/lib.rs` réexporte les nouveaux types publics conformément aux règles du workspace. + +Le README local est réconcilié avec le stream primaire et ses frontières. Un `USAGE.md` durable est ajouté car l'ordre session -> stream primaire -> `RealtimeConnection` et la contrainte d'accept serveur nécessitent désormais un guide d'utilisation distinct du delta. + +Le plan `005` enregistre les décisions réellement matérialisées sans modifier le forecast de `alpha.4+`. + +`ROADMAP.md` et `CHANGELOG.md` restent inchangés : cette alpha ne modifie ni la mission macro de `0.3.5` ni une livraison stable. + +## Tests + +Le test unitaire du backend ajoute la preuve que : + +- la longueur est encodée en `u32` big-endian ; +- la borne de frame refuse une taille supérieure à 1 MiB avec `MessageTooLarge`. + +Le nouveau test d'intégration `tests/realtime_connection.rs` prouve en loopback : + +- ouverture du stream primaire côté client ; +- ouverture cliente puis accept du stream primaire côté serveur avant la première frame applicative ; +- adaptation `RealtimeConnection` ; +- payload vide ; +- payload binaire non UTF-8 ; +- ordre de plusieurs messages ; +- echo bidirectionnel ; +- FIN client observé comme `TransportReceive::Closed` côté serveur ; +- FIN serveur observé comme `TransportReceive::Closed` côté client. + +Les sessions restent vivantes pendant toute la preuve afin que le test ne confonde pas FIN du stream logique et drop de session. + +## Fichiers modifiés + +```text +Cargo.toml +README.md +crates/common/game-realtime-webtransport-lib/README.md +crates/common/game-realtime-webtransport-lib/src/lib.rs +crates/common/game-realtime-webtransport-lib/src/webtransport.rs +crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs +docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md +``` + +Nouveaux fichiers : + +```text +crates/common/game-realtime-webtransport-lib/USAGE.md +crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs +history/0.3.5/alpha.2.fix.1.md +deltas/0.3.5/alpha.3.md +``` + +Aucune dépendance Cargo n'est ajoutée ou modifiée dans cette tranche. + +## Validation exécutée dans l'environnement de génération + +L'environnement de génération ne possède pas de toolchain Rust. Aucun `cargo fmt`, `cargo check`, Clippy ou test n'est donc attribué à cette livraison. + +Les audits statiques disponibles ont été exécutés sur le candidat final : + +```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), 273 file(s)) +Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent) +``` + +Le compteur de fichiers Markdown correspond à la reconstruction de travail issue de l'archive taggée et des deltas fournis. Il n'est pas utilisé comme invariant contre le checkout utilisateur, qui contient davantage de fichiers suivis localement. + +## Validation utilisateur demandée + +Cette tranche modifie le backend Rust et ajoute un test d'intégration. La gate ciblée est : + +```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-webtransport-lib --all-targets --all-features + +cargo tree -i game-realtime-webtransport-lib --workspace --edges normal +``` + +Le contrat commun est retesté parce que `alpha.3` en devient un nouvel implémenteur, même si sa crate n'est pas modifiée. + +Le `cargo tree` inverse vérifie qu'aucune crate moteur ou gameplay n'a acquis de dépendance vers le backend concret. Les arbres complets `web-transport-quinn` de `alpha.2` ne sont pas répétés puisque le graphe de dépendances n'a pas changé. + +Aucun smoke executable n'est encore attendu : le smoke natif public reste `alpha.5`, après la tranche de robustesse `alpha.4`. + +## Suite après validation + +Si la gate est propre, ouvrir `0.3.5-alpha.4` pour : + +- limites configurables si justifiées ; +- deadlines ; +- backpressure/erreurs observables ; +- close/reset/abort ; +- cancellation/drop ; +- cas négatifs de framing ; +- mapping d'erreurs détaillé ; +- tests de robustesse ciblés. + +Un défaut fermé de cette tranche produit d'abord `0.3.5-alpha.3.fix.N` au lieu d'ouvrir `alpha.4`. 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 7c51e3d..0c8ba31 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` et son correctif de validation `0.3.5-alpha.2.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` et le chemin fiable candidat de `0.3.5-alpha.3`. 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. @@ -75,6 +75,23 @@ L'identité locale générée est ECDSA P-256/SHA-256, contient les SAN `localho La gate de `alpha.2` a confirmé compilation, Clippy, configuration TLS/pinning et rejet d’un mauvais pin, mais le test positif comparait strictement `127.0.0.1:port` à sa représentation IPv4-mapped IPv6 `[::ffff:127.0.0.1]:port` remontée par Quinn. `alpha.2.fix.1` normalise uniquement cette représentation dans le test d’intégration ; aucun contrat, comportement transport ou scope de `alpha.3` n’est modifié. +### Fermeture du chemin fiable en alpha.3 + +`alpha.3` conserve `game-realtime-transport-lib` inchangé et adapte le backend concret à ses traits. Une `WebTransportSession` devient une `WebTransportConnection` après sélection d'un unique stream bidirectionnel primaire : le client l'ouvre, le serveur l'accepte. Le `split()` conserve une copie de la session dans chaque moitié afin que la session QUIC/WebTransport ne soit pas fermée au moment où l'objet connexion est consommé. + +Le framing privé est exactement : + +```text +u32 big-endian payload length +payload bytes +``` + +La borne POC est fixée à 1 MiB par message dans cette tranche. Elle est vérifiée avant écriture et, surtout, avant allocation côté réception. Cette limite reste interne : `alpha.4` décide sa configuration produit avec deadlines, backpressure, reset/abort/cancellation et mapping d'erreurs détaillé. + +Le FIN du stream primaire constitue la fermeture logique de base de `alpha.3` et produit `TransportReceive::Closed` après consommation des messages déjà écrits. La fermeture de session complète et les scénarios de lifecycle avancés restent explicitement réservés à `alpha.4`. + +`web-transport-quinn` écrit l'en-tête WebTransport du stream durant `open_bi()` avant de rendre le stream au code appelant. L'accept serveur peut donc terminer avant la première frame applicative ; le framing games.sasedev commence directement au premier octet applicatif et n'ajoute aucun préambule de visibilité. + ### Ownership physique Nouveau backend durable candidat : @@ -380,13 +397,18 @@ Pas encore d'implémentation complète `RealtimeConnection` si cela rend la tran ### `0.3.5-alpha.3` — stream fiable et contrat commun +Tranche candidate matérialisée avec : + - stream bidirectionnel principal ; -- framing `u32 + payload` borné ; +- framing `u32 + payload` borné avant allocation ; +- borne POC interne de 1 MiB ; - `RealtimeConnection`, sender et receiver ; -- round-trip ordonné multi-message ; -- fermeture distante de base ; +- round-trip ordonné multi-message, y compris payload vide et octets non UTF-8 ; +- fermeture distante de base par FIN du stream primaire ; - tests loopback ciblés ; -- USAGE si l'API/configuration devient non triviale. +- `USAGE.md` pour la séquence session -> stream primaire -> contrat commun. + +La gate utilisateur reste requise avant d'ouvrir `alpha.4`. ### `0.3.5-alpha.4` — robustesse et lifecycle diff --git a/history/0.3.5/alpha.2.fix.1.md b/history/0.3.5/alpha.2.fix.1.md new file mode 100644 index 0000000..131e4ad --- /dev/null +++ b/history/0.3.5/alpha.2.fix.1.md @@ -0,0 +1,59 @@ + + + +# Historique 0.3.5-alpha.2.fix.1 + +## Statut + +`0.3.5-alpha.2.fix.1` a été validée par l'utilisateur le 2026-09-21. Le correctif ferme l'unique défaut de la gate `alpha.2` : la comparaison d'adresse distante accepte désormais la représentation IPv4-mapped IPv6 remontée par Quinn sans relâcher la comparaison du port ou d'une adresse réellement différente. + +Aucun autre fix n'est requis. La suite peut ouvrir directement `0.3.5-alpha.3` pour le stream bidirectionnel principal, le framing fiable borné et l'adaptation au contrat commun. + +## 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 +``` + +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), 286 file(s)) +Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent) +cargo check --workspace: clean +cargo clippy --workspace --all-targets --all-features -- -D warnings: clean +``` + +Le workspace compilé porte `0.3.5-alpha.2.fix.1`, y compris `game-realtime-webtransport-lib` et les autres targets workspace affichées par la gate. + +## Tests WebTransport + +La commande : + +```bash +cargo test -p game-realtime-webtransport-lib --all-targets --all-features +``` + +est entièrement propre : + +```text +unit tests: 4 passed; 0 failed +establishment integration tests: 2 passed; 0 failed +``` + +Le test positif `pinned_client_and_server_establish_a_loopback_session` passe désormais, tout comme le rejet d'un mauvais pin. La preuve d'établissement natif TLS/WebTransport de `alpha.2` est donc fermée. + +## Conséquence + +Le forecast reste inchangé fonctionnellement : `alpha.3` peut introduire un unique stream bidirectionnel fiable principal, le framing privé `u32` big-endian + payload borné avant allocation, `RealtimeConnection`/sender/receiver, le round-trip multi-message ordonné et la fermeture distante de base.