0.3.5-alpha.9

This commit is contained in:
2026-09-22 08:32:36 +02:00
parent 17d9e18dcd
commit 9f1a1c3d06
13 changed files with 638 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: crates/common/game-realtime-webtransport-lib/README.md -->
<!-- version: 5 -->
<!-- version: 6 -->
# game-realtime-webtransport-lib
@@ -29,6 +29,7 @@ La frontière fiable disponible couvre désormais :
- tracing sous `games::realtime::webtransport`;
- client WASM avec endpoint HTTPS, hash certificat SHA-256 explicite, établissement de session navigateur et ouverture du stream bidirectionnel primaire;
- adaptation WASM du framing `u32` big-endian et des traits realtime, y compris FIN, reset/STOP et réception incrémentale.
- capacité datagram WebTransport backend-spécifique sur les sessions native et navigateur, sans extension de `RealtimeConnection`.
## Chemin navigateur/WASM
@@ -40,6 +41,16 @@ Le pin SHA-256 est transmis à `WebTransportOptions.serverCertificateHashes`; au
À partir de `alpha.7`, les deadlines `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont aussi matérialisées côté navigateur par des timers WASM. Une deadline navigateur doit tenir dans la plage `u32` millisecondes ; une valeur supérieure est rejetée comme `InvalidConfiguration`. Une réception idle reste volontairement sans timeout implicite, comme sur le chemin natif. Le smoke navigateur technique séparé prouve ensuite cette surface avec un vrai navigateur sans fallback WebSocket.
## Datagrams backend-spécifiques
`WebTransportSession` expose `max_datagram_size()`, `send_datagram(...)` et `receive_datagram()` sur les chemins natif et navigateur. Cette surface reste volontairement propre au backend WebTransport : un datagram n'est ni fiable ni ordonné et ne peut donc pas satisfaire le contrat `RealtimeConnection`.
La taille maximale dépend de la session et du chemin réseau. Le backend rejette localement un payload supérieur à `max_datagram_size()` avec `TransportErrorKind::MessageTooLarge`; cette valeur peut évoluer entre sessions et ne doit pas être traitée comme une constante produit.
`receive_datagram()` n'impose aucun timeout. Le consommateur doit borner l'attente selon son scénario. Le smoke `game-realtime-webtransport-datagram-smoke` utilise une deadline locale précisément parce que la perte d'un datagram est un résultat autorisé par le protocole. Il vérifie un échange loopback dans les deux sens sans en déduire de garantie de livraison ou d'ordre.
Le chemin navigateur compile la même capacité via `web-transport-wasm`; `alpha.9` ne réouvre pas le smoke navigateur interactif et limite la preuve runtime datagram au backend natif.
## TLS de développement
`WebTransportServerIdentity::generate_loopback()` crée une identité en mémoire. La clé privée n'est ni écrite ni versionnée. Le certificat est valide sept jours, avec une petite marge de clock skew, et son SHA-256 est exposé à travers `WebTransportCertificateHash` afin que le client puisse utiliser le pinning fourni par `web-transport-quinn`.

View File

@@ -1,9 +1,9 @@
<!-- file: crates/common/game-realtime-webtransport-lib/USAGE.md -->
<!-- version: 4 -->
<!-- version: 5 -->
# Utilisation de game-realtime-webtransport-lib
Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés par `game-realtime-webtransport-lib`. Il ne décrit ni gameplay, ni protocole wire métier, ni datagrams.
Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés par `game-realtime-webtransport-lib`, ainsi que la capacité datagram backend-spécifique. Il ne décrit ni gameplay ni protocole wire métier.
## Configuration transport
@@ -157,3 +157,25 @@ Ces deux méthodes sont backend-spécifiques : elles ne sont pas ajoutées au co
Dropper un sender actif ou annuler une future `send()` en cours provoque un reset explicite. Dropper un receiver actif provoque un stop explicite. Cela évite qu'une cancellation d'écriture partielle soit interprétée comme une fermeture propre ou qu'une frame suivante reprenne au mauvais offset.
Une future `receive()` peut en revanche être annulée puis relancée : le backend conserve l'état partiel du header/payload et reprend le framing à l'octet correct.
## Datagrams WebTransport
Les datagrams sont une capacité propre à `WebTransportSession`; ils ne passent pas par `RealtimeConnection`. Toujours consulter la taille courante avant émission :
```rust
let max_payload = session.max_datagram_size();
if payload.len() <= max_payload {
session.send_datagram(payload)?;
}
```
La réception est asynchrone :
```rust
let payload = session.receive_datagram().await?;
```
Ces appels ne donnent aucune garantie de livraison ni d'ordre. Une perte n'est pas une violation du transport datagram. Toute deadline de réception appartient donc au scénario consommateur ; elle n'est pas injectée dans `WebTransportConfig`.
Le chemin navigateur expose les mêmes noms, avec `send_datagram(...).await`. La surface reste backend-spécifique afin de ne pas imposer une sémantique non fiable à WebSocket ou au contrat fiable commun.

View File

@@ -1,5 +1,5 @@
// file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs
// version: 4
// version: 5
const CERTIFICATE_HASH_SIZE: usize = 32;
const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60);
@@ -250,6 +250,36 @@ impl WebTransportSession {
None => None,
};
}
/// Returns the maximum payload size accepted by the backend-specific unreliable datagram path.
#[must_use]
pub fn max_datagram_size(&self) -> usize {
return self.inner.max_datagram_size();
}
/// Sends one backend-specific unreliable and unordered WebTransport datagram.
///
/// This capability is intentionally not part of `RealtimeConnection`; delivery and ordering are not guaranteed.
pub fn send_datagram(&self, payload: &[u8]) -> Result<(), game_realtime_transport_lib::TransportError> {
let max_datagram_size = self.max_datagram_size();
if payload.len() > max_datagram_size {
return Err(datagram_too_large(payload.len(), max_datagram_size));
}
return match self.inner.send_datagram(payload.to_vec().into()) {
Ok(()) => Ok(()),
Err(error) => Err(transport_error(map_session_error_kind(&error), error.to_string())),
};
}
/// Receives one backend-specific unreliable and unordered WebTransport datagram.
///
/// The caller is responsible for applying any operation deadline required by its use case.
pub async fn receive_datagram(&self) -> Result<Vec<u8>, game_realtime_transport_lib::TransportError> {
return match self.inner.read_datagram().await {
Ok(payload) => Ok(payload.to_vec()),
Err(error) => Err(transport_error(map_session_error_kind(&error), error.to_string())),
};
}
}
/// Established WebTransport realtime connection carried by one primary reliable bidirectional stream.
@@ -771,6 +801,13 @@ fn map_write_error(error: web_transport_quinn::WriteError) -> game_realtime_tran
return transport_error(kind, error.to_string());
}
fn datagram_too_large(payload_len: usize, max_datagram_size: usize) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
format!("WebTransport datagram payload size {payload_len} exceeds current session maximum {max_datagram_size} bytes"),
);
}
fn message_too_large(payload_len: usize, max_message_size: usize) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,

View File

@@ -1,5 +1,5 @@
// file: crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs
// version: 2
// version: 3
const CERTIFICATE_HASH_SIZE: usize = 32;
const FRAME_PROTOCOL_ERROR_CODE: u32 = 0x10;
@@ -123,6 +123,36 @@ impl WebTransportSession {
pub fn endpoint(&self) -> &str {
return self.inner.url().as_str();
}
/// Returns the maximum payload size accepted by the browser WebTransport datagram path.
#[must_use]
pub fn max_datagram_size(&self) -> usize {
return self.inner.max_datagram_size();
}
/// Sends one browser WebTransport datagram without reliability or ordering guarantees.
///
/// This capability is intentionally not part of `RealtimeConnection`.
pub async fn send_datagram(&self, payload: &[u8]) -> Result<(), game_realtime_transport_lib::TransportError> {
let max_datagram_size = self.max_datagram_size();
if payload.len() > max_datagram_size {
return Err(datagram_too_large(payload.len(), max_datagram_size));
}
return match self.inner.send_datagram(payload.to_vec().into()).await {
Ok(()) => Ok(()),
Err(error) => Err(map_write_error(error)),
};
}
/// Receives one browser WebTransport datagram without reliability or ordering guarantees.
///
/// The caller is responsible for applying any operation deadline required by its use case.
pub async fn receive_datagram(&self) -> Result<Vec<u8>, game_realtime_transport_lib::TransportError> {
return match self.inner.recv_datagram().await {
Ok(payload) => Ok(payload.to_vec()),
Err(error) => Err(map_read_error(error)),
};
}
}
/// Established browser WebTransport connection carrying the single reliable primary stream.
@@ -588,6 +618,13 @@ fn map_write_error(error: web_transport_wasm::Error) -> game_realtime_transport_
return transport_error(kind, error.to_string());
}
fn datagram_too_large(payload_len: usize, max_datagram_size: usize) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
format!("WebTransport datagram payload size {payload_len} exceeds current session maximum {max_datagram_size} bytes"),
);
}
fn message_too_large(payload_len: usize, max_message_size: usize) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,

View File

@@ -0,0 +1,109 @@
// file: crates/common/game-realtime-webtransport-lib/tests/datagrams.rs
// version: 1
#![cfg(not(target_arch = "wasm32"))]
const CLIENT_DATAGRAM: &[u8] = b"client-datagram";
const RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
const SERVER_DATAGRAM: &[u8] = b"server-datagram";
#[tokio::test(flavor = "current_thread")]
async fn backend_specific_datagrams_round_trip_without_entering_the_common_contract() {
let identity_result = game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback();
assert!(identity_result.is_ok());
let identity = match identity_result {
Ok(value) => value,
Err(error) => panic!("identity generation failed unexpectedly: {error}"),
};
let certificate_hash = identity.certificate_hash().clone();
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
let listener_result = game_realtime_webtransport_lib::WebTransportListener::bind(server_config);
assert!(listener_result.is_ok());
let mut listener = match listener_result {
Ok(value) => value,
Err(error) => panic!("listener bind failed unexpectedly: {error}"),
};
let endpoint = format!("https://{}/datagrams", listener.local_addr());
let client_config_result = game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash);
assert!(client_config_result.is_ok());
let client_config = match client_config_result {
Ok(value) => value,
Err(error) => panic!("client config failed unexpectedly: {error}"),
};
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
assert!(server_result.is_ok());
assert!(client_result.is_ok());
let server_session = match server_result {
Ok(value) => value,
Err(error) => panic!("server accept failed unexpectedly: {error}"),
};
let client_session = match client_result {
Ok(value) => value,
Err(error) => panic!("client connect failed unexpectedly: {error}"),
};
assert!(client_session.max_datagram_size() >= CLIENT_DATAGRAM.len());
assert!(server_session.max_datagram_size() >= SERVER_DATAGRAM.len());
assert!(client_session.send_datagram(CLIENT_DATAGRAM).is_ok());
let server_receive = tokio::time::timeout(RECEIVE_TIMEOUT, server_session.receive_datagram()).await;
assert!(server_receive.is_ok());
let server_payload = match server_receive {
Ok(Ok(value)) => value,
Ok(Err(error)) => panic!("server datagram receive failed unexpectedly: {error}"),
Err(_) => panic!("server datagram receive timed out"),
};
assert_eq!(server_payload.as_slice(), CLIENT_DATAGRAM);
assert!(server_session.send_datagram(SERVER_DATAGRAM).is_ok());
let client_receive = tokio::time::timeout(RECEIVE_TIMEOUT, client_session.receive_datagram()).await;
assert!(client_receive.is_ok());
let client_payload = match client_receive {
Ok(Ok(value)) => value,
Ok(Err(error)) => panic!("client datagram receive failed unexpectedly: {error}"),
Err(_) => panic!("client datagram receive timed out"),
};
assert_eq!(client_payload.as_slice(), SERVER_DATAGRAM);
}
#[tokio::test(flavor = "current_thread")]
async fn oversized_datagram_is_rejected_before_backend_send() {
let identity_result = game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback();
assert!(identity_result.is_ok());
let identity = match identity_result {
Ok(value) => value,
Err(error) => panic!("identity generation failed unexpectedly: {error}"),
};
let certificate_hash = identity.certificate_hash().clone();
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
let listener_result = game_realtime_webtransport_lib::WebTransportListener::bind(server_config);
assert!(listener_result.is_ok());
let mut listener = match listener_result {
Ok(value) => value,
Err(error) => panic!("listener bind failed unexpectedly: {error}"),
};
let endpoint = format!("https://{}/datagrams-limit", listener.local_addr());
let client_config_result = game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash);
assert!(client_config_result.is_ok());
let client_config = match client_config_result {
Ok(value) => value,
Err(error) => panic!("client config failed unexpectedly: {error}"),
};
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
assert!(server_result.is_ok());
assert!(client_result.is_ok());
let client_session = match client_result {
Ok(value) => value,
Err(error) => panic!("client connect failed unexpectedly: {error}"),
};
let oversized_len = match client_session.max_datagram_size().checked_add(1) {
Some(value) => value,
None => panic!("datagram maximum unexpectedly reached usize::MAX"),
};
assert!(oversized_len <= 1024 * 1024);
let payload = vec![0_u8; oversized_len];
let error_result = client_session.send_datagram(payload.as_slice());
assert!(error_result.is_err());
let error = match error_result {
Ok(()) => panic!("oversized datagram unexpectedly succeeded"),
Err(value) => value,
};
assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge);
}