0.3.5-alpha.7

This commit is contained in:
2026-09-22 06:36:54 +02:00
parent 0ef5e1a233
commit 1764eb9bed
20 changed files with 1021 additions and 55 deletions

View File

@@ -1,5 +1,5 @@
# file: crates/common/game-realtime-webtransport-lib/Cargo.toml
# version: 3
# version: 4
[package]
name = "game-realtime-webtransport-lib"
@@ -21,6 +21,8 @@ tokio = { workspace = true, features = ["time"] }
web-transport-quinn = { workspace = true, features = ["ring"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-util = { workspace = true, features = ["std"] }
gloo-timers = { workspace = true, features = ["futures"] }
web-transport-wasm.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]

View File

@@ -1,5 +1,5 @@
<!-- file: crates/common/game-realtime-webtransport-lib/README.md -->
<!-- version: 4 -->
<!-- version: 5 -->
# game-realtime-webtransport-lib
@@ -19,7 +19,7 @@ La frontière fiable disponible couvre désormais :
- sélection d'un unique stream bidirectionnel fiable comme chemin realtime principal ;
- framing privé `u32` big-endian + payload binaire ;
- limite de message configurable, 1 MiB par défaut, vérifiée avant allocation côté réception et avant écriture côté émission ;
- deadlines configurables sur le chemin natif pour la connexion, l'ouverture/accept du stream primaire et un envoi complet ;
- deadlines configurables sur les chemins natif et navigateur pour la connexion, l'ouverture/accept du stream primaire et un envoi complet ;
- adaptation `RealtimeConnection` / `RealtimeSender` / `RealtimeReceiver` ;
- FIN propre via `RealtimeSender::close()` ;
- reset/STOP_SENDING backend-spécifiques via `WebTransportSender::abort(...)` et `WebTransportReceiver::abort(...)` ;
@@ -38,7 +38,7 @@ L'API client garde les mêmes noms de surface que le client natif : `WebTranspor
Le pin SHA-256 est transmis à `WebTransportOptions.serverCertificateHashes`; aucune variante navigateur sans validation TLS n'est ajoutée. Le framing applicatif reste strictement identique au natif.
`alpha.6` ferme uniquement la preuve de compilation de ce chemin. La limite de message de `WebTransportConfig` est appliquée immédiatement, mais les deadlines opérationnelles de cette configuration restent une différence explicite : le wrapper navigateur conserve les opérations Web API abandonnées de manière cancel-safe, et la tranche `alpha.7` doit décider puis prouver la politique de timer/runtime avant de déclarer la parité comportementale navigateur. Aucun smoke runtime navigateur n'est attribué à `alpha.6`.
À partir de `alpha.7`, les deadlines `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont aussi matérialisées côté navigateur par des timers WASM. Une deadline navigateur doit tenir dans la plage `u32` millisecondes ; une valeur supérieure est rejetée comme `InvalidConfiguration`. Une réception idle reste volontairement sans timeout implicite, comme sur le chemin natif. Le smoke navigateur technique séparé prouve ensuite cette surface avec un vrai navigateur sans fallback WebSocket.
## TLS de développement
@@ -69,7 +69,7 @@ one WebTransport session
`WebTransportConfig::default()` conserve la baseline de 1 MiB par message. La limite peut être réduite ou augmentée tant qu'elle reste strictement positive et représentable dans le champ de longueur `u32` du framing.
Sur le chemin natif, les deadlines configurables couvrent :
Sur les chemins natif et navigateur, les deadlines configurables couvrent :
- connexion client et réponse finale à une requête WebTransport déjà surfacée côté serveur ;
- ouverture ou accept du stream bidirectionnel principal ;
@@ -77,7 +77,7 @@ Sur le chemin natif, les deadlines configurables couvrent :
L'attente d'un nouveau pair sur le listener reste volontairement non bornée : un serveur inactif ne doit pas produire périodiquement une erreur uniquement parce qu'aucun client ne se présente.
QUIC applique sa propre flow-control. Le backend natif n'ajoute pas une seconde file applicative : si un envoi reste bloqué par flow-control/réseau au-delà de `send_timeout`, l'opération retourne `TransportErrorKind::Timeout` et le stream est reset afin qu'une frame partiellement transmise ne puisse pas être suivie d'une nouvelle frame invalide. Le chemin navigateur conserve la même politique de reset sur cancellation, mais son timer `send_timeout` reste explicitement différé à `alpha.7`.
QUIC applique sa propre flow-control. Le backend n'ajoute pas une seconde file applicative : si un envoi reste bloqué par flow-control/réseau au-delà de `send_timeout`, l'opération retourne `TransportErrorKind::Timeout` et le stream est reset afin qu'une frame partiellement transmise ne puisse pas être suivie d'une nouvelle frame invalide. Côté navigateur, le timer est porté par `gloo-timers` et la future WebTransport abandonnée reste traitée selon la sémantique cancel-safe du wrapper amont.
## Lifecycle, abort et cancellation
@@ -103,7 +103,7 @@ Le backend distingue notamment :
- fermeture de session WebTransport explicite -> `Closed` ;
- erreur de session/connexion non classée comme fermeture propre -> `Io` ;
- reset/stop invalide ou framing tronqué -> `Protocol` ;
- deadline dépassée -> `Timeout` sur le chemin natif ; la matérialisation des timers navigateur reste différée à `alpha.7`.
- deadline dépassée -> `Timeout` sur les chemins natif et navigateur.
Une longueur entrante hors limite ou un framing tronqué provoque aussi l'arrêt de la direction de réception afin d'éviter de poursuivre sur un flux désynchronisé.
@@ -113,8 +113,7 @@ La crate ne possède toujours pas :
- d'API datagram transport-neutral ;
- de serveur WebTransport WASM ;
- de preuve runtime navigateur ;
- de fallback WebSocket ;
- de fallback WebSocket dans le backend ;
- de benchmark WebSocket/WebTransport.
Ces responsabilités restent réservées aux tranches suivantes du plan `0.3.5`.

View File

@@ -1,5 +1,5 @@
<!-- file: crates/common/game-realtime-webtransport-lib/USAGE.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Utilisation de game-realtime-webtransport-lib
@@ -7,7 +7,7 @@ Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés pa
## Configuration transport
`WebTransportConfig` porte les limites et deadlines du chemin fiable. La configuration par défaut garde une limite de 1 MiB par message. Les deadlines sont appliquées par le chemin natif ; le client navigateur les valide mais leur matérialisation par timer reste différée à `alpha.7`.
`WebTransportConfig` porte les limites et deadlines du chemin fiable. La configuration par défaut garde une limite de 1 MiB par message. Les deadlines sont appliquées sur les chemins natif et navigateur pour la connexion, la sélection du stream primaire et chaque envoi complet.
Exemple de configuration plus stricte :
@@ -19,7 +19,7 @@ let transport = game_realtime_webtransport_lib::WebTransportConfig::default()
.with_send_timeout(std::time::Duration::from_secs(2));
```
La validation effective se fait lors du bind serveur ou de la connexion client. Une limite nulle, une limite non représentable en `u32` ou une deadline nulle est rejetée comme `InvalidConfiguration`, y compris côté navigateur même lorsque le timer correspondant n'est pas encore matérialisé.
La validation effective se fait lors du bind serveur ou de la connexion client. Une limite nulle, une limite non représentable en `u32` ou une deadline nulle est rejetée comme `InvalidConfiguration`. Côté navigateur, les deadlines doivent également tenir dans la plage `u32` millisecondes imposée par le timer WASM.
## Serveur natif
@@ -102,9 +102,9 @@ let connection = match session.open_primary_connection().await {
};
```
Le build workspace fournit `web_sys_unstable_apis` uniquement à `wasm32-unknown-unknown`. Le hash SHA-256 est transmis au navigateur comme `serverCertificateHashes`. La limite de message configurée est appliquée au framing WASM. Les deadlines `connect_timeout`, `primary_stream_timeout` et `send_timeout` restent validées mais ne sont pas encore appliquées par un timer navigateur dans la tranche de compilation ; cette politique est fermée avec le smoke runtime de la tranche suivante.
Le build workspace fournit `web_sys_unstable_apis` uniquement à `wasm32-unknown-unknown`. Le hash SHA-256 est transmis au navigateur comme `serverCertificateHashes`. La limite de message configurée est appliquée au framing WASM. `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont réalisés par des timers WASM ; une expiration retourne `TransportErrorKind::Timeout`, et un send expiré reset le stream comme sur le chemin natif.
Aucun `wasm-bindgen` frontend ou host Vite n'est requis pour simplement vérifier la compilation de la bibliothèque.
Le host Vite et l'adapter `wasm-bindgen` de `game-realtime-webtransport-browser-smoke` servent uniquement de preuve runtime. Ils ne sont pas nécessaires à un consommateur qui intègre déjà la bibliothèque dans son propre frontend.
## Contrat realtime
@@ -130,7 +130,7 @@ if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut send
Le backend encode chaque `TransportMessage` sous la forme `u32` big-endian + payload. Le consommateur ne doit pas reproduire ce framing lui-même.
La flow-control QUIC est respectée naturellement par l'écriture asynchrone. Sur le chemin natif, un send qui dépasse sa deadline est considéré terminal : le stream est reset et le même sender ne doit pas être réutilisé. Sur le chemin navigateur de `alpha.6`, une cancellation du send reste terminale et reset le stream, mais le timer `send_timeout` est différé à `alpha.7`.
La flow-control QUIC est respectée naturellement par l'écriture asynchrone. Sur les chemins natif et navigateur, un send qui dépasse sa deadline est considéré terminal : le stream est reset et le même sender ne doit pas être réutilisé.
## Fermeture et abort

View File

@@ -1,10 +1,12 @@
// file: crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs
// version: 1
// version: 2
const CERTIFICATE_HASH_SIZE: usize = 32;
const FRAME_PROTOCOL_ERROR_CODE: u32 = 0x10;
const FRAME_TOO_LARGE_ERROR_CODE: u32 = 0x11;
const PRIMARY_FRAME_HEADER_SIZE: usize = 4;
const SEND_FAILURE_ERROR_CODE: u32 = 0x12;
const SEND_TIMEOUT_ERROR_CODE: u32 = 0x13;
const STREAM_CANCELLED_ERROR_CODE: u32 = 0x14;
const TRACING_TARGET: &str = "games::realtime::webtransport";
@@ -54,8 +56,7 @@ impl WebTransportClientConfig {
/// Returns a copy with explicit reliable-path limits and deadlines.
///
/// The browser compile path applies the message-size limit immediately. Browser-side operation deadlines are
/// validated here but remain a runtime-interoperability concern for the dedicated browser smoke tranche.
/// The browser path applies the message-size limit and operation deadlines to connect, primary-stream open and send.
#[must_use]
pub fn with_transport_config(mut self, transport: crate::WebTransportConfig) -> Self {
self.transport = transport;
@@ -94,13 +95,24 @@ impl WebTransportSession {
/// Opens the single primary bidirectional stream and adapts it to the transport-neutral realtime contract.
pub async fn open_primary_connection(self) -> Result<WebTransportConnection, game_realtime_transport_lib::TransportError> {
let (sender, receiver) = match self.inner.open_bi().await {
let timeout = self.transport.primary_stream_timeout();
let timeout_millis = match browser_timeout_millis(timeout, "primary_stream_timeout") {
Ok(value) => value,
Err(error) => {
Err(error) => return Err(error),
};
let opened = await_with_timeout(self.inner.open_bi(), timeout_millis).await;
let (sender, receiver) = match opened {
Some(Ok(value)) => value,
Some(Err(error)) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string());
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "browser WebTransport primary bidirectional stream open failed");
return Err(mapped);
},
None => {
let mapped = timeout_error("browser WebTransport primary bidirectional stream open", timeout);
tracing::warn!(target: TRACING_TARGET, timeout_ms = timeout.as_millis(), "browser WebTransport primary bidirectional stream open timed out");
return Err(mapped);
},
};
tracing::debug!(target: TRACING_TARGET, endpoint = self.inner.url().as_str(), "browser WebTransport primary bidirectional stream opened");
return Ok(WebTransportConnection::new(self.inner, sender, receiver, self.transport));
@@ -143,6 +155,7 @@ impl game_realtime_transport_lib::RealtimeConnection for WebTransportConnection
inner: self.sender,
_session: self.session,
max_message_size: self.transport.max_message_size(),
send_timeout: self.transport.send_timeout(),
terminal: false,
},
crate::WebTransportReceiver {
@@ -316,6 +329,7 @@ pub struct WebTransportSender {
inner: web_transport_wasm::SendStream,
_session: web_transport_wasm::Session,
max_message_size: usize,
send_timeout: std::time::Duration,
terminal: bool,
}
@@ -384,16 +398,33 @@ impl game_realtime_transport_lib::RealtimeSender for WebTransportSender {
Ok(value) => value,
Err(error) => return Err(error),
};
let send_timeout = self.send_timeout;
let timeout_millis = match browser_timeout_millis(send_timeout, "send_timeout") {
Ok(value) => value,
Err(error) => return Err(error),
};
let mut guard = SendOperationGuard::new(&mut self.inner, &mut self.terminal);
if let Err(error) = guard.write_frame(&frame_header, message.as_bytes()).await {
let mapped = map_write_error(error);
guard.abort();
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "browser WebTransport framed send failed");
return Err(mapped);
}
guard.disarm();
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "framed browser WebTransport payload sent");
return Ok(());
let operation = guard.write_frame(&frame_header, message.as_bytes());
let result = await_with_timeout(operation, timeout_millis).await;
return match result {
Some(Ok(())) => {
guard.complete();
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "framed browser WebTransport payload sent");
Ok(())
},
Some(Err(error)) => {
guard.abort(SEND_FAILURE_ERROR_CODE);
let mapped = map_write_error(error);
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, kind = %mapped.kind(), detail = mapped.detail(), "browser WebTransport framed send failed");
Err(mapped)
},
None => {
guard.abort(SEND_TIMEOUT_ERROR_CODE);
let mapped = timeout_error("browser WebTransport framed send", send_timeout);
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, timeout_ms = send_timeout.as_millis(), "browser WebTransport framed send timed out under flow control/backpressure");
Err(mapped)
},
};
});
}
}
@@ -409,15 +440,15 @@ impl<'a> SendOperationGuard<'a> {
return Self { inner, terminal, armed: true };
}
fn abort(&mut self) {
fn abort(&mut self, code: u32) {
if self.armed {
self.inner.reset(STREAM_CANCELLED_ERROR_CODE);
self.inner.reset(code);
*self.terminal = true;
self.armed = false;
}
}
fn disarm(&mut self) {
fn complete(&mut self) {
self.armed = false;
}
@@ -438,7 +469,7 @@ impl<'a> SendOperationGuard<'a> {
impl Drop for SendOperationGuard<'_> {
fn drop(&mut self) {
self.abort();
self.abort(STREAM_CANCELLED_ERROR_CODE);
}
}
@@ -447,13 +478,22 @@ pub async fn connect(config: &WebTransportClientConfig) -> Result<WebTransportSe
if let Err(error) = config.transport.validate() {
return Err(error);
}
if let Err(error) = validate_browser_deadlines(config.transport) {
return Err(error);
}
let client = web_transport_wasm::ClientBuilder::new()
.with_pooling(false)
.with_unreliable(true)
.with_server_certificate_hashes(vec![config.certificate_hash.as_bytes().to_vec()]);
let session = match client.connect(config.endpoint.clone()).await {
let timeout = config.transport.connect_timeout();
let timeout_millis = match browser_timeout_millis(timeout, "connect_timeout") {
Ok(value) => value,
Err(error) => {
Err(error) => return Err(error),
};
let connected = await_with_timeout(client.connect(config.endpoint.clone()), timeout_millis).await;
let session = match connected {
Some(Ok(value)) => value,
Some(Err(error)) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Connect, error.to_string());
tracing::warn!(
target: TRACING_TARGET,
@@ -463,11 +503,56 @@ pub async fn connect(config: &WebTransportClientConfig) -> Result<WebTransportSe
);
return Err(mapped);
},
None => {
let mapped = timeout_error("browser WebTransport client connect", timeout);
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), timeout_ms = timeout.as_millis(), "browser WebTransport client connection timed out");
return Err(mapped);
},
};
tracing::info!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), "browser WebTransport client connected");
return Ok(WebTransportSession::new(session, config.transport));
}
async fn await_with_timeout<F>(operation: F, timeout_millis: u32) -> Option<F::Output>
where
F: core::future::Future,
{
let operation = Box::pin(operation);
let timeout = Box::pin(gloo_timers::future::TimeoutFuture::new(timeout_millis));
return match futures_util::future::select(operation, timeout).await {
futures_util::future::Either::Left((output, _)) => Some(output),
futures_util::future::Either::Right(((), _)) => None,
};
}
fn browser_timeout_millis(duration: std::time::Duration, name: &str) -> Result<u32, game_realtime_transport_lib::TransportError> {
return match u32::try_from(duration.as_millis()) {
Ok(value) if value > 0 => Ok(value),
Ok(_) => Err(invalid_configuration(format!("{name} must resolve to at least one browser timer millisecond"))),
Err(_) => Err(invalid_configuration(format!("{name} exceeds the browser timer range of u32 milliseconds"))),
};
}
fn timeout_error(operation: &str, timeout: std::time::Duration) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::Timeout,
format!("{operation} exceeded configured deadline of {} ms", timeout.as_millis()),
);
}
fn validate_browser_deadlines(config: crate::WebTransportConfig) -> Result<(), game_realtime_transport_lib::TransportError> {
if let Err(error) = browser_timeout_millis(config.connect_timeout(), "connect_timeout") {
return Err(error);
}
if let Err(error) = browser_timeout_millis(config.primary_stream_timeout(), "primary_stream_timeout") {
return Err(error);
}
if let Err(error) = browser_timeout_millis(config.send_timeout(), "send_timeout") {
return Err(error);
}
return Ok(());
}
fn frame_header(payload_len: usize, max_message_size: usize) -> Result<[u8; PRIMARY_FRAME_HEADER_SIZE], game_realtime_transport_lib::TransportError> {
if payload_len > max_message_size {
return Err(message_too_large(payload_len, max_message_size));