0.3.5-alpha.7
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# file: crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "game-realtime-webtransport-browser-smoke"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
|
||||
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
game-logging-lib = { path = "../../common/game-logging-lib" }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen.workspace = true
|
||||
wasm-bindgen-futures.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,96 @@
|
||||
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/browser.rs
|
||||
// version: 1
|
||||
|
||||
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 CERTIFICATE_HASH_HEX_SIZE: usize = 64;
|
||||
const CERTIFICATE_HASH_SIZE: usize = 32;
|
||||
|
||||
/// Runs one real-browser WebTransport round-trip against the supplied pinned endpoint.
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub async fn run_browser_smoke(endpoint: String, certificate_sha256_hex: String) -> Result<String, wasm_bindgen::JsValue> {
|
||||
let certificate_hash = match decode_certificate_hash(certificate_sha256_hex.as_str()) {
|
||||
Ok(value) => game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256(value),
|
||||
Err(error) => return Err(wasm_bindgen::JsValue::from_str(error.as_str())),
|
||||
};
|
||||
let transport = game_realtime_webtransport_lib::WebTransportConfig::default()
|
||||
.with_connect_timeout(std::time::Duration::from_secs(5))
|
||||
.with_primary_stream_timeout(std::time::Duration::from_secs(5))
|
||||
.with_send_timeout(std::time::Duration::from_secs(5));
|
||||
let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||
Ok(value) => value.with_transport_config(transport),
|
||||
Err(error) => return Err(js_transport_error("browser client configuration failed", error)),
|
||||
};
|
||||
let session = match game_realtime_webtransport_lib::connect(&config).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(js_transport_error("browser client connection failed", error)),
|
||||
};
|
||||
let connection = match session.open_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(js_transport_error("browser primary stream open failed", error)),
|
||||
};
|
||||
let (mut sender, mut receiver) = connection.split();
|
||||
if let Err(error) = sender.send(game_realtime_transport_lib::TransportMessage::new(crate::shared::BROWSER_PAYLOAD.to_vec())).await {
|
||||
return Err(js_transport_error("browser send failed", error));
|
||||
}
|
||||
let received = match receiver.receive().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(js_transport_error("browser receive failed", error)),
|
||||
};
|
||||
if !receive_matches(received, crate::shared::SERVER_PAYLOAD) {
|
||||
return Err(wasm_bindgen::JsValue::from_str("browser did not receive the expected server payload"));
|
||||
}
|
||||
if let Err(error) = sender.close().await {
|
||||
return Err(js_transport_error("browser close failed", error));
|
||||
}
|
||||
let remote_close = match receiver.receive().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(js_transport_error("browser close observation failed", error)),
|
||||
};
|
||||
if remote_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||
return Err(wasm_bindgen::JsValue::from_str("browser did not observe the server FIN"));
|
||||
}
|
||||
return Ok(String::from("game-realtime-webtransport-browser-smoke: PASS"));
|
||||
}
|
||||
|
||||
fn decode_certificate_hash(value: &str) -> Result<[u8; CERTIFICATE_HASH_SIZE], String> {
|
||||
let encoded = value.as_bytes();
|
||||
if encoded.len() != CERTIFICATE_HASH_HEX_SIZE {
|
||||
return Err(String::from("certificate SHA-256 hash must contain exactly 64 hexadecimal characters"));
|
||||
}
|
||||
let mut decoded = [0_u8; CERTIFICATE_HASH_SIZE];
|
||||
for index in 0..CERTIFICATE_HASH_SIZE {
|
||||
let high = match hex_nibble(encoded[index * 2]) {
|
||||
Some(value) => value,
|
||||
None => return Err(String::from("certificate SHA-256 hash contains a non-hexadecimal character")),
|
||||
};
|
||||
let low = match hex_nibble(encoded[index * 2 + 1]) {
|
||||
Some(value) => value,
|
||||
None => return Err(String::from("certificate SHA-256 hash contains a non-hexadecimal character")),
|
||||
};
|
||||
decoded[index] = (high << 4) | low;
|
||||
}
|
||||
return Ok(decoded);
|
||||
}
|
||||
|
||||
fn hex_nibble(value: u8) -> Option<u8> {
|
||||
return match value {
|
||||
b'0'..=b'9' => Some(value - b'0'),
|
||||
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
fn js_transport_error(context: &str, error: game_realtime_transport_lib::TransportError) -> wasm_bindgen::JsValue {
|
||||
return wasm_bindgen::JsValue::from_str(format!("{context}: {error}").as_str());
|
||||
}
|
||||
|
||||
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||
return match receive {
|
||||
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! WebAssembly adapter for the real-browser WebTransport smoke.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod shared;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
/// Re-export of the browser smoke entry point exposed through wasm-bindgen.
|
||||
pub use self::browser::run_browser_smoke;
|
||||
144
crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
Normal file
144
crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Native peer for the real-browser WebTransport smoke.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod shared;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const BROWSER_HOST_URL: &str = "http://127.0.0.1:1435/main.html";
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const BROWSER_SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const TRACING_TARGET: &str = "games::realtime::webtransport::browser-smoke";
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> std::process::ExitCode {
|
||||
let _logging_guard = match game_logging_lib::init_console_tracing() {
|
||||
std::result::Result::Ok(guard) => guard,
|
||||
std::result::Result::Err(error) => {
|
||||
eprintln!("failed to initialize browser WebTransport smoke tracing: {error}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
},
|
||||
};
|
||||
let prepared = match prepare_listener() {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "browser WebTransport smoke preparation failed");
|
||||
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: {error}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
},
|
||||
};
|
||||
let (mut listener, endpoint, certificate_hash_hex) = prepared;
|
||||
let browser_url = format!("{BROWSER_HOST_URL}?endpoint={endpoint}&sha256={certificate_hash_hex}");
|
||||
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "browser WebTransport smoke peer ready");
|
||||
println!("game-realtime-webtransport-browser-smoke: ENDPOINT={endpoint}");
|
||||
println!("game-realtime-webtransport-browser-smoke: CERT_SHA256={certificate_hash_hex}");
|
||||
println!("game-realtime-webtransport-browser-smoke: OPEN={browser_url}");
|
||||
let result = tokio::time::timeout(BROWSER_SMOKE_TIMEOUT, serve_browser(&mut listener)).await;
|
||||
return match result {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(target: TRACING_TARGET, "browser WebTransport smoke passed");
|
||||
println!("game-realtime-webtransport-browser-smoke: PASS");
|
||||
std::process::ExitCode::SUCCESS
|
||||
},
|
||||
Ok(Err(error)) => {
|
||||
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "browser WebTransport smoke failed");
|
||||
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: {error}");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
Err(_) => {
|
||||
tracing::error!(target: TRACING_TARGET, timeout_ms = BROWSER_SMOKE_TIMEOUT.as_millis(), "browser WebTransport smoke timed out");
|
||||
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: browser did not complete the smoke before the launcher deadline");
|
||||
std::process::ExitCode::FAILURE
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_listener() -> Result<(game_realtime_webtransport_lib::WebTransportListener, String, String), String> {
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(format!("loopback identity generation failed: {error}")),
|
||||
};
|
||||
let certificate_hash_hex = encode_hex(identity.certificate_hash().as_bytes());
|
||||
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
|
||||
let listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(format!("listener bind failed: {error}")),
|
||||
};
|
||||
let endpoint = format!("https://{}/browser-smoke", listener.local_addr());
|
||||
return Ok((listener, endpoint, certificate_hash_hex));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn serve_browser(listener: &mut game_realtime_webtransport_lib::WebTransportListener) -> Result<(), String> {
|
||||
let session = match listener.accept().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(format!("server accept failed: {error}")),
|
||||
};
|
||||
let connection = match session.accept_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(format!("server primary stream accept failed: {error}")),
|
||||
};
|
||||
let (mut sender, mut receiver) = connection.split();
|
||||
let browser_received = match receiver.receive().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(format!("server receive failed: {error}")),
|
||||
};
|
||||
if !receive_matches(browser_received, shared::BROWSER_PAYLOAD) {
|
||||
return Err(String::from("server did not receive the expected browser payload"));
|
||||
}
|
||||
if let Err(error) = sender.send(game_realtime_transport_lib::TransportMessage::new(shared::SERVER_PAYLOAD.to_vec())).await {
|
||||
return Err(format!("server send failed: {error}"));
|
||||
}
|
||||
let browser_close = match receiver.receive().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(format!("server close observation failed: {error}")),
|
||||
};
|
||||
if browser_close != game_realtime_transport_lib::TransportReceive::Closed {
|
||||
return Err(String::from("server did not observe the browser FIN"));
|
||||
}
|
||||
if let Err(error) = sender.close().await {
|
||||
return Err(format!("server close failed: {error}"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn encode_hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut encoded = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
|
||||
return match receive {
|
||||
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
|
||||
game_realtime_transport_lib::TransportReceive::Closed => false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/shared.rs
|
||||
// version: 1
|
||||
|
||||
/// Binary payload sent from the real browser to the native smoke peer.
|
||||
pub(crate) const BROWSER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-browser-client-smoke";
|
||||
/// Binary payload sent from the native smoke peer back to the real browser.
|
||||
pub(crate) const SERVER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-browser-server-smoke";
|
||||
@@ -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]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user