From fa17ea0ca515d78ff5aea6e5623270047f20afde Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Mon, 21 Sep 2026 18:00:32 +0200 Subject: [PATCH] 0.3.4-alpha.4 --- Cargo.toml | 4 +- README.md | 6 +- .../game-realtime-websocket-lib/Cargo.toml | 4 +- .../game-realtime-websocket-lib/src/config.rs | 167 ++++++++++++++++ .../game-realtime-websocket-lib/src/lib.rs | 9 +- .../src/websocket.rs | 178 +++++++++++++++--- .../tests/robustness.rs | 134 +++++++++++++ .../unit_tests/config.rs | 40 ++++ .../unit_tests/websocket.rs | 18 ++ deltas/0.3.4/alpha.4.md | 157 +++++++++++++++ ...0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md | 62 +++++- history/0.3.4/alpha.3.fix.1.md | 60 ++++++ 12 files changed, 792 insertions(+), 47 deletions(-) create mode 100644 crates/common/game-realtime-websocket-lib/src/config.rs create mode 100644 crates/common/game-realtime-websocket-lib/tests/robustness.rs create mode 100644 crates/common/game-realtime-websocket-lib/unit_tests/config.rs create mode 100644 crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs create mode 100644 deltas/0.3.4/alpha.4.md create mode 100644 history/0.3.4/alpha.3.fix.1.md diff --git a/Cargo.toml b/Cargo.toml index 09d9414..b60d4c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 89 +# version: 90 [workspace] resolver = "3" @@ -23,7 +23,7 @@ members = [ ] [workspace.package] -version = "0.3.4-alpha.3.fix.1" +version = "0.3.4-alpha.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/games" diff --git a/README.md b/README.md index ddeecd1..764f985 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.3.fix.1`. `0.3.2` reste différée. +Version candidate active : `0.3.4-alpha.4`. `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.3.fix.1` corrige l'héritage Cargo des options `default-features = false` de `futures-util` et `tokio-tungstenite` sans changer le scope fonctionnel de `alpha.3` : API transport-neutral conservée, backend WebSocket séparé fondé sur Tokio + `tokio-tungstenite`, client `ws://`, listener serveur, split sender/receiver, tracing et round-trip localhost déterministe. +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.4` consolide la baseline realtime validée en `alpha.3.fix.1` avec une configuration WebSocket explicite : limite message/frame de 1 MiB, write buffer cible 64 KiB et maximum 2 MiB, deadlines connect/send/close de 10/5/2 s, rejet Text, dépassements de taille, handshake silencieux et fermeture abrupte couverts par des tests locaux bornés. 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 index 2511a43..4495b4f 100644 --- a/crates/common/game-realtime-websocket-lib/Cargo.toml +++ b/crates/common/game-realtime-websocket-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/common/game-realtime-websocket-lib/Cargo.toml -# version: 2 +# version: 3 [package] name = "game-realtime-websocket-lib" @@ -13,7 +13,7 @@ publish.workspace = true [dependencies] futures-util = { workspace = true, features = ["sink", "std"] } game-realtime-transport-lib = { path = "../game-realtime-transport-lib" } -tokio = { workspace = true, features = ["net"] } +tokio = { workspace = true, features = ["net", "time"] } tokio-tungstenite = { workspace = true, features = ["connect", "handshake"] } tracing.workspace = true diff --git a/crates/common/game-realtime-websocket-lib/src/config.rs b/crates/common/game-realtime-websocket-lib/src/config.rs new file mode 100644 index 0000000..5f288e8 --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/src/config.rs @@ -0,0 +1,167 @@ +// file: crates/common/game-realtime-websocket-lib/src/config.rs +// version: 1 + +const DEFAULT_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const DEFAULT_MAX_FRAME_SIZE: usize = 1024 * 1024; +const DEFAULT_MAX_MESSAGE_SIZE: usize = 1024 * 1024; +const DEFAULT_MAX_WRITE_BUFFER_SIZE: usize = 2 * 1024 * 1024; +const DEFAULT_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const DEFAULT_WRITE_BUFFER_SIZE: usize = 64 * 1024; + +/// Product-facing limits and operation deadlines for one WebSocket connection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WebSocketConfig { + max_message_size: usize, + max_frame_size: usize, + write_buffer_size: usize, + max_write_buffer_size: usize, + connect_timeout: std::time::Duration, + send_timeout: std::time::Duration, + close_timeout: std::time::Duration, +} + +impl Default for WebSocketConfig { + fn default() -> Self { + return Self { + max_message_size: DEFAULT_MAX_MESSAGE_SIZE, + max_frame_size: DEFAULT_MAX_FRAME_SIZE, + write_buffer_size: DEFAULT_WRITE_BUFFER_SIZE, + max_write_buffer_size: DEFAULT_MAX_WRITE_BUFFER_SIZE, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + send_timeout: DEFAULT_SEND_TIMEOUT, + close_timeout: DEFAULT_CLOSE_TIMEOUT, + }; + } +} + +impl WebSocketConfig { + /// Returns a copy with a different maximum binary message size. + #[must_use] + pub fn with_max_message_size(mut self, value: usize) -> Self { + self.max_message_size = value; + return self; + } + + /// Returns a copy with a different maximum WebSocket frame payload size. + #[must_use] + pub fn with_max_frame_size(mut self, value: usize) -> Self { + self.max_frame_size = value; + return self; + } + + /// Returns a copy with a different Tungstenite write-buffer target. + #[must_use] + pub fn with_write_buffer_size(mut self, value: usize) -> Self { + self.write_buffer_size = value; + return self; + } + + /// Returns a copy with a different hard maximum for the Tungstenite write buffer. + #[must_use] + pub fn with_max_write_buffer_size(mut self, value: usize) -> Self { + self.max_write_buffer_size = value; + return self; + } + + /// Returns a copy with a different connection or handshake deadline. + #[must_use] + pub fn with_connect_timeout(mut self, value: std::time::Duration) -> Self { + self.connect_timeout = value; + return self; + } + + /// Returns a copy with a different deadline for one send operation. + #[must_use] + pub fn with_send_timeout(mut self, value: std::time::Duration) -> Self { + self.send_timeout = value; + return self; + } + + /// Returns a copy with a different deadline for a clean local close operation. + #[must_use] + pub fn with_close_timeout(mut self, value: std::time::Duration) -> Self { + self.close_timeout = value; + return self; + } + + /// Returns the configured maximum binary message size. + #[must_use] + pub fn max_message_size(&self) -> usize { + return self.max_message_size; + } + + /// Returns the configured maximum WebSocket frame payload size. + #[must_use] + pub fn max_frame_size(&self) -> usize { + return self.max_frame_size; + } + + /// Returns the configured Tungstenite write-buffer target. + #[must_use] + pub fn write_buffer_size(&self) -> usize { + return self.write_buffer_size; + } + + /// Returns the configured hard maximum for the Tungstenite write buffer. + #[must_use] + pub fn max_write_buffer_size(&self) -> usize { + return self.max_write_buffer_size; + } + + /// Returns the configured connection or handshake deadline. + #[must_use] + pub fn connect_timeout(&self) -> std::time::Duration { + return self.connect_timeout; + } + + /// Returns the configured deadline for one send operation. + #[must_use] + pub fn send_timeout(&self) -> std::time::Duration { + return self.send_timeout; + } + + /// Returns the configured deadline for a clean local close operation. + #[must_use] + pub fn close_timeout(&self) -> std::time::Duration { + return self.close_timeout; + } + + /// Validates all invariants required before creating a WebSocket endpoint or connection. + pub fn validate(&self) -> Result<(), game_realtime_transport_lib::TransportError> { + if self.max_message_size == 0 { + return Err(invalid_configuration("max_message_size must be greater than zero")); + } + if self.max_frame_size == 0 { + return Err(invalid_configuration("max_frame_size must be greater than zero")); + } + if self.max_frame_size > self.max_message_size { + return Err(invalid_configuration("max_frame_size must not exceed max_message_size")); + } + let minimum_max_write_buffer_size = match self.write_buffer_size.checked_add(self.max_message_size) { + Some(value) => value, + None => return Err(invalid_configuration("write_buffer_size + max_message_size overflows usize")), + }; + if self.max_write_buffer_size < minimum_max_write_buffer_size { + return Err(invalid_configuration("max_write_buffer_size must fit write_buffer_size plus one maximum-sized message")); + } + if self.connect_timeout.is_zero() { + return Err(invalid_configuration("connect_timeout must be greater than zero")); + } + if self.send_timeout.is_zero() { + return Err(invalid_configuration("send_timeout must be greater than zero")); + } + if self.close_timeout.is_zero() { + return Err(invalid_configuration("close_timeout must be greater than zero")); + } + return Ok(()); + } +} + +fn invalid_configuration(detail: &str) -> game_realtime_transport_lib::TransportError { + return game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail); +} + +#[cfg(test)] +#[path = "../unit_tests/config.rs"] +mod tests; diff --git a/crates/common/game-realtime-websocket-lib/src/lib.rs b/crates/common/game-realtime-websocket-lib/src/lib.rs index c255bda..0cc7c6a 100644 --- a/crates/common/game-realtime-websocket-lib/src/lib.rs +++ b/crates/common/game-realtime-websocket-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/common/game-realtime-websocket-lib/src/lib.rs -// version: 1 +// version: 2 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -7,8 +7,11 @@ //! Tokio/tokio-tungstenite WebSocket backend for the games.sasedev realtime transport contract. +mod config; mod websocket; +/// Re-export of product-facing WebSocket limits and operation deadlines. +pub use self::config::WebSocketConfig; /// Re-export of an established WebSocket transport connection. pub use self::websocket::WebSocketConnection; /// Re-export of a bound WebSocket server listener. @@ -17,5 +20,7 @@ pub use self::websocket::WebSocketListener; pub use self::websocket::WebSocketReceiver; /// Re-export of the send half of an established WebSocket connection. pub use self::websocket::WebSocketSender; -/// Re-export of the WebSocket client connection constructor. +/// Re-export of the WebSocket client connection constructor using baseline defaults. pub use self::websocket::connect; +/// Re-export of the configurable WebSocket client connection constructor. +pub use self::websocket::connect_with_config; diff --git a/crates/common/game-realtime-websocket-lib/src/websocket.rs b/crates/common/game-realtime-websocket-lib/src/websocket.rs index e987084..3e05326 100644 --- a/crates/common/game-realtime-websocket-lib/src/websocket.rs +++ b/crates/common/game-realtime-websocket-lib/src/websocket.rs @@ -1,5 +1,5 @@ // file: crates/common/game-realtime-websocket-lib/src/websocket.rs -// version: 1 +// version: 2 use futures_util::SinkExt; // rust-rules: trait-import use futures_util::StreamExt; // rust-rules: trait-import @@ -31,15 +31,16 @@ enum WebSocketReceiverKind { /// Established WebSocket connection implementing the transport-neutral realtime contract. pub struct WebSocketConnection { inner: WebSocketStreamKind, + config: crate::WebSocketConfig, } impl WebSocketConnection { - fn from_client(stream: ClientStream) -> Self { - return Self { inner: WebSocketStreamKind::Client(stream) }; + fn from_client(stream: ClientStream, config: crate::WebSocketConfig) -> Self { + return Self { inner: WebSocketStreamKind::Client(stream), config }; } - fn from_server(stream: ServerStream) -> Self { - return Self { inner: WebSocketStreamKind::Server(stream) }; + fn from_server(stream: ServerStream, config: crate::WebSocketConfig) -> Self { + return Self { inner: WebSocketStreamKind::Server(stream), config }; } } @@ -48,18 +49,36 @@ impl game_realtime_transport_lib::RealtimeConnection for WebSocketConnection { type Receiver = crate::WebSocketReceiver; fn split(self) -> (Self::Sender, Self::Receiver) { + let max_frame_size = self.config.max_frame_size(); + let max_message_size = self.config.max_message_size(); + let send_timeout = self.config.send_timeout(); + let close_timeout = self.config.close_timeout(); return match self.inner { WebSocketStreamKind::Client(stream) => { let (sender, receiver) = stream.split(); ( - crate::WebSocketSender { inner: WebSocketSinkKind::Client(sender) }, + crate::WebSocketSender { + inner: WebSocketSinkKind::Client(sender), + max_frame_size, + max_message_size, + send_timeout, + close_timeout, + send_timed_out: false, + }, crate::WebSocketReceiver { inner: WebSocketReceiverKind::Client(receiver) }, ) }, WebSocketStreamKind::Server(stream) => { let (sender, receiver) = stream.split(); ( - crate::WebSocketSender { inner: WebSocketSinkKind::Server(sender) }, + crate::WebSocketSender { + inner: WebSocketSinkKind::Server(sender), + max_frame_size, + max_message_size, + send_timeout, + close_timeout, + send_timed_out: false, + }, crate::WebSocketReceiver { inner: WebSocketReceiverKind::Server(receiver) }, ) }, @@ -70,6 +89,11 @@ impl game_realtime_transport_lib::RealtimeConnection for WebSocketConnection { /// Send half of an established WebSocket transport connection. pub struct WebSocketSender { inner: WebSocketSinkKind, + max_frame_size: usize, + max_message_size: usize, + send_timeout: std::time::Duration, + close_timeout: std::time::Duration, + send_timed_out: bool, } impl game_realtime_transport_lib::RealtimeSender for WebSocketSender { @@ -84,42 +108,81 @@ impl game_realtime_transport_lib::RealtimeSender for WebSocketSender { fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> { return Box::pin(async move { + if self.send_timed_out { + return Err(game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Aborted, + "sender is unavailable after a previous send timeout", + )); + } let payload_len = message.len(); + if payload_len > self.max_message_size || payload_len > self.max_frame_size { + let error = game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::MessageTooLarge, + format!("binary payload size {payload_len} exceeds configured message/frame maxima {}/{}", self.max_message_size, self.max_frame_size), + ); + tracing::warn!( + target: TRACING_TARGET, + payload_len = payload_len, + max_message_size = self.max_message_size, + max_frame_size = self.max_frame_size, + "outbound WebSocket payload rejected" + ); + return Err(error); + } 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, + let send_timeout = self.send_timeout; + let send = async { + return match &mut self.inner { + WebSocketSinkKind::Client(sender) => sender.send(websocket_message).await, + WebSocketSinkKind::Server(sender) => sender.send(websocket_message).await, + }; }; + let result = tokio::time::timeout(send_timeout, send).await; return match result { - Ok(()) => { + Ok(Ok(())) => { tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "binary WebSocket payload sent"); Ok(()) }, - Err(error) => { + 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) }, + Err(_) => { + self.send_timed_out = true; + let error = timeout_error("WebSocket send", send_timeout); + tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(send_timeout), "WebSocket send timed out"); + Err(error) + }, }; }); } 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, + let close_timeout = self.close_timeout; + let close = async { + return match &mut self.inner { + WebSocketSinkKind::Client(sender) => sender.close().await, + WebSocketSinkKind::Server(sender) => sender.close().await, + }; }; + let result = tokio::time::timeout(close_timeout, close).await; return match result { - Ok(()) => { + Ok(Ok(())) => { tracing::debug!(target: TRACING_TARGET, "local WebSocket close initiated"); Ok(()) }, - Err(error) => { + 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) }, + Err(_) => { + let error = timeout_error("WebSocket close", close_timeout); + tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(close_timeout), "WebSocket close timed out"); + Err(error) + }, }; }); } @@ -188,11 +251,20 @@ impl game_realtime_transport_lib::RealtimeReceiver for WebSocketReceiver { pub struct WebSocketListener { listener: tokio::net::TcpListener, local_addr: std::net::SocketAddr, + config: crate::WebSocketConfig, } impl WebSocketListener { - /// Binds a WebSocket listener to one concrete socket address. + /// Binds a WebSocket listener with the baseline configuration. pub async fn bind(address: std::net::SocketAddr) -> Result { + return Self::bind_with_config(address, crate::WebSocketConfig::default()).await; + } + + /// Binds a WebSocket listener with explicit product-facing limits and deadlines. + pub async fn bind_with_config(address: std::net::SocketAddr, config: crate::WebSocketConfig) -> Result { + if let Err(error) = config.validate() { + return Err(error); + } let listener = match tokio::net::TcpListener::bind(address).await { Ok(value) => value, Err(error) => { @@ -210,7 +282,7 @@ impl WebSocketListener { }, }; tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebSocket listener bound"); - return Ok(Self { listener, local_addr }); + return Ok(Self { listener, local_addr, config }); } /// Returns the concrete local socket address, including an ephemeral port selected by the OS. @@ -219,7 +291,7 @@ impl WebSocketListener { return self.local_addr; } - /// Accepts one TCP peer and completes the server-side WebSocket handshake. + /// Accepts one TCP peer and completes a bounded server-side WebSocket handshake. pub async fn accept(&self) -> Result { let (stream, peer_addr) = match self.listener.accept().await { Ok(value) => value, @@ -229,31 +301,51 @@ impl WebSocketListener { return Err(mapped); }, }; - let websocket = match tokio_tungstenite::accept_async(stream).await { - Ok(value) => value, - Err(error) => { + let handshake = tokio_tungstenite::accept_async_with_config(stream, Some(tungstenite_config(&self.config))); + let result = tokio::time::timeout(self.config.connect_timeout(), handshake).await; + let websocket = match result { + Ok(Ok(value)) => value, + Ok(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); }, + Err(_) => { + let error = timeout_error("WebSocket server handshake", self.config.connect_timeout()); + tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, timeout_ms = duration_millis(self.config.connect_timeout()), "WebSocket server handshake timed out"); + return Err(error); + }, }; tracing::info!(target: TRACING_TARGET, peer = %peer_addr, "WebSocket peer accepted"); - return Ok(crate::WebSocketConnection::from_server(websocket)); + return Ok(crate::WebSocketConnection::from_server(websocket, self.config)); } } -/// Connects a client to one plain `ws://` endpoint and completes the WebSocket handshake. +/// Connects a client to one plain `ws://` endpoint with the baseline configuration. pub async fn connect(endpoint: &str) -> Result { + return crate::connect_with_config(endpoint, crate::WebSocketConfig::default()).await; +} + +/// Connects a client to one plain `ws://` endpoint with explicit limits and deadlines. +pub async fn connect_with_config( + endpoint: &str, + config: crate::WebSocketConfig, +) -> 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", )); } + if let Err(error) = config.validate() { + return Err(error); + } 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 handshake = tokio_tungstenite::connect_async_with_config(endpoint, Some(tungstenite_config(&config)), false); + let result = tokio::time::timeout(config.connect_timeout(), handshake).await; + let (stream, _) = match result { + Ok(Ok(value)) => value, + Ok(Err(error)) => { let mapped = map_connect_error(error); tracing::warn!( target: TRACING_TARGET, @@ -264,9 +356,33 @@ pub async fn connect(endpoint: &str) -> Result { + let error = timeout_error("WebSocket client connect", config.connect_timeout()); + tracing::warn!(target: TRACING_TARGET, endpoint = endpoint, timeout_ms = duration_millis(config.connect_timeout()), "WebSocket client connect timed out"); + return Err(error); + }, }; tracing::info!(target: TRACING_TARGET, endpoint = endpoint, "WebSocket client connected"); - return Ok(crate::WebSocketConnection::from_client(stream)); + return Ok(crate::WebSocketConnection::from_client(stream, config)); +} + +fn tungstenite_config(config: &crate::WebSocketConfig) -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig { + return tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .write_buffer_size(config.write_buffer_size()) + .max_write_buffer_size(config.max_write_buffer_size()) + .max_message_size(Some(config.max_message_size())) + .max_frame_size(Some(config.max_frame_size())); +} + +fn timeout_error(operation: &str, timeout: std::time::Duration) -> game_realtime_transport_lib::TransportError { + return game_realtime_transport_lib::TransportError::new( + game_realtime_transport_lib::TransportErrorKind::Timeout, + format!("{operation} exceeded configured deadline of {} ms", duration_millis(timeout)), + ); +} + +fn duration_millis(duration: std::time::Duration) -> u128 { + return duration.as_millis(); } fn map_connect_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError { @@ -289,3 +405,7 @@ fn map_stream_error(error: tokio_tungstenite::tungstenite::Error) -> game_realti }; return game_realtime_transport_lib::TransportError::new(kind, error.to_string()); } + +#[cfg(test)] +#[path = "../unit_tests/websocket.rs"] +mod tests; diff --git a/crates/common/game-realtime-websocket-lib/tests/robustness.rs b/crates/common/game-realtime-websocket-lib/tests/robustness.rs new file mode 100644 index 0000000..eb59e0d --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/tests/robustness.rs @@ -0,0 +1,134 @@ +// file: crates/common/game-realtime-websocket-lib/tests/robustness.rs +// version: 1 + +//! Negative and bounded lifecycle tests for the WebSocket transport backend. + +use futures_util::SinkExt; // rust-rules: trait-import +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 SMALL_MESSAGE_LIMIT: usize = 32; +const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + +type RawClient = tokio_tungstenite::WebSocketStream>; + +#[tokio::test(flavor = "current_thread")] +async fn outbound_payload_over_the_configured_limit_is_rejected_before_write() { + let config = small_message_config(); + let (server_connection, client_connection) = establish_backend_pair(config).await; + let (_server_sender, _server_receiver) = server_connection.split(); + let (mut client_sender, _client_receiver) = client_connection.split(); + let oversized = game_realtime_transport_lib::TransportMessage::new(vec![7; SMALL_MESSAGE_LIMIT + 1]); + let result = client_sender.send(oversized).await; + match result { + Ok(()) => panic!("oversized outbound payload was accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn inbound_payload_over_the_configured_limit_is_rejected_by_tungstenite() { + let config = small_message_config(); + let (server_connection, mut raw_client) = establish_backend_server_with_raw_client(config).await; + let (_server_sender, mut server_receiver) = server_connection.split(); + let send = raw_client.send(tokio_tungstenite::tungstenite::Message::binary(vec![3; SMALL_MESSAGE_LIMIT + 1])).await; + assert!(send.is_ok()); + let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await; + match receive { + Ok(Ok(value)) => panic!("oversized inbound payload produced a successful receive: {value:?}"), + Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge), + Err(_) => panic!("oversized inbound payload did not complete within the test timeout"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn text_message_is_rejected_by_the_binary_transport_contract() { + let (server_connection, mut raw_client) = establish_backend_server_with_raw_client(game_realtime_websocket_lib::WebSocketConfig::default()).await; + let (_server_sender, mut server_receiver) = server_connection.split(); + let send = raw_client.send(tokio_tungstenite::tungstenite::Message::text("text is outside the transport contract")).await; + assert!(send.is_ok()); + let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await; + match receive { + Ok(Ok(value)) => panic!("text message produced a successful receive: {value:?}"), + Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol), + Err(_) => panic!("text-message rejection did not complete within the test timeout"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn peer_drop_without_close_handshake_is_reported_as_protocol_failure() { + let (server_connection, raw_client) = establish_backend_server_with_raw_client(game_realtime_websocket_lib::WebSocketConfig::default()).await; + let (_server_sender, mut server_receiver) = server_connection.split(); + drop(raw_client); + let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await; + match receive { + Ok(Ok(value)) => panic!("abrupt peer drop was reported as a successful receive: {value:?}"), + Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol), + Err(_) => panic!("abrupt peer drop was not observed within the test timeout"), + } +} + +#[tokio::test(flavor = "current_thread")] +async fn silent_tcp_peer_hits_the_server_handshake_deadline() { + let config = game_realtime_websocket_lib::WebSocketConfig::default().with_connect_timeout(std::time::Duration::from_millis(50)); + let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await { + Ok(value) => value, + Err(error) => panic!("bounded listener bind failed: {error}"), + }; + let _silent_peer = match tokio::net::TcpStream::connect(listener.local_addr()).await { + Ok(value) => value, + Err(error) => panic!("silent TCP peer connection failed: {error}"), + }; + let accept = tokio::time::timeout(TEST_TIMEOUT, listener.accept()).await; + match accept { + Ok(Ok(_connection)) => panic!("silent TCP peer unexpectedly completed a WebSocket handshake"), + Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Timeout), + Err(_) => panic!("server handshake timeout did not fire within the outer test timeout"), + } +} + +fn small_message_config() -> game_realtime_websocket_lib::WebSocketConfig { + return game_realtime_websocket_lib::WebSocketConfig::default().with_max_message_size(SMALL_MESSAGE_LIMIT).with_max_frame_size(SMALL_MESSAGE_LIMIT); +} + +async fn establish_backend_pair( + config: game_realtime_websocket_lib::WebSocketConfig, +) -> (game_realtime_websocket_lib::WebSocketConnection, game_realtime_websocket_lib::WebSocketConnection) { + let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).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_with_config(endpoint.as_str(), config)); + }) + .await; + return match pair { + Ok((Ok(server), Ok(client))) => (server, client), + Ok((_server, _client)) => panic!("loopback connection establishment failed"), + Err(_) => panic!("loopback connection establishment timed out"), + }; +} + +async fn establish_backend_server_with_raw_client( + config: game_realtime_websocket_lib::WebSocketConfig, +) -> (game_realtime_websocket_lib::WebSocketConnection, RawClient) { + let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0)); + let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).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(), tokio_tungstenite::connect_async(endpoint.as_str())); + }) + .await; + return match pair { + Ok((Ok(server), Ok((client, _response)))) => (server, client), + Ok((_server, _client)) => panic!("raw-client loopback connection establishment failed"), + Err(_) => panic!("raw-client loopback connection establishment timed out"), + }; +} diff --git a/crates/common/game-realtime-websocket-lib/unit_tests/config.rs b/crates/common/game-realtime-websocket-lib/unit_tests/config.rs new file mode 100644 index 0000000..6dbabab --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/unit_tests/config.rs @@ -0,0 +1,40 @@ +// file: crates/common/game-realtime-websocket-lib/unit_tests/config.rs +// version: 1 + +#[test] +fn default_configuration_matches_the_product_baseline() { + let config = crate::WebSocketConfig::default(); + assert_eq!(config.max_message_size(), 1024 * 1024); + assert_eq!(config.max_frame_size(), 1024 * 1024); + assert_eq!(config.write_buffer_size(), 64 * 1024); + assert_eq!(config.max_write_buffer_size(), 2 * 1024 * 1024); + assert_eq!(config.connect_timeout(), std::time::Duration::from_secs(10)); + assert_eq!(config.send_timeout(), std::time::Duration::from_secs(5)); + assert_eq!(config.close_timeout(), std::time::Duration::from_secs(2)); + assert!(config.validate().is_ok()); +} + +#[test] +fn invalid_message_and_write_buffer_bounds_are_rejected() { + let zero_message = crate::WebSocketConfig::default().with_max_message_size(0); + assert_invalid_configuration(zero_message); + let oversized_frame = crate::WebSocketConfig::default().with_max_frame_size(2 * 1024 * 1024); + assert_invalid_configuration(oversized_frame); + let insufficient_write_buffer = crate::WebSocketConfig::default().with_max_write_buffer_size(1024 * 1024); + assert_invalid_configuration(insufficient_write_buffer); +} + +#[test] +fn zero_operation_deadlines_are_rejected() { + assert_invalid_configuration(crate::WebSocketConfig::default().with_connect_timeout(std::time::Duration::ZERO)); + assert_invalid_configuration(crate::WebSocketConfig::default().with_send_timeout(std::time::Duration::ZERO)); + assert_invalid_configuration(crate::WebSocketConfig::default().with_close_timeout(std::time::Duration::ZERO)); +} + +fn assert_invalid_configuration(config: crate::WebSocketConfig) { + let result = config.validate(); + match result { + Ok(()) => panic!("invalid WebSocket configuration was accepted"), + Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration), + } +} diff --git a/crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs b/crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs new file mode 100644 index 0000000..5189275 --- /dev/null +++ b/crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs @@ -0,0 +1,18 @@ +// file: crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs +// version: 1 + +#[test] +fn tungstenite_backpressure_maps_to_transport_backpressure() { + let message = tokio_tungstenite::tungstenite::Message::Binary(vec![1, 2, 3].into()); + let backend_error = tokio_tungstenite::tungstenite::Error::WriteBufferFull(Box::new(message)); + let mapped = super::map_stream_error(backend_error); + assert_eq!(mapped.kind(), game_realtime_transport_lib::TransportErrorKind::Backpressure); +} + +#[test] +fn tungstenite_capacity_maps_to_message_too_large() { + let capacity = tokio_tungstenite::tungstenite::error::CapacityError::MessageTooLong { size: 65, max_size: 64 }; + let backend_error = tokio_tungstenite::tungstenite::Error::Capacity(capacity); + let mapped = super::map_stream_error(backend_error); + assert_eq!(mapped.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge); +} diff --git a/deltas/0.3.4/alpha.4.md b/deltas/0.3.4/alpha.4.md new file mode 100644 index 0000000..afbf36b --- /dev/null +++ b/deltas/0.3.4/alpha.4.md @@ -0,0 +1,157 @@ + + + +# Delta 0.3.4-alpha.4 + +## Base + +Base directe : `0.3.4-alpha.3.fix.1`, validée par l'utilisateur le 2026-09-21. + +Cette tranche n'élargit pas le contrat transport-neutral et n'introduit ni session gameplay, ni wire codec, ni TLS. Elle ferme les limites, deadlines et principaux cas négatifs du backend WebSocket avant la validation large. + +## Historique du jalon précédent + +Ajout de : + +```text +history/0.3.4/alpha.3.fix.1.md +``` + +L'historique enregistre la gate utilisateur réellement fournie : audits propres, workspace/Clippy propres, sept tests transport, loopback WebSocket propre et graphe normal sans pile TLS. + +## Configuration WebSocket produit + +Ajout de `WebSocketConfig`, réexportée au crate-root de `game-realtime-websocket-lib`. + +Valeurs par défaut : + +```text +max message size 1 MiB +max frame size 1 MiB +write buffer target 64 KiB +max write buffer 2 MiB +connect/handshake 10 s +send 5 s +close 2 s +receive idle timeout aucun +``` + +La configuration peut être ajustée par builders `with_*` puis validée. Les invariants refusent notamment : + +- message/frame de taille nulle ; +- frame maximale supérieure au message maximal ; +- write buffer maximal incapable de contenir le target plus un message maximal ; +- overflow de ce calcul ; +- deadline connect/send/close nulle. + +Les wrappers `connect()` et `WebSocketListener::bind()` restent disponibles avec la configuration par défaut. Les nouveaux chemins configurables sont : + +```text +connect_with_config(endpoint, config) +WebSocketListener::bind_with_config(address, config) +``` + +## Limites et backpressure + +Les limites message/frame/write-buffer sont transmises à `tungstenite::protocol::WebSocketConfig` aussi bien côté client que côté serveur. + +Le send vérifie également `max_message_size` et `max_frame_size` avant l'écriture afin que le dépassement sortant soit déterministe et remonte `TransportErrorKind::MessageTooLarge` sans dépendre d'un comportement réseau. + +`max_write_buffer_size` est borné à `2 MiB` par défaut. `tungstenite::Error::WriteBufferFull` continue d'être mappé vers `TransportErrorKind::Backpressure` et ce mapping dispose désormais d'un test unitaire dédié. Aucun test loopback ne fabrique une saturation artificielle : le write buffer Tungstenite ne grossit au-delà de son target que lors d'échecs d'écriture sous-jacents, ce qui rendrait ce scénario réseau local non déterministe. + +## Timeouts et lifecycle + +La feature Tokio `time` devient une dépendance de production uniquement pour `game-realtime-websocket-lib`. + +Les deadlines sont appliquées ainsi : + +- connexion client complète : `connect_timeout` ; +- handshake serveur après accept TCP : `connect_timeout` ; +- émission d'un message : `send_timeout` ; +- fermeture locale du sink : `close_timeout`. + +L'attente d'un nouveau peer sur `TcpListener::accept()` n'est volontairement pas bornée par la configuration d'une connexion. `receive()` ne reçoit aucun idle timeout : heartbeat et inactivité appartiennent aux futures couches session/synchronisation. + +Si un send expire, la moitié émission mémorise cet état et refuse un nouvel envoi avec `TransportErrorKind::Aborted`. Le send interrompu peut avoir progressé partiellement ; il ne doit donc jamais être rejoué implicitement comme s'il n'avait rien produit. Une fermeture explicite reste néanmoins tentable avec sa propre deadline. + +Le backend continue de ne créer aucun runtime, thread ou task détachée. + +## Tests ajoutés + +Tests unitaires de configuration : + +- defaults produit exacts ; +- limites message/frame/write-buffer invalides ; +- deadlines nulles invalides. + +Tests unitaires de mapping backend : + +- `WriteBufferFull -> Backpressure` ; +- `Capacity -> MessageTooLarge`. + +Nouveau `tests/robustness.rs` : + +1. payload sortant hors limite rejeté avant écriture ; +2. payload entrant hors limite rejeté par Tungstenite ; +3. frame Text rejetée par le contrat binaire ; +4. peer drop sans close handshake remonté comme erreur de protocole ; +5. peer TCP silencieux borné par le timeout de handshake serveur. + +Tous les scénarios réseau utilisent uniquement `127.0.0.1:0` et une borne externe de test de trois secondes. + +## Documentation + +Le plan actif `docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md` est mis à jour avec : + +- validation effective de `alpha.3.fix.1` ; +- configuration et deadlines retenues ; +- raison de l'absence d'idle timeout transport ; +- stratégie backpressure testable sans test réseau flaky ; +- orientation vers beta ou courte consolidation selon le résultat réel de cette gate. + +`CHANGELOG.md` et `ROADMAP.md` restent inchangés : la version est encore en alpha et le scope macroscopique `0.3.4` ne change pas. + +## Fichiers + +Modifiés : + +```text +Cargo.toml +README.md +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 +docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md +``` + +Ajoutés : + +```text +crates/common/game-realtime-websocket-lib/src/config.rs +crates/common/game-realtime-websocket-lib/unit_tests/config.rs +crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs +crates/common/game-realtime-websocket-lib/tests/robustness.rs +history/0.3.4/alpha.3.fix.1.md +deltas/0.3.4/alpha.4.md +``` + +## Validation attendue + +```bash +cargo fmt --all +cargo fmt --all -- --check + +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history +python3 scripts/audit_distribution_layout.py + +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings + +cargo test -p game-realtime-transport-lib --all-targets --all-features +cargo test -p game-realtime-websocket-lib --all-targets --all-features + +cargo tree -p game-realtime-websocket-lib --edges normal +``` + +La validation utilisateur reste autoritaire pour la compilation, Clippy et les tests runtime. Les audits statiques exécutables dans l'environnement de génération doivent être propres avant livraison du delta. 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 b1fe74e..3b4525a 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`. -Les tranches `alpha.1` et `alpha.2` ont été validées le 2026-09-21. La validation de `alpha.3` a révélé un défaut de manifeste Cargo avant compilation : une crate membre ne peut pas remplacer `default-features` d'une dépendance héritée du workspace. La tranche active `alpha.3.fix.1` corrige uniquement cet héritage ; les limites, timeouts et cas négatifs complets restent réservés à `alpha.4`. +Les tranches `alpha.1`, `alpha.2` et `alpha.3.fix.1` ont été validées le 2026-09-21. `alpha.3` avait été rejetée avant compilation à cause d'un héritage Cargo invalide de `default-features`; son fix a ensuite validé le backend WebSocket, le round-trip localhost et le graphe sans TLS. La tranche active `alpha.4` ferme maintenant les limites, timeouts, cas négatifs et lifecycle avant consolidation/beta. ## Mission @@ -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. -`alpha.3.fix.1` centralise désormais aussi `default-features = false` sous `[workspace.dependencies]`, car Cargo interdit à une dépendance membre héritée de remplacer cette option. `game-realtime-websocket-lib` ajoute seulement les features locales `sink + std` pour `futures-util` et `connect + handshake` pour `tokio-tungstenite`; aucune feature TLS n'est activée. La compatibilité effective de la toolchain utilisateur sera attestée par la gate Cargo utilisateur du fix. +`alpha.3.fix.1` centralise désormais aussi `default-features = false` sous `[workspace.dependencies]`, car Cargo interdit à une dépendance membre héritée de remplacer cette option. `game-realtime-websocket-lib` ajoute seulement les features locales `sink + std` pour `futures-util` et `connect + handshake` pour `tokio-tungstenite`; aucune feature TLS n'est activée. La gate utilisateur du fix confirme Rust `1.94.1`, Tokio `1.53.1`, tokio-tungstenite/Tungstenite `0.30.0` et un graphe normal sans pile TLS. `alpha.4` ajoute uniquement la feature Tokio `time` au backend afin d'appliquer les deadlines produit. ## Ownership physique retenu @@ -307,6 +307,38 @@ Le backend ne crée aucun runtime, thread ni task détachée. Le harness d'inté 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-*`. +## Décisions matérialisées dans `alpha.4` + +`game-realtime-websocket-lib` expose désormais `WebSocketConfig` et les variantes configurables `connect_with_config()` et `WebSocketListener::bind_with_config()`. Les wrappers historiques `connect()` et `bind()` conservent des valeurs de baseline explicites : + +```text +max message size 1 MiB +max frame size 1 MiB +write buffer target 64 KiB +max write buffer 2 MiB +connect/handshake 10 s +send 5 s +close 2 s +receive idle timeout aucun +``` + +La configuration refuse une limite nulle, une frame plus grande que le message, un write buffer maximum incapable de contenir le target plus un message maximal et une deadline nulle. Les limites Tungstenite sont transmises aux handshakes client et serveur ; les tailles message/frame sortantes sont également vérifiées avant l'appel au sink afin que le rejet soit déterministe. + +`connect_timeout` borne la connexion client et la phase de handshake serveur après accept TCP. Le listener reste volontairement capable d'attendre indéfiniment un nouveau peer : ce temps d'attente appartient au service consommateur, pas à une connexion déjà en établissement. `send_timeout` et `close_timeout` bornent leurs opérations respectives. Après expiration d'un `send`, la moitié émission refuse un nouvel envoi avec `Aborted`, car l'appel interrompu peut avoir progressé partiellement et ne doit pas être rejoué implicitement. + +Aucun idle timeout n'est ajouté à `receive()`. Heartbeat, inactivité joueur et politique de session restent au-dessus du transport. + +Les tests négatifs `alpha.4` couvrent : + +- rejet sortant d'un payload hors limite avant écriture ; +- rejet entrant d'un message/frame hors limite par Tungstenite ; +- rejet d'une frame Text par le contrat binaire ; +- drop TCP/WebSocket sans close handshake, attendu comme erreur de protocole ; +- timeout de handshake serveur avec un peer TCP silencieux ; +- mapping déterministe de `WriteBufferFull` vers `Backpressure` et de `Capacity` vers `MessageTooLarge`. + +Un test réseau artificiel de saturation `WriteBufferFull` n'est pas retenu : Tungstenite documente que son write buffer ne dépasse le target que lorsque les écritures sous-jacentes échouent, ce qui rendrait une saturation loopback normale non représentative et potentiellement flaky. La borne finie est néanmoins configurée, le mapping backend est testé unitairement et le timeout d'émission borne l'attente du sink. + ## Tests retenus ### Contrat commun @@ -384,6 +416,16 @@ cargo tree -p game-realtime-websocket-lib --edges normal Fermer robustesse, limites, timeouts, close/cancellation et cas négatifs. Réexécuter les tests des deux crates. Un demo n'est ajouté que si une preuve manque réellement. +Gates ciblées : + +```bash +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 +``` + +Le test backend doit maintenant couvrir le loopback positif, les invariants de configuration, les mappings de capacité/backpressure et les cinq scénarios négatifs déterministes de `robustness.rs`. + ### Beta La beta est le jalon large retenu pour : @@ -430,15 +472,17 @@ Aucune dépendance réseau n'est ajoutée. ### `0.3.4-alpha.4` — robustesse et consolidation technique -- limites explicites ; -- timeouts ; -- fermeture distante/abrupt drop ; +- `WebSocketConfig` avec limites et deadlines explicites ; +- application symétrique de la configuration aux handshakes client/serveur ; +- timeout connect/handshake, send et close, sans idle timeout transport ; +- fermeture distante propre conservée et abrupt drop distingué ; - cas Text non supporté ; -- backpressure bornée ; -- cancellation/lifecycle sans tâche orpheline ; +- message/frame hors limite ; +- write buffer maximum borné et mapping `Backpressure` testé sans fabriquer un test réseau flaky ; +- lifecycle sans runtime/task backend privé ; - documentation API/backend et audit du graphe. -Cette tranche peut absorber la consolidation avant beta si elle reste dans le budget. Si elle devient trop lourde, une `alpha.5` de consolidation est créée ; elle ne doit pas être ajoutée uniquement pour suivre un numéro prévu. +Si les gates de cette tranche sont propres, le demo/CLI conditionnel n'apporte plus de preuve supplémentaire : la suite doit privilégier une courte consolidation alpha uniquement si la revue documentaire/API révèle une dette réelle, sinon passer directement à la beta large. ### `0.3.4-beta.1` — validation large diff --git a/history/0.3.4/alpha.3.fix.1.md b/history/0.3.4/alpha.3.fix.1.md new file mode 100644 index 0000000..d432202 --- /dev/null +++ b/history/0.3.4/alpha.3.fix.1.md @@ -0,0 +1,60 @@ + + + +# Historique 0.3.4-alpha.3.fix.1 + +## Statut + +`0.3.4-alpha.3.fix.1` a été validée par l'utilisateur le 2026-09-21. Le correctif de manifeste ferme le défaut de `alpha.3` et valide effectivement le backend WebSocket prévu par cette tranche. + +Aucun nouveau fix de `alpha.3` n'est requis. La suite peut ouvrir `0.3.4-alpha.4` pour les limites, timeouts et cas négatifs planifiés. + +## 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), 265 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.3.fix.1`, y compris `game-realtime-transport-lib` et `game-realtime-websocket-lib`. + +## Tests + +Le contrat transport-neutral reste propre : + +```text +cargo test -p game-realtime-transport-lib --all-targets --all-features +7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +``` + +Le backend WebSocket compile et son test d'intégration localhost passe : + +```text +cargo test -p game-realtime-websocket-lib --all-targets --all-features +unit tests: 0 passed; 0 failed +loopback: 1 passed; 0 failed +``` + +Le loopback couvre le round-trip binaire dans les deux sens et la fermeture propre distante. + +## Graphe de dépendances + +`cargo tree -p game-realtime-websocket-lib --edges normal` confirme la frontière attendue : + +- `game-realtime-websocket-lib` dépend directement de `game-realtime-transport-lib`, `futures-util 0.3.34`, `tokio 1.53.1`, `tokio-tungstenite 0.30.0` et `tracing 0.1.44` ; +- `tokio-tungstenite 0.30.0` apporte `tungstenite 0.30.0` ; +- aucune pile TLS `native-tls`/`rustls` n'apparaît dans le graphe normal ; +- aucune crate gameplay ou moteur n'est introduite comme dépendance du backend. + +## Conséquence + +La baseline WebSocket de `alpha.3` est désormais réellement validée après son fix de manifeste. `alpha.4` peut ajouter la robustesse produit sans rouvrir l'API transport-neutral ni introduire de protocole session/gameplay.