From 4fb521f54e46b8861e14542544bcea08b406d4a8 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Mon, 21 Sep 2026 17:40:56 +0200 Subject: [PATCH] 0.3.4-alpha.3 --- Cargo.toml | 8 +- README.md | 6 +- .../game-realtime-websocket-lib/Cargo.toml | 24 ++ .../game-realtime-websocket-lib/src/lib.rs | 21 ++ .../src/websocket.rs | 300 ++++++++++++++++++ .../tests/loopback.rs | 63 ++++ deltas/0.3.4/alpha.3.md | 144 +++++++++ ...0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md | 31 +- history/0.3.4/alpha.2.md | 48 +++ 9 files changed, 636 insertions(+), 9 deletions(-) create mode 100644 crates/common/game-realtime-websocket-lib/Cargo.toml create mode 100644 crates/common/game-realtime-websocket-lib/src/lib.rs create mode 100644 crates/common/game-realtime-websocket-lib/src/websocket.rs create mode 100644 crates/common/game-realtime-websocket-lib/tests/loopback.rs create mode 100644 deltas/0.3.4/alpha.3.md create mode 100644 history/0.3.4/alpha.2.md diff --git a/Cargo.toml b/Cargo.toml index dd7ae7d..e6e9e27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 87 +# version: 88 [workspace] resolver = "3" @@ -14,6 +14,7 @@ members = [ "crates/common/game-assets-lib", "crates/common/game-logging-lib", "crates/common/game-realtime-transport-lib", + "crates/common/game-realtime-websocket-lib", "crates/apps/game-android-entrypoint", "crates/apps/game-reflex-poc-tauri", "crates/apps/game-reflex-poc-wasm", @@ -22,7 +23,7 @@ members = [ ] [workspace.package] -version = "0.3.4-alpha.2" +version = "0.3.4-alpha.3" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/games" @@ -30,6 +31,7 @@ authors = ["Sasedev "] publish = false [workspace.dependencies] +futures-util = "0.3.34" serde = { version = "1", features = ["derive"] } sdl3 = "^0.20" tracing = "0.1.44" @@ -39,6 +41,8 @@ tracing-subscriber = "0.3.23" tauri = "2" tauri-build = "2" tauri-plugin-tracing = "^0.3" +tokio = "1.53.1" +tokio-tungstenite = "0.30.0" wasm-bindgen = "0.2" [workspace.lints.rust] diff --git a/README.md b/README.md index 0db83c2..e31cdab 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.3`. -Version candidate active : `0.3.4-alpha.2`. `0.3.2` reste différée. +Version candidate active : `0.3.4-alpha.3`. `0.3.2` reste différée. -La stable `0.3.3` livre la voie Android SDL3 native multi-ABI : build Gradle/Cargo sans orchestrateur Python, APK Debug universal et AAB Release pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, `minSdk 21` réellement fumé et compatibilité pages mémoire 16 KB validée sur les ABI 64 bits. `0.3.4-alpha.2` introduit la première API transport-neutral realtime : messages binaires opaques, fermeture distante explicite, erreurs catégorisées et split statique sender/receiver, sans dépendance Tokio ou WebSocket. +La stable `0.3.3` livre la voie Android SDL3 native multi-ABI : build Gradle/Cargo sans orchestrateur Python, APK Debug universal et AAB Release pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, `minSdk 21` réellement fumé et compatibilité pages mémoire 16 KB validée sur les ABI 64 bits. `0.3.4-alpha.3` conserve l'API transport-neutral de `alpha.2` et ajoute un backend WebSocket séparé fondé sur Tokio + `tokio-tungstenite`, avec client `ws://`, listener serveur, split sender/receiver, tracing et round-trip localhost déterministe. 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. diff --git a/crates/common/game-realtime-websocket-lib/Cargo.toml b/crates/common/game-realtime-websocket-lib/Cargo.toml new file mode 100644 index 0000000..34bc3eb --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/Cargo.toml @@ -0,0 +1,24 @@ +# file: crates/common/game-realtime-websocket-lib/Cargo.toml +# version: 1 + +[package] +name = "game-realtime-websocket-lib" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +futures-util = { workspace = true, default-features = false, features = ["sink", "std"] } +game-realtime-transport-lib = { path = "../game-realtime-transport-lib" } +tokio = { workspace = true, features = ["net"] } +tokio-tungstenite = { workspace = true, default-features = false, features = ["connect", "handshake"] } +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "time"] } + +[lints] +workspace = true diff --git a/crates/common/game-realtime-websocket-lib/src/lib.rs b/crates/common/game-realtime-websocket-lib/src/lib.rs new file mode 100644 index 0000000..9c763ad --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/src/lib.rs @@ -0,0 +1,21 @@ +// file: crates/common/game-realtime-websocket-lib/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Tokio/tokio-tungstenite WebSocket backend for the games.sasedev realtime transport contract. + +mod websocket; + +/// Re-export of the WebSocket client connection constructor. +pub use self::websocket::connect; +/// Re-export of an established WebSocket transport connection. +pub use self::websocket::WebSocketConnection; +/// Re-export of the receive half of an established WebSocket connection. +pub use self::websocket::WebSocketReceiver; +/// Re-export of a bound WebSocket server listener. +pub use self::websocket::WebSocketListener; +/// Re-export of the send half of an established WebSocket connection. +pub use self::websocket::WebSocketSender; diff --git a/crates/common/game-realtime-websocket-lib/src/websocket.rs b/crates/common/game-realtime-websocket-lib/src/websocket.rs new file mode 100644 index 0000000..5ce96a5 --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/src/websocket.rs @@ -0,0 +1,300 @@ +// file: crates/common/game-realtime-websocket-lib/src/websocket.rs +// version: 1 + +use futures_util::SinkExt; // rust-rules: trait-import +use futures_util::StreamExt; // rust-rules: trait-import + +const TRACING_TARGET: &str = "games::realtime::websocket"; + +type ClientStream = tokio_tungstenite::WebSocketStream>; +type ServerStream = tokio_tungstenite::WebSocketStream; +type ClientSink = futures_util::stream::SplitSink; +type ServerSink = futures_util::stream::SplitSink; +type ClientReceiver = futures_util::stream::SplitStream; +type ServerReceiver = futures_util::stream::SplitStream; + +enum WebSocketStreamKind { + Client(ClientStream), + Server(ServerStream), +} + +enum WebSocketSinkKind { + Client(ClientSink), + Server(ServerSink), +} + +enum WebSocketReceiverKind { + Client(ClientReceiver), + Server(ServerReceiver), +} + +/// Established WebSocket connection implementing the transport-neutral realtime contract. +pub struct WebSocketConnection { + inner: WebSocketStreamKind, +} + +impl WebSocketConnection { + fn from_client(stream: ClientStream) -> Self { + return Self { inner: WebSocketStreamKind::Client(stream) }; + } + + fn from_server(stream: ServerStream) -> Self { + return Self { inner: WebSocketStreamKind::Server(stream) }; + } +} + +impl game_realtime_transport_lib::RealtimeConnection for WebSocketConnection { + type Sender = crate::WebSocketSender; + type Receiver = crate::WebSocketReceiver; + + fn split(self) -> (Self::Sender, Self::Receiver) { + return match self.inner { + WebSocketStreamKind::Client(stream) => { + let (sender, receiver) = stream.split(); + ( + crate::WebSocketSender { inner: WebSocketSinkKind::Client(sender) }, + crate::WebSocketReceiver { inner: WebSocketReceiverKind::Client(receiver) }, + ) + }, + WebSocketStreamKind::Server(stream) => { + let (sender, receiver) = stream.split(); + ( + crate::WebSocketSender { inner: WebSocketSinkKind::Server(sender) }, + crate::WebSocketReceiver { inner: WebSocketReceiverKind::Server(receiver) }, + ) + }, + }; + } +} + +/// Send half of an established WebSocket transport connection. +pub struct WebSocketSender { + inner: WebSocketSinkKind, +} + +impl game_realtime_transport_lib::RealtimeSender for WebSocketSender { + type SendFuture<'a> = futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>> where Self: 'a; + type CloseFuture<'a> = futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>> where Self: 'a; + + fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> { + return Box::pin(async move { + let payload_len = message.len(); + let websocket_message = tokio_tungstenite::tungstenite::Message::Binary(message.into_bytes().into()); + let result = match &mut self.inner { + WebSocketSinkKind::Client(sender) => sender.send(websocket_message).await, + WebSocketSinkKind::Server(sender) => sender.send(websocket_message).await, + }; + return match result { + Ok(()) => { + tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "binary WebSocket payload sent"); + Ok(()) + }, + Err(error) => { + let mapped = map_stream_error(error); + tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket send failed"); + Err(mapped) + }, + }; + }); + } + + fn close(&mut self) -> Self::CloseFuture<'_> { + return Box::pin(async move { + let result = match &mut self.inner { + WebSocketSinkKind::Client(sender) => sender.close().await, + WebSocketSinkKind::Server(sender) => sender.close().await, + }; + return match result { + Ok(()) => { + tracing::debug!(target: TRACING_TARGET, "local WebSocket close initiated"); + Ok(()) + }, + Err(error) => { + let mapped = map_stream_error(error); + tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket close failed"); + Err(mapped) + }, + }; + }); + } +} + +/// Receive half of an established WebSocket transport connection. +pub struct WebSocketReceiver { + inner: WebSocketReceiverKind, +} + +impl game_realtime_transport_lib::RealtimeReceiver for WebSocketReceiver { + type ReceiveFuture<'a> = + futures_util::future::LocalBoxFuture<'a, Result> + where + Self: 'a; + + fn receive(&mut self) -> Self::ReceiveFuture<'_> { + return Box::pin(async move { + loop { + let next_message = match &mut self.inner { + WebSocketReceiverKind::Client(receiver) => receiver.next().await, + WebSocketReceiverKind::Server(receiver) => receiver.next().await, + }; + match next_message { + Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(bytes))) => { + tracing::trace!(target: TRACING_TARGET, payload_len = bytes.len(), "binary WebSocket payload received"); + return Ok(game_realtime_transport_lib::TransportReceive::Message( + game_realtime_transport_lib::TransportMessage::new(bytes.to_vec()), + )); + }, + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => { + tracing::debug!(target: TRACING_TARGET, "remote WebSocket close observed"); + return Ok(game_realtime_transport_lib::TransportReceive::Closed); + }, + Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) + | Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => {}, + Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) => { + let error = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Protocol, + "text WebSocket messages are not part of the binary transport contract", + ); + tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unsupported WebSocket text message received"); + return Err(error); + }, + Some(Ok(tokio_tungstenite::tungstenite::Message::Frame(_))) => { + let error = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Protocol, + "unexpected raw WebSocket frame surfaced by the backend", + ); + tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unexpected raw WebSocket frame received"); + return Err(error); + }, + Some(Err(error)) => { + let mapped = map_stream_error(error); + tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket receive failed"); + return Err(mapped); + }, + None => { + tracing::debug!(target: TRACING_TARGET, "WebSocket stream ended"); + return Ok(game_realtime_transport_lib::TransportReceive::Closed); + }, + } + } + }); + } +} + +/// Bound TCP listener that upgrades accepted peers to WebSocket connections. +pub struct WebSocketListener { + listener: tokio::net::TcpListener, + local_addr: std::net::SocketAddr, +} + +impl WebSocketListener { + /// Binds a WebSocket listener to one concrete socket address. + pub async fn bind(address: std::net::SocketAddr) -> Result { + let listener = match tokio::net::TcpListener::bind(address).await { + Ok(value) => value, + Err(error) => { + let mapped = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Bind, + error.to_string(), + ); + tracing::warn!(target: TRACING_TARGET, address = %address, detail = mapped.detail(), "WebSocket listener bind failed"); + return Err(mapped); + }, + }; + let local_addr = match listener.local_addr() { + Ok(value) => value, + Err(error) => { + let mapped = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Bind, + error.to_string(), + ); + tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebSocket listener address lookup failed"); + return Err(mapped); + }, + }; + tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebSocket listener bound"); + return Ok(Self { listener, local_addr }); + } + + /// Returns the concrete local socket address, including an ephemeral port selected by the OS. + #[must_use] + pub fn local_addr(&self) -> std::net::SocketAddr { + return self.local_addr; + } + + /// Accepts one TCP peer and completes the server-side WebSocket handshake. + pub async fn accept(&self) -> Result { + let (stream, peer_addr) = match self.listener.accept().await { + Ok(value) => value, + Err(error) => { + let mapped = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Accept, + error.to_string(), + ); + tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebSocket TCP accept failed"); + return Err(mapped); + }, + }; + let websocket = match tokio_tungstenite::accept_async(stream).await { + Ok(value) => value, + Err(error) => { + let mapped = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Accept, + error.to_string(), + ); + tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, detail = mapped.detail(), "WebSocket server handshake failed"); + return Err(mapped); + }, + }; + tracing::info!(target: TRACING_TARGET, peer = %peer_addr, "WebSocket peer accepted"); + return Ok(crate::WebSocketConnection::from_server(websocket)); + } +} + +/// Connects a client to one plain `ws://` endpoint and completes the WebSocket handshake. +pub async fn connect(endpoint: &str) -> Result { + if !endpoint.starts_with("ws://") { + return Err(game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, + "the baseline WebSocket backend accepts only ws:// endpoints", + )); + } + tracing::debug!(target: TRACING_TARGET, endpoint = endpoint, "connecting WebSocket client"); + let (stream, _) = match tokio_tungstenite::connect_async(endpoint).await { + Ok(value) => value, + Err(error) => { + let mapped = map_connect_error(error); + tracing::warn!( + target: TRACING_TARGET, + endpoint = endpoint, + kind = %mapped.kind(), + detail = mapped.detail(), + "WebSocket client connect failed" + ); + return Err(mapped); + }, + }; + tracing::info!(target: TRACING_TARGET, endpoint = endpoint, "WebSocket client connected"); + return Ok(crate::WebSocketConnection::from_client(stream)); +} + +fn map_connect_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError { + let kind = match &error { + tokio_tungstenite::tungstenite::Error::Url(_) => game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, + _ => game_realtime_transport_lib::TransportErrorKind::Connect, + }; + return game_realtime_transport_lib::TransportError::new(kind, error.to_string()); +} + +fn map_stream_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError { + let kind = match &error { + tokio_tungstenite::tungstenite::Error::ConnectionClosed | tokio_tungstenite::tungstenite::Error::AlreadyClosed => { + game_realtime_transport_lib::TransportErrorKind::Closed + }, + tokio_tungstenite::tungstenite::Error::Io(_) => game_realtime_transport_lib::TransportErrorKind::Io, + tokio_tungstenite::tungstenite::Error::Capacity(_) => game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, + tokio_tungstenite::tungstenite::Error::WriteBufferFull(_) => game_realtime_transport_lib::TransportErrorKind::Backpressure, + _ => game_realtime_transport_lib::TransportErrorKind::Protocol, + }; + return game_realtime_transport_lib::TransportError::new(kind, error.to_string()); +} diff --git a/crates/common/game-realtime-websocket-lib/tests/loopback.rs b/crates/common/game-realtime-websocket-lib/tests/loopback.rs new file mode 100644 index 0000000..d60d445 --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/tests/loopback.rs @@ -0,0 +1,63 @@ +// file: crates/common/game-realtime-websocket-lib/tests/loopback.rs +// version: 1 + +//! Deterministic localhost proof for the Tokio/tokio-tungstenite backend. + +use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import +use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import +use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import + +const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + +#[tokio::test(flavor = "current_thread")] +async fn binary_round_trip_and_clean_close_work_on_loopback() { + let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await { + Ok(value) => value, + Err(error) => panic!("loopback listener bind failed: {error}"), + }; + let endpoint = format!("ws://{}/", listener.local_addr()); + let pair = tokio::time::timeout(TEST_TIMEOUT, async { + return tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint.as_str())); + }) + .await; + let (server_connection, client_connection) = match pair { + Ok((Ok(server), Ok(client))) => (server, client), + Ok((_server, _client)) => panic!("loopback connection establishment failed"), + Err(_) => panic!("loopback connection establishment timed out"), + }; + let (mut server_sender, mut server_receiver) = server_connection.split(); + let (mut client_sender, mut client_receiver) = client_connection.split(); + let client_payload = game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]); + let client_send = tokio::time::timeout(TEST_TIMEOUT, client_sender.send(client_payload)).await; + assert!(matches!(client_send, Ok(Ok(())))); + let server_receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await; + match server_receive { + Ok(Ok(received)) => assert_eq!( + received, + game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255])) + ), + Ok(Err(error)) => panic!("server receive failed: {error}"), + Err(_) => panic!("server receive timed out"), + } + let server_payload = game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6]); + let server_send = tokio::time::timeout(TEST_TIMEOUT, server_sender.send(server_payload)).await; + assert!(matches!(server_send, Ok(Ok(())))); + let client_receive = tokio::time::timeout(TEST_TIMEOUT, client_receiver.receive()).await; + match client_receive { + Ok(Ok(received)) => assert_eq!( + received, + game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6])) + ), + Ok(Err(error)) => panic!("client receive failed: {error}"), + Err(_) => panic!("client receive timed out"), + } + let client_close = tokio::time::timeout(TEST_TIMEOUT, client_sender.close()).await; + assert!(matches!(client_close, Ok(Ok(())))); + let server_close_observation = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await; + match server_close_observation { + Ok(Ok(received)) => assert_eq!(received, game_realtime_transport_lib::TransportReceive::Closed), + Ok(Err(error)) => panic!("server close observation failed: {error}"), + Err(_) => panic!("server close observation timed out"), + }; +} diff --git a/deltas/0.3.4/alpha.3.md b/deltas/0.3.4/alpha.3.md new file mode 100644 index 0000000..b9a092f --- /dev/null +++ b/deltas/0.3.4/alpha.3.md @@ -0,0 +1,144 @@ + + + +# Delta 0.3.4-alpha.3 + +## Mission + +Introduire le premier backend concret de la frontière realtime validée en `alpha.2` : WebSocket async natif avec Tokio + `tokio-tungstenite`, sans TLS, sans protocole session/gameplay et sans dépendance backend dans les moteurs ou jeux. + +## Historique fermé + +`history/0.3.4/alpha.2.md` enregistre la validation utilisateur : audits propres, workspace/Clippy strict propres et `7` tests sur `game-realtime-transport-lib` passés. + +Aucun `alpha.2.fix.N` n'est requis. + +## Version et dépendances + +Le workspace passe à : + +```text +0.3.4-alpha.3 +``` + +Dépendances tierces centralisées au workspace : + +```text +futures-util 0.3.34 +tokio 1.53.1 +tokio-tungstenite 0.30.0 +``` + +`game-realtime-websocket-lib` active uniquement les features dont elle a besoin : + +```text +futures-util: default-features=false, sink, std +tokio: net +tokio-tungstenite: default-features=false, connect, handshake +``` + +Le harness de test ajoute localement les features Tokio `macros`, `rt` et `time`. Aucune feature TLS n'est activée. + +## Nouvelle crate WebSocket + +Ajout : + +```text +crates/common/game-realtime-websocket-lib/Cargo.toml +crates/common/game-realtime-websocket-lib/src/lib.rs +crates/common/game-realtime-websocket-lib/src/websocket.rs +crates/common/game-realtime-websocket-lib/tests/loopback.rs +``` + +La crate implémente le contrat `game-realtime-transport-lib` sans en modifier l'API. + +### Client + +`connect(&str)` établit une connexion WebSocket cliente à partir d'un endpoint `ws://`. Un endpoint hors baseline, notamment `wss://`, est rejeté comme `InvalidConfiguration` au lieu d'activer implicitement un backend TLS. + +### Serveur + +`WebSocketListener::bind(SocketAddr)` bind un `tokio::net::TcpListener`; `127.0.0.1:0` permet au système de choisir un port de test éphémère. `accept()` accepte le TCP puis effectue le handshake WebSocket serveur. + +### Connexion + +Client et serveur retournent le même `WebSocketConnection` public. Les représentations `tokio_tungstenite::WebSocketStream<...>` restent privées. + +`RealtimeConnection::split()` produit `WebSocketSender` et `WebSocketReceiver`. Les futures concrètes sont boxées uniquement dans ce backend afin d'implémenter les associated futures GAT du contrat. Les représentations WebSocket/Tungstenite restent privées ; l'alias public `LocalBoxFuture` de `futures-util` sert uniquement de représentation concrète des associated futures du backend. + +### Mapping WebSocket + +```text +Binary -> TransportReceive::Message +Close -> TransportReceive::Closed +Ping / Pong -> détail de contrôle backend, non remonté au gameplay +Text -> TransportErrorKind::Protocol +Frame -> TransportErrorKind::Protocol +WriteBufferFull -> TransportErrorKind::Backpressure +Capacity -> TransportErrorKind::MessageTooLarge +I/O -> TransportErrorKind::Io +closed/already -> TransportErrorKind::Closed +``` + +Les erreurs de connect/bind/accept restent catégorisées au niveau de l'opération qui échoue et aucun `tungstenite::Error` ne fuit dans l'API publique. + +## Tracing + +Le target backend est : + +```text +games::realtime::websocket +``` + +Les événements couvrent connect, bind, accept, send, receive, close et erreurs. Le contenu brut des payloads n'est jamais loggué ; seule leur longueur peut apparaître au niveau trace. + +La crate ne configure aucun subscriber global. + +## Preuve loopback + +Le test d'intégration public : + +```text +binary_round_trip_and_clean_close_work_on_loopback +``` + +utilise `127.0.0.1:0`, établit client et serveur sans Internet ni port fixe, puis vérifie : + +- payload binaire client vers serveur ; +- payload binaire serveur vers client ; +- fermeture locale cliente observée comme fermeture distante propre côté serveur. + +Une borne de trois secondes entoure les étapes asynchrones uniquement pour rendre le test déterministe en cas de régression ; elle ne constitue pas encore le timeout de transport produit, réservé à `alpha.4`. + +## Documentation locale + +Aucun `README.md` ou `USAGE.md` local n'est ajouté pour cette nouvelle crate pendant `alpha.3`. Le contrat reste petit, la rustdoc décrit l'API et le plan central porte les décisions d'architecture. Cette décision sera réévaluée pendant la consolidation finale conformément à `DOC-CRATE-*`. + +## Fichiers existants modifiés + +```text +Cargo.toml +README.md +docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md +``` + +`ROADMAP.md` et `CHANGELOG.md` restent inchangés : le scope macro de `0.3.4` n'a pas changé et une alpha n'ajoute pas encore de changelog de release. + +## Validation attendue + +```bash +cargo fmt --all +cargo fmt --all -- --check + +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history +python3 scripts/audit_distribution_layout.py + +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p game-realtime-transport-lib --all-targets --all-features +cargo test -p game-realtime-websocket-lib --all-targets --all-features +cargo tree -p game-realtime-websocket-lib --edges normal +``` + +`cargo test --workspace --all-targets --all-features` reste réservé à la beta conformément au plan. diff --git a/docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md b/docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md index 8d1cf6a..b7995a8 100644 --- a/docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md +++ b/docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md @@ -1,5 +1,5 @@ - + # Plan 0.3.4 — transport realtime et baseline WebSocket @@ -7,7 +7,7 @@ Plan actif créé pendant `0.3.4-alpha.1` à partir de l'archive taggée `v0.3.3`. -La tranche `alpha.1` a été validée le 2026-09-21. La tranche active `alpha.2` matérialise maintenant le contrat transport-neutral sans dépendance runtime/backend ; le backend WebSocket reste réservé à `alpha.3`. +Les tranches `alpha.1` et `alpha.2` ont été validées le 2026-09-21. La tranche active `alpha.3` ajoute maintenant le backend WebSocket Tokio/tokio-tungstenite derrière le contrat transport-neutral validé ; les limites, timeouts et cas négatifs complets restent réservés à `alpha.4`. ## Mission @@ -64,7 +64,7 @@ Les anciens labels `0-pre`, `1-alpha`, `2-beta` et `3-rc` restent valides unique ## Dépendances vérifiées au 2026-09-21 -Versions amont observées pour la future implémentation : +Versions amont retenues et introduites par `alpha.3` : ```text Tokio 1.53.1 @@ -82,7 +82,7 @@ Contraintes utiles : - `native-tls` et les variantes `rustls-*` sont optionnelles ; - Tungstenite expose déjà des limites de message/frame et de write buffer configurables. -La compatibilité effective de la toolchain utilisateur avec ces MSRV sera attestée par les gates Cargo de la tranche qui introduira réellement les dépendances. Elles ne sont pas ajoutées pendant `alpha.1` : elles seront introduites uniquement avec les crates qui les consomment réellement. +`alpha.3` centralise désormais ces versions sous `[workspace.dependencies]` et active les features uniquement dans `game-realtime-websocket-lib`. `futures-util` est consommé avec `default-features = false` et seulement `sink + std`; `tokio-tungstenite` est consommé avec `default-features = false` et seulement `connect + handshake`; aucune feature TLS n'est activée. La compatibilité effective de la toolchain utilisateur avec ces MSRV sera attestée par la gate Cargo utilisateur de cette tranche. ## Ownership physique retenu @@ -284,6 +284,29 @@ Ne pas logguer par défaut le contenu brut des payloads de transport. Les futurs domaines session/sync/simulation utilisent des targets distinctes. +## Décisions matérialisées dans `alpha.3` + +Le backend concret reste sous `crates/common/game-realtime-websocket-lib` et dépend uniquement du contrat commun plus des briques réseau/tracing nécessaires. + +L'API publique expose : + +```text +connect(endpoint) +WebSocketListener::bind(address) +WebSocketListener::accept() +WebSocketConnection +WebSocketSender +WebSocketReceiver +``` + +Les types `WebSocketStream`, `SplitSink`, `SplitStream`, `MaybeTlsStream` et `tungstenite::Error` restent privés. Client et serveur produisent le même `WebSocketConnection` public grâce à une représentation interne à deux variantes ; le contrat commun reste donc la seule frontière partagée par les consommateurs. + +`connect()` accepte volontairement uniquement `ws://` dans cette baseline. Le TLS direct reste différé. Les frames binaires deviennent `TransportMessage`; Close devient `TransportReceive::Closed`; Ping/Pong sont traités comme contrôle backend ; Text et raw Frame sont explicitement rejetés comme erreurs de protocole. Les tests négatifs exhaustifs correspondants restent planifiés pour `alpha.4`. + +Le backend ne crée aucun runtime, thread ni task détachée. Le harness d'intégration utilise un runtime Tokio de test courant, un bind `127.0.0.1:0` et une borne temporelle uniquement pour empêcher un test loopback défectueux de rester suspendu. + +Aucun `README.md`/`USAGE.md` local n'est ajouté pendant `alpha.3` : la crate reste petite, son API publique est documentée par rustdoc et le présent plan porte encore les décisions durables. Ce choix sera réévalué pendant la consolidation finale conformément à `DOC-CRATE-*`. + ## Tests retenus ### Contrat commun diff --git a/history/0.3.4/alpha.2.md b/history/0.3.4/alpha.2.md new file mode 100644 index 0000000..c01a3b3 --- /dev/null +++ b/history/0.3.4/alpha.2.md @@ -0,0 +1,48 @@ + + + +# Historique 0.3.4-alpha.2 + +## Statut + +`0.3.4-alpha.2` a été validée par l'utilisateur le 2026-09-21. Le contrat transport-neutral compile dans le workspace, passe Clippy strict et ses sept tests ciblés sont propres. + +Aucun `alpha.2.fix.N` n'est requis. La suite peut ouvrir `0.3.4-alpha.3` et introduire le backend WebSocket Tokio/tokio-tungstenite planifié. + +## Gates statiques et Rust + +La gate fournie est propre : + +```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), 262 file(s)) +Distribution layout audit: clean (49 required path(s), 8 forbidden path(s) absent) +cargo fmt --all: clean +cargo fmt --all -- --check: clean +cargo check --workspace: clean +cargo clippy --workspace --all-targets --all-features -- -D warnings: clean +``` + +Le workspace compilé porte `0.3.4-alpha.2`, y compris `game-realtime-transport-lib`. + +## Tests du contrat transport + +La commande : + +```text +cargo test -p game-realtime-transport-lib --all-targets --all-features +``` + +termine avec : + +```text +7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +Les tests couvrent le payload binaire possédé, le payload vide, la distinction message/fermeture propre, le split sender/receiver, les futures associées de send/receive/close et les catégories d'erreur transport-neutral. + +## Conséquence + +La frontière commune est acceptée sans Tokio, Tungstenite ni runtime concret. `alpha.3` peut ajouter une crate backend séparée qui implémente ce contrat, tout en conservant le gameplay et les moteurs indépendants du backend WebSocket.