Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48de882f70 | |||
| ce3811f8ec | |||
| fd6ffecdf5 | |||
| 5b7939cbd8 | |||
| 0077ea8b4d | |||
| 3e24b7840c |
@@ -1,5 +1,5 @@
|
||||
# file: Cargo.toml
|
||||
# version: 94
|
||||
# version: 100
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"crates/common/game-logging-lib",
|
||||
"crates/common/game-realtime-transport-lib",
|
||||
"crates/common/game-realtime-websocket-lib",
|
||||
"crates/common/game-realtime-webtransport-lib",
|
||||
"crates/apps/game-android-entrypoint",
|
||||
"crates/apps/game-reflex-poc-tauri",
|
||||
"crates/apps/game-reflex-poc-wasm",
|
||||
@@ -24,7 +25,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.4"
|
||||
version = "0.3.5-alpha.4.fix.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/games"
|
||||
@@ -33,6 +34,7 @@ publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
futures-util = { version = "0.3.34", default-features = false }
|
||||
rcgen = { version = "0.14.10", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sdl3 = "^0.20"
|
||||
tracing = "0.1.44"
|
||||
@@ -44,7 +46,9 @@ tauri-build = "2"
|
||||
tauri-plugin-tracing = "^0.3"
|
||||
tokio = "1.53.1"
|
||||
tokio-tungstenite = { version = "0.30.0", default-features = false }
|
||||
url = "2.5.8"
|
||||
wasm-bindgen = "0.2"
|
||||
web-transport-quinn = { version = "0.12.1", default-features = false }
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: README.md -->
|
||||
<!-- version: 62 -->
|
||||
<!-- version: 68 -->
|
||||
|
||||
# games.sasedev
|
||||
|
||||
@@ -27,9 +27,9 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
|
||||
|
||||
Version stable de référence : `0.3.4`.
|
||||
|
||||
Prochaine version planifiée : `0.3.5-alpha.1`. `0.3.2` reste différée.
|
||||
Version active : `0.3.5-alpha.4.fix.1`. `0.3.2` reste différée.
|
||||
|
||||
La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. La RC a été validée sans défaut de publication, puis promue mécaniquement en stable. `0.3.5` est réservée au POC WebTransport/QUIC et à sa comparaison avec WebSocket, sans rouvrir dans `0.3.4` les couches wire/session/synchronisation.
|
||||
La stable `0.3.4` livre la première baseline realtime : contrat binaire transport-neutral, backend WebSocket Tokio/tokio-tungstenite, limites et deadlines, tests loopback/robustesse, smoke runtime localhost public et frontières de dépendances empêchant moteurs et gameplay de dépendre d'un backend concret. `0.3.5-alpha.4.fix.1` consolide le backend WebTransport natif avec limite de message configurable, deadlines connexion/stream/send, mapping d'erreurs, reset/abort/drop explicites et parseur de framing incrémental résistant à l'annulation ; le fix ferme uniquement le lint strict `implicit-return` révélé par la gate de `alpha.4` sans changer le comportement runtime. Navigateur/WASM, smoke public, fallback et datagrams restent réservés aux tranches suivantes.
|
||||
|
||||
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.
|
||||
|
||||
@@ -45,4 +45,4 @@ Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-sna
|
||||
|
||||
## Diagnostics et tests
|
||||
|
||||
Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, et `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite ; leurs responsabilités et leur consommation sont documentées dans leurs README/USAGE locaux. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`.
|
||||
Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. Le realtime est séparé entre `game-realtime-transport-lib`, contrat binaire transport-neutral, `game-realtime-websocket-lib`, backend Tokio/tokio-tungstenite, et `game-realtime-webtransport-lib`, backend WebTransport/QUIC candidat dont le chemin natif couvre désormais TLS/pinning, établissement de session et stream fiable principal adapté au contrat commun. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` fournit la preuve runtime localhost hors harness de test ; le smoke WebTransport public est planifié après la tranche de robustesse. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests d’intégration/environnement résident sous `tests/`.
|
||||
|
||||
25
crates/common/game-realtime-webtransport-lib/Cargo.toml
Normal file
25
crates/common/game-realtime-webtransport-lib/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
# file: crates/common/game-realtime-webtransport-lib/Cargo.toml
|
||||
# version: 2
|
||||
|
||||
[package]
|
||||
name = "game-realtime-webtransport-lib"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
game-realtime-transport-lib = { path = "../game-realtime-transport-lib" }
|
||||
rcgen = { workspace = true, features = ["ring"] }
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
web-transport-quinn = { workspace = true, features = ["ring"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
110
crates/common/game-realtime-webtransport-lib/README.md
Normal file
110
crates/common/game-realtime-webtransport-lib/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
<!-- file: crates/common/game-realtime-webtransport-lib/README.md -->
|
||||
<!-- version: 3 -->
|
||||
|
||||
# game-realtime-webtransport-lib
|
||||
|
||||
Backend WebTransport/QUIC candidat pour le realtime de `games.sasedev`.
|
||||
|
||||
## Responsabilité
|
||||
|
||||
La crate possède le transport WebTransport concret sans introduire de sémantique gameplay, room, joueur, tick ou snapshot. Son chemin natif repose sur `web-transport-quinn` et conserve les erreurs publiques dans `game-realtime-transport-lib`.
|
||||
|
||||
La frontière native disponible couvre désormais :
|
||||
|
||||
- configuration client HTTPS avec pin SHA-256 exact ;
|
||||
- identité serveur X.509 DER + clé privée PKCS#8 DER injectables ;
|
||||
- génération locale d'une identité self-signed ECDSA P-256 à validité courte pour `localhost`, IPv4 loopback et IPv6 loopback ;
|
||||
- bind UDP/QUIC sur adresse explicite ou port éphémère ;
|
||||
- établissement HTTP/3 WebTransport client/server natif ;
|
||||
- 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 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(...)` ;
|
||||
- cancellation/drop terminale : un sender abandonné est reset plutôt que transformé implicitement en FIN ;
|
||||
- parseur de framing réception incrémental conservant son état si une future `receive()` est annulée ;
|
||||
- mapping stable des erreurs reset/close/session/protocole vers `TransportErrorKind` ;
|
||||
- tracing sous `games::realtime::webtransport`.
|
||||
|
||||
## 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`.
|
||||
|
||||
Une identité préexistante peut être injectée en DER avec `WebTransportServerIdentity::from_pkcs8_der(...)`. La compatibilité certificat/clé est alors vérifiée par le builder TLS au bind du serveur.
|
||||
|
||||
Aucune option de désactivation globale de la vérification TLS n'est exposée.
|
||||
|
||||
## Stream fiable principal
|
||||
|
||||
Une session WebTransport établie n'est pas encore le contrat realtime lui-même. Le client appelle `WebTransportSession::open_primary_connection()` ; le serveur appelle `WebTransportSession::accept_primary_connection()`.
|
||||
|
||||
Le chemin logique devient ensuite :
|
||||
|
||||
```text
|
||||
one WebTransport session
|
||||
-> one primary bidirectional reliable stream
|
||||
-> u32 big-endian payload length
|
||||
-> payload bytes
|
||||
```
|
||||
|
||||
`open_primary_connection()` écrit déjà l'en-tête de stream WebTransport requis par HTTP/3 avant de retourner. Le pair peut donc terminer `accept_primary_connection()` avant l'envoi de la première frame applicative ; aucun préambule propre à games.sasedev n'est nécessaire.
|
||||
|
||||
`RealtimeConnection::split()` conserve la session WebTransport dans les deux moitiés afin que la session ne soit pas fermée au moment où l'objet connexion est consommé.
|
||||
|
||||
## Limites et deadlines
|
||||
|
||||
`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.
|
||||
|
||||
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 ;
|
||||
- écriture complète header + payload d'une frame.
|
||||
|
||||
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 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.
|
||||
|
||||
## Lifecycle, abort et cancellation
|
||||
|
||||
`RealtimeSender::close()` reste la fermeture propre de la direction d'émission et produit un FIN. À l'inverse :
|
||||
|
||||
- `WebTransportSender::abort(code)` envoie un `RESET_STREAM` WebTransport ;
|
||||
- `WebTransportReceiver::abort(code)` envoie un `STOP_SENDING` WebTransport ;
|
||||
- dropper un `WebTransportSender` encore actif provoque un reset explicite ;
|
||||
- dropper un `WebTransportReceiver` encore actif provoque un stop explicite ;
|
||||
- annuler une future `send()` en cours provoque également un reset via une garde de cancellation.
|
||||
|
||||
Une erreur terminale de lecture/écriture rend la moitié concernée indisponible pour une réutilisation silencieuse.
|
||||
|
||||
La réception n'utilise plus une lecture exacte monolithique. Le header et le payload sont lus progressivement avec l'API de lecture cancel-safe de Quinn ; `header_read`/`payload_read` restent dans le receiver. Une future `receive()` annulée peut donc être relancée sans perdre les octets déjà consommés ni décaler le framing.
|
||||
|
||||
## Mapping d'erreurs
|
||||
|
||||
Le backend distingue notamment :
|
||||
|
||||
- payload hors limite -> `MessageTooLarge` ;
|
||||
- reset/STOP_SENDING valide -> `Aborted` ;
|
||||
- stream déjà fermé -> `Closed` ;
|
||||
- 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`.
|
||||
|
||||
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é.
|
||||
|
||||
## Frontières actuelles
|
||||
|
||||
La crate ne possède toujours pas :
|
||||
|
||||
- d'API datagram transport-neutral ;
|
||||
- de chemin navigateur/WASM ;
|
||||
- de smoke executable public WebTransport ;
|
||||
- de fallback WebSocket ;
|
||||
- de benchmark WebSocket/WebTransport.
|
||||
|
||||
Ces responsabilités restent réservées aux tranches suivantes du plan `0.3.5`.
|
||||
|
||||
Le chemin natif s'exécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Le chemin navigateur/WASM est distinct : aucun `cfg` WASM ni dépendance navigateur n'est requis par le backend natif actuel.
|
||||
130
crates/common/game-realtime-webtransport-lib/USAGE.md
Normal file
130
crates/common/game-realtime-webtransport-lib/USAGE.md
Normal file
@@ -0,0 +1,130 @@
|
||||
<!-- file: crates/common/game-realtime-webtransport-lib/USAGE.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Utilisation de game-realtime-webtransport-lib
|
||||
|
||||
Ce guide décrit le chemin natif fiable exposé par `game-realtime-webtransport-lib`. Il ne décrit ni gameplay, ni protocole wire métier, ni datagrams.
|
||||
|
||||
## Configuration transport
|
||||
|
||||
`WebTransportConfig` porte les limites et deadlines du chemin fiable. La configuration par défaut garde une limite de 1 MiB par message et des deadlines bornées pour connexion, stream primaire et send.
|
||||
|
||||
Exemple de configuration plus stricte :
|
||||
|
||||
```rust
|
||||
let transport = game_realtime_webtransport_lib::WebTransportConfig::default()
|
||||
.with_max_message_size(256 * 1024)
|
||||
.with_connect_timeout(std::time::Duration::from_secs(5))
|
||||
.with_primary_stream_timeout(std::time::Duration::from_secs(2))
|
||||
.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`.
|
||||
|
||||
## Serveur natif
|
||||
|
||||
Créer d'abord l'identité TLS et le listener :
|
||||
|
||||
```rust
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let config = game_realtime_webtransport_lib::WebTransportServerConfig::new(
|
||||
std::net::SocketAddr::from(([127, 0, 0, 1], 4433)),
|
||||
identity,
|
||||
)
|
||||
.with_transport_config(transport);
|
||||
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let session = match listener.accept().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let connection = match session.accept_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
L'attente du prochain client dans `listener.accept()` n'a pas de timeout périodique. Une fois une requête WebTransport surfacée, sa réponse finale utilise la deadline de connexion configurée.
|
||||
|
||||
`open_primary_connection()` écrit l'en-tête WebTransport requis pour identifier le stream avant de retourner. Le serveur peut donc attendre `accept_primary_connection()` puis commencer les échanges applicatifs ; aucune frame artificielle n'est nécessaire pour rendre le stream visible.
|
||||
|
||||
## Client natif
|
||||
|
||||
Le client doit connaître le SHA-256 exact du certificat serveur :
|
||||
|
||||
```rust
|
||||
let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(
|
||||
"https://127.0.0.1:4433/game",
|
||||
certificate_hash,
|
||||
) {
|
||||
Ok(value) => value.with_transport_config(transport),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let session = match game_realtime_webtransport_lib::connect(&config).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let connection = match session.open_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
```
|
||||
|
||||
Le pinning est obligatoire dans cette API native ; il n'existe pas de variante qui désactive globalement la vérification TLS.
|
||||
|
||||
## Contrat realtime
|
||||
|
||||
Une fois le stream primaire sélectionné, utiliser les traits de `game-realtime-transport-lib` pour le chemin fiable normal :
|
||||
|
||||
```rust
|
||||
let (mut sender, mut receiver) = game_realtime_transport_lib::RealtimeConnection::split(connection);
|
||||
|
||||
let message = game_realtime_transport_lib::TransportMessage::new(vec![1, 2, 3, 4]);
|
||||
if let Err(error) = game_realtime_transport_lib::RealtimeSender::send(&mut sender, message).await {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let received = match game_realtime_transport_lib::RealtimeReceiver::receive(&mut receiver).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut sender).await {
|
||||
return Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
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. 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
|
||||
|
||||
`RealtimeSender::close()` termine proprement la direction d'émission du stream primaire. Le pair observe ensuite `TransportReceive::Closed` lorsqu'il atteint le FIN après les messages déjà écrits.
|
||||
|
||||
Pour abandonner explicitement une direction WebTransport :
|
||||
|
||||
```rust
|
||||
if let Err(error) = sender.abort(42) {
|
||||
return Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
ou côté réception :
|
||||
|
||||
```rust
|
||||
if let Err(error) = receiver.abort(43) {
|
||||
return Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
Ces deux méthodes sont backend-spécifiques : elles ne sont pas ajoutées au contrat commun car WebSocket n'expose pas la même primitive QUIC de reset/stop.
|
||||
|
||||
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.
|
||||
109
crates/common/game-realtime-webtransport-lib/src/config.rs
Normal file
109
crates/common/game-realtime-webtransport-lib/src/config.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/src/config.rs
|
||||
// version: 1
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const DEFAULT_MAX_MESSAGE_SIZE: usize = 1024 * 1024;
|
||||
const DEFAULT_PRIMARY_STREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
const DEFAULT_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Product-facing limits and operation deadlines for the native WebTransport reliable path.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct WebTransportConfig {
|
||||
connect_timeout: std::time::Duration,
|
||||
max_message_size: usize,
|
||||
primary_stream_timeout: std::time::Duration,
|
||||
send_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for WebTransportConfig {
|
||||
fn default() -> Self {
|
||||
return Self {
|
||||
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
|
||||
max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
|
||||
primary_stream_timeout: DEFAULT_PRIMARY_STREAM_TIMEOUT,
|
||||
send_timeout: DEFAULT_SEND_TIMEOUT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl WebTransportConfig {
|
||||
/// Returns a copy with a different client/session 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 maximum framed 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 deadline for opening or accepting the primary stream.
|
||||
#[must_use]
|
||||
pub fn with_primary_stream_timeout(mut self, value: std::time::Duration) -> Self {
|
||||
self.primary_stream_timeout = value;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Returns a copy with a different deadline for one complete framed send operation.
|
||||
#[must_use]
|
||||
pub fn with_send_timeout(mut self, value: std::time::Duration) -> Self {
|
||||
self.send_timeout = value;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Returns the configured client/session handshake deadline.
|
||||
#[must_use]
|
||||
pub fn connect_timeout(&self) -> std::time::Duration {
|
||||
return self.connect_timeout;
|
||||
}
|
||||
|
||||
/// Returns the maximum framed binary message size.
|
||||
#[must_use]
|
||||
pub fn max_message_size(&self) -> usize {
|
||||
return self.max_message_size;
|
||||
}
|
||||
|
||||
/// Returns the deadline for opening or accepting the primary stream.
|
||||
#[must_use]
|
||||
pub fn primary_stream_timeout(&self) -> std::time::Duration {
|
||||
return self.primary_stream_timeout;
|
||||
}
|
||||
|
||||
/// Returns the deadline for one complete framed send operation.
|
||||
#[must_use]
|
||||
pub fn send_timeout(&self) -> std::time::Duration {
|
||||
return self.send_timeout;
|
||||
}
|
||||
|
||||
/// Validates all limits and deadlines required by the reliable WebTransport path.
|
||||
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 u32::try_from(self.max_message_size).is_err() {
|
||||
return Err(invalid_configuration("max_message_size must fit the u32 framing length field"));
|
||||
}
|
||||
if self.connect_timeout.is_zero() {
|
||||
return Err(invalid_configuration("connect_timeout must be greater than zero"));
|
||||
}
|
||||
if self.primary_stream_timeout.is_zero() {
|
||||
return Err(invalid_configuration("primary_stream_timeout must be greater than zero"));
|
||||
}
|
||||
if self.send_timeout.is_zero() {
|
||||
return Err(invalid_configuration("send_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;
|
||||
34
crates/common/game-realtime-webtransport-lib/src/lib.rs
Normal file
34
crates/common/game-realtime-webtransport-lib/src/lib.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/src/lib.rs
|
||||
// version: 3
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Native WebTransport/QUIC backend candidate for the transport-neutral realtime contract.
|
||||
|
||||
mod config;
|
||||
mod webtransport;
|
||||
|
||||
/// Re-export of product-facing limits and operation deadlines for the reliable WebTransport path.
|
||||
pub use self::config::WebTransportConfig;
|
||||
/// Re-export of the pinned SHA-256 certificate fingerprint used by the native client.
|
||||
pub use self::webtransport::WebTransportCertificateHash;
|
||||
/// Re-export of native WebTransport client configuration.
|
||||
pub use self::webtransport::WebTransportClientConfig;
|
||||
/// Re-export of an established WebTransport connection adapted to the transport-neutral realtime contract.
|
||||
pub use self::webtransport::WebTransportConnection;
|
||||
/// Re-export of the bound native WebTransport listener.
|
||||
pub use self::webtransport::WebTransportListener;
|
||||
/// Re-export of the receive half of the primary reliable WebTransport stream.
|
||||
pub use self::webtransport::WebTransportReceiver;
|
||||
/// Re-export of the send half of the primary reliable WebTransport stream.
|
||||
pub use self::webtransport::WebTransportSender;
|
||||
/// Re-export of native WebTransport server configuration.
|
||||
pub use self::webtransport::WebTransportServerConfig;
|
||||
/// Re-export of native WebTransport server TLS identity material.
|
||||
pub use self::webtransport::WebTransportServerIdentity;
|
||||
/// Re-export of an established native WebTransport session.
|
||||
pub use self::webtransport::WebTransportSession;
|
||||
/// Re-export of the native WebTransport client establishment function.
|
||||
pub use self::webtransport::connect;
|
||||
802
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
Normal file
802
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
Normal file
@@ -0,0 +1,802 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
||||
// version: 4
|
||||
|
||||
const CERTIFICATE_HASH_SIZE: usize = 32;
|
||||
const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
const LOCAL_CERTIFICATE_VALIDITY: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
|
||||
const PRIMARY_FRAME_HEADER_SIZE: usize = 4;
|
||||
const TRACING_TARGET: &str = "games::realtime::webtransport";
|
||||
|
||||
/// SHA-256 fingerprint of one certificate accepted by the native WebTransport client.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WebTransportCertificateHash {
|
||||
bytes: [u8; CERTIFICATE_HASH_SIZE],
|
||||
}
|
||||
|
||||
impl WebTransportCertificateHash {
|
||||
/// Creates a fingerprint from an already-computed SHA-256 digest.
|
||||
#[must_use]
|
||||
pub fn from_sha256(bytes: [u8; CERTIFICATE_HASH_SIZE]) -> Self {
|
||||
return Self { bytes };
|
||||
}
|
||||
|
||||
/// Returns the exact 32-byte SHA-256 digest.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8; CERTIFICATE_HASH_SIZE] {
|
||||
return &self.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-contained certificate/private-key identity used by a native WebTransport server.
|
||||
pub struct WebTransportServerIdentity {
|
||||
certificate_der: Vec<u8>,
|
||||
private_key_pkcs8_der: Vec<u8>,
|
||||
certificate_hash: WebTransportCertificateHash,
|
||||
}
|
||||
|
||||
impl WebTransportServerIdentity {
|
||||
/// Generates a short-lived self-signed ECDSA P-256 identity for localhost and loopback addresses.
|
||||
pub fn generate_loopback() -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
let now = std::time::SystemTime::now();
|
||||
let not_before = match now.checked_sub(LOCAL_CERTIFICATE_CLOCK_SKEW) {
|
||||
Some(value) => value,
|
||||
None => return Err(invalid_configuration("failed to compute local certificate not-before time")),
|
||||
};
|
||||
let not_after = match now.checked_add(LOCAL_CERTIFICATE_VALIDITY) {
|
||||
Some(value) => value,
|
||||
None => return Err(invalid_configuration("failed to compute local certificate not-after time")),
|
||||
};
|
||||
let subject_alt_names = vec!["localhost".to_owned(), "127.0.0.1".to_owned(), "::1".to_owned()];
|
||||
let mut params = match rcgen::CertificateParams::new(subject_alt_names) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
params.not_before = not_before.into();
|
||||
params.not_after = not_after.into();
|
||||
let key_pair = match rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
let certificate = match params.self_signed(&key_pair) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
let certificate_der = certificate.der().to_vec();
|
||||
let private_key_pkcs8_der = key_pair.serialize_der();
|
||||
return Self::from_pkcs8_der(certificate_der, private_key_pkcs8_der);
|
||||
}
|
||||
|
||||
/// Builds an identity from an X.509 certificate DER blob and its PKCS#8 private key DER blob.
|
||||
///
|
||||
/// Certificate/key compatibility is validated by the native TLS server builder when the listener is bound.
|
||||
pub fn from_pkcs8_der(certificate_der: Vec<u8>, private_key_pkcs8_der: Vec<u8>) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
if certificate_der.is_empty() {
|
||||
return Err(invalid_configuration("certificate DER must not be empty"));
|
||||
}
|
||||
if private_key_pkcs8_der.is_empty() {
|
||||
return Err(invalid_configuration("PKCS#8 private-key DER must not be empty"));
|
||||
}
|
||||
let certificate_hash = match certificate_hash(certificate_der.as_slice()) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
return Ok(Self { certificate_der, private_key_pkcs8_der, certificate_hash });
|
||||
}
|
||||
|
||||
/// Returns the SHA-256 certificate fingerprint used for native hash pinning.
|
||||
#[must_use]
|
||||
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
||||
return &self.certificate_hash;
|
||||
}
|
||||
}
|
||||
|
||||
/// Native WebTransport client endpoint, certificate pin and reliable-path configuration.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WebTransportClientConfig {
|
||||
endpoint: url::Url,
|
||||
certificate_hash: WebTransportCertificateHash,
|
||||
transport: crate::WebTransportConfig,
|
||||
}
|
||||
|
||||
impl WebTransportClientConfig {
|
||||
/// Parses and validates a secure WebTransport endpoint with one pinned SHA-256 certificate fingerprint.
|
||||
pub fn new(endpoint: &str, certificate_hash: WebTransportCertificateHash) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
let parsed = match url::Url::parse(endpoint) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(invalid_configuration("WebTransport endpoint scheme must be https"));
|
||||
}
|
||||
if parsed.host().is_none() {
|
||||
return Err(invalid_configuration("WebTransport endpoint must contain a host"));
|
||||
}
|
||||
return Ok(Self { endpoint: parsed, certificate_hash, transport: crate::WebTransportConfig::default() });
|
||||
}
|
||||
|
||||
/// Returns a copy with explicit reliable-path limits and deadlines.
|
||||
#[must_use]
|
||||
pub fn with_transport_config(mut self, transport: crate::WebTransportConfig) -> Self {
|
||||
self.transport = transport;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Returns the validated WebTransport endpoint URL.
|
||||
#[must_use]
|
||||
pub fn endpoint(&self) -> &str {
|
||||
return self.endpoint.as_str();
|
||||
}
|
||||
|
||||
/// Returns the pinned SHA-256 server-certificate fingerprint.
|
||||
#[must_use]
|
||||
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
||||
return &self.certificate_hash;
|
||||
}
|
||||
|
||||
/// Returns the reliable-path limits and deadlines.
|
||||
#[must_use]
|
||||
pub fn transport_config(&self) -> crate::WebTransportConfig {
|
||||
return self.transport;
|
||||
}
|
||||
}
|
||||
|
||||
/// Native WebTransport server bind address, TLS identity and reliable-path configuration.
|
||||
pub struct WebTransportServerConfig {
|
||||
bind_address: std::net::SocketAddr,
|
||||
identity: WebTransportServerIdentity,
|
||||
transport: crate::WebTransportConfig,
|
||||
}
|
||||
|
||||
impl WebTransportServerConfig {
|
||||
/// Creates native server configuration for the requested bind address and TLS identity.
|
||||
#[must_use]
|
||||
pub fn new(bind_address: std::net::SocketAddr, identity: WebTransportServerIdentity) -> Self {
|
||||
return Self { bind_address, identity, transport: crate::WebTransportConfig::default() };
|
||||
}
|
||||
|
||||
/// Returns a copy with explicit reliable-path limits and deadlines.
|
||||
#[must_use]
|
||||
pub fn with_transport_config(mut self, transport: crate::WebTransportConfig) -> Self {
|
||||
self.transport = transport;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Returns the requested UDP bind address.
|
||||
#[must_use]
|
||||
pub fn bind_address(&self) -> std::net::SocketAddr {
|
||||
return self.bind_address;
|
||||
}
|
||||
|
||||
/// Returns the server certificate fingerprint that clients must pin for this identity.
|
||||
#[must_use]
|
||||
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
||||
return self.identity.certificate_hash();
|
||||
}
|
||||
|
||||
/// Returns the reliable-path limits and deadlines.
|
||||
#[must_use]
|
||||
pub fn transport_config(&self) -> crate::WebTransportConfig {
|
||||
return self.transport;
|
||||
}
|
||||
}
|
||||
|
||||
/// Established native WebTransport session before or while the single primary application stream is selected.
|
||||
pub struct WebTransportSession {
|
||||
inner: web_transport_quinn::Session,
|
||||
transport: crate::WebTransportConfig,
|
||||
}
|
||||
|
||||
impl WebTransportSession {
|
||||
fn new(inner: web_transport_quinn::Session, transport: crate::WebTransportConfig) -> Self {
|
||||
return Self { inner, transport };
|
||||
}
|
||||
|
||||
/// Accepts the peer-created primary bidirectional stream and adapts it to the transport-neutral realtime contract.
|
||||
///
|
||||
/// The native WebTransport wrapper writes the required stream/session header while opening the stream, so the peer can
|
||||
/// accept it before the first application frame is sent.
|
||||
pub async fn accept_primary_connection(self) -> Result<WebTransportConnection, game_realtime_transport_lib::TransportError> {
|
||||
let timeout = self.transport.primary_stream_timeout();
|
||||
let accepted = tokio::time::timeout(timeout, self.inner.accept_bi()).await;
|
||||
let (sender, receiver) = match accepted {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(error)) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary bidirectional stream accept failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
Err(_) => {
|
||||
let mapped = timeout_error("WebTransport primary bidirectional stream accept", timeout);
|
||||
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(timeout), "WebTransport primary bidirectional stream accept timed out");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::debug!(target: TRACING_TARGET, peer = %self.inner.remote_address(), "WebTransport primary bidirectional stream accepted");
|
||||
return Ok(WebTransportConnection::new(self.inner, sender, receiver, self.transport));
|
||||
}
|
||||
|
||||
/// 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 timeout = self.transport.primary_stream_timeout();
|
||||
let opened = tokio::time::timeout(timeout, self.inner.open_bi()).await;
|
||||
let (sender, receiver) = match opened {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(error)) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary bidirectional stream open failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
Err(_) => {
|
||||
let mapped = timeout_error("WebTransport primary bidirectional stream open", timeout);
|
||||
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(timeout), "WebTransport primary bidirectional stream open timed out");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::debug!(target: TRACING_TARGET, peer = %self.inner.remote_address(), "WebTransport primary bidirectional stream opened");
|
||||
return Ok(WebTransportConnection::new(self.inner, sender, receiver, self.transport));
|
||||
}
|
||||
|
||||
/// Returns the remote UDP socket address backing the established QUIC connection.
|
||||
#[must_use]
|
||||
pub fn remote_addr(&self) -> std::net::SocketAddr {
|
||||
return self.inner.remote_address();
|
||||
}
|
||||
|
||||
/// Returns the HTTP/3 CONNECT URL used to establish this session when available.
|
||||
#[must_use]
|
||||
pub fn request_url(&self) -> Option<&str> {
|
||||
return match self.inner.request() {
|
||||
Some(request) => Some(request.url.as_str()),
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Established WebTransport realtime connection carried by one primary reliable bidirectional stream.
|
||||
pub struct WebTransportConnection {
|
||||
receiver: web_transport_quinn::RecvStream,
|
||||
sender: web_transport_quinn::SendStream,
|
||||
session: web_transport_quinn::Session,
|
||||
transport: crate::WebTransportConfig,
|
||||
}
|
||||
|
||||
impl WebTransportConnection {
|
||||
fn new(
|
||||
session: web_transport_quinn::Session,
|
||||
sender: web_transport_quinn::SendStream,
|
||||
receiver: web_transport_quinn::RecvStream,
|
||||
transport: crate::WebTransportConfig,
|
||||
) -> Self {
|
||||
return Self { receiver, sender, session, transport };
|
||||
}
|
||||
}
|
||||
|
||||
impl game_realtime_transport_lib::RealtimeConnection for WebTransportConnection {
|
||||
type Receiver = crate::WebTransportReceiver;
|
||||
type Sender = crate::WebTransportSender;
|
||||
|
||||
fn split(self) -> (Self::Sender, Self::Receiver) {
|
||||
let receiver_session = self.session.clone();
|
||||
return (
|
||||
crate::WebTransportSender {
|
||||
inner: self.sender,
|
||||
_session: self.session,
|
||||
max_message_size: self.transport.max_message_size(),
|
||||
send_timeout: self.transport.send_timeout(),
|
||||
terminal: false,
|
||||
},
|
||||
crate::WebTransportReceiver {
|
||||
inner: self.receiver,
|
||||
_session: receiver_session,
|
||||
max_message_size: self.transport.max_message_size(),
|
||||
header: [0_u8; PRIMARY_FRAME_HEADER_SIZE],
|
||||
header_read: 0,
|
||||
payload: Vec::new(),
|
||||
payload_read: 0,
|
||||
clean_closed: false,
|
||||
terminal: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive half of the primary reliable WebTransport stream.
|
||||
pub struct WebTransportReceiver {
|
||||
inner: web_transport_quinn::RecvStream,
|
||||
_session: web_transport_quinn::Session,
|
||||
max_message_size: usize,
|
||||
header: [u8; PRIMARY_FRAME_HEADER_SIZE],
|
||||
header_read: usize,
|
||||
payload: Vec<u8>,
|
||||
payload_read: usize,
|
||||
clean_closed: bool,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
impl WebTransportReceiver {
|
||||
/// Abruptly stops the reliable receive direction with one WebTransport application error code.
|
||||
pub fn abort(&mut self, code: u32) -> Result<(), game_realtime_transport_lib::TransportError> {
|
||||
if self.clean_closed || self.terminal {
|
||||
return Err(transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, "WebTransport receiver is already terminal"));
|
||||
}
|
||||
return match self.inner.stop(code) {
|
||||
Ok(()) => {
|
||||
self.terminal = true;
|
||||
tracing::debug!(target: TRACING_TARGET, code = code, "WebTransport primary receive stream aborted");
|
||||
Ok(())
|
||||
},
|
||||
Err(error) => {
|
||||
self.terminal = true;
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, code = code, detail = mapped.detail(), "WebTransport primary receive stream abort failed");
|
||||
Err(mapped)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async fn receive_frame(&mut self) -> Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError> {
|
||||
if self.clean_closed {
|
||||
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
||||
}
|
||||
if self.terminal {
|
||||
return Err(transport_error(
|
||||
game_realtime_transport_lib::TransportErrorKind::Aborted,
|
||||
"WebTransport receiver is unavailable after a terminal stream failure or abort",
|
||||
));
|
||||
}
|
||||
loop {
|
||||
if self.header_read < PRIMARY_FRAME_HEADER_SIZE {
|
||||
let read = self.inner.read(&mut self.header[self.header_read..]).await;
|
||||
match read {
|
||||
Ok(Some(0)) => return self.fail_protocol("WebTransport primary stream returned an empty read in the middle of a frame header"),
|
||||
Ok(Some(value)) => {
|
||||
self.header_read += value;
|
||||
continue;
|
||||
},
|
||||
Ok(None) => {
|
||||
if self.header_read == 0 {
|
||||
self.clean_closed = true;
|
||||
tracing::debug!(target: TRACING_TARGET, "remote WebTransport primary stream closed cleanly");
|
||||
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
||||
}
|
||||
return self.fail_protocol("WebTransport primary stream closed in the middle of a frame header");
|
||||
},
|
||||
Err(error) => return self.fail_read(error),
|
||||
}
|
||||
}
|
||||
if self.payload.is_empty() && self.payload_read == 0 {
|
||||
let payload_len = u32::from_be_bytes(self.header) as usize;
|
||||
if payload_len > self.max_message_size {
|
||||
let error = message_too_large(payload_len, self.max_message_size);
|
||||
self.stop_after_failure(FRAME_TOO_LARGE_ERROR_CODE);
|
||||
return Err(error);
|
||||
}
|
||||
if payload_len == 0 {
|
||||
self.reset_frame_state();
|
||||
tracing::trace!(target: TRACING_TARGET, payload_len = 0, "framed WebTransport payload received");
|
||||
return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(Vec::new())));
|
||||
}
|
||||
self.payload = vec![0_u8; payload_len];
|
||||
}
|
||||
if self.payload_read < self.payload.len() {
|
||||
let read = self.inner.read(&mut self.payload[self.payload_read..]).await;
|
||||
match read {
|
||||
Ok(Some(0)) => return self.fail_protocol("WebTransport primary stream returned an empty read in the middle of a frame payload"),
|
||||
Ok(Some(value)) => {
|
||||
self.payload_read += value;
|
||||
if self.payload_read < self.payload.len() {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Ok(None) => return self.fail_protocol("WebTransport primary stream closed in the middle of a frame payload"),
|
||||
Err(error) => return self.fail_read(error),
|
||||
}
|
||||
}
|
||||
let payload = core::mem::take(&mut self.payload);
|
||||
self.reset_frame_state();
|
||||
tracing::trace!(target: TRACING_TARGET, payload_len = payload.len(), "framed WebTransport payload received");
|
||||
return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_protocol(&mut self, detail: &str) -> Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError> {
|
||||
let error = protocol_error(detail);
|
||||
self.stop_after_failure(FRAME_PROTOCOL_ERROR_CODE);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
fn fail_read(
|
||||
&mut self,
|
||||
error: web_transport_quinn::ReadError,
|
||||
) -> Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError> {
|
||||
self.terminal = true;
|
||||
let mapped = map_read_error(error);
|
||||
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebTransport primary stream receive failed");
|
||||
return Err(mapped);
|
||||
}
|
||||
|
||||
fn reset_frame_state(&mut self) {
|
||||
self.header = [0_u8; PRIMARY_FRAME_HEADER_SIZE];
|
||||
self.header_read = 0;
|
||||
self.payload.clear();
|
||||
self.payload_read = 0;
|
||||
}
|
||||
|
||||
fn stop_after_failure(&mut self, code: u32) {
|
||||
let _ = self.inner.stop(code);
|
||||
self.terminal = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebTransportReceiver {
|
||||
fn drop(&mut self) {
|
||||
if !self.clean_closed && !self.terminal {
|
||||
let _ = self.inner.stop(STREAM_CANCELLED_ERROR_CODE);
|
||||
self.terminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl game_realtime_transport_lib::RealtimeReceiver for WebTransportReceiver {
|
||||
type ReceiveFuture<'a>
|
||||
= std::pin::Pin<
|
||||
Box<dyn core::future::Future<Output = Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError>> + 'a>,
|
||||
>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
|
||||
return Box::pin(async move { return self.receive_frame().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Send half of the primary reliable WebTransport stream.
|
||||
pub struct WebTransportSender {
|
||||
inner: web_transport_quinn::SendStream,
|
||||
_session: web_transport_quinn::Session,
|
||||
max_message_size: usize,
|
||||
send_timeout: std::time::Duration,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
impl WebTransportSender {
|
||||
/// Abruptly resets the reliable send direction with one WebTransport application error code.
|
||||
pub fn abort(&mut self, code: u32) -> Result<(), game_realtime_transport_lib::TransportError> {
|
||||
if self.terminal {
|
||||
return Err(transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, "WebTransport sender is already terminal"));
|
||||
}
|
||||
self.terminal = true;
|
||||
return match self.inner.reset(code) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(target: TRACING_TARGET, code = code, "WebTransport primary send stream aborted");
|
||||
Ok(())
|
||||
},
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, code = code, detail = mapped.detail(), "WebTransport primary send stream abort failed");
|
||||
Err(mapped)
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebTransportSender {
|
||||
fn drop(&mut self) {
|
||||
if !self.terminal {
|
||||
let _ = self.inner.reset(STREAM_CANCELLED_ERROR_CODE);
|
||||
self.terminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl game_realtime_transport_lib::RealtimeSender for WebTransportSender {
|
||||
type CloseFuture<'a>
|
||||
= std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), game_realtime_transport_lib::TransportError>> + 'a>>
|
||||
where
|
||||
Self: 'a;
|
||||
type SendFuture<'a>
|
||||
= std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), game_realtime_transport_lib::TransportError>> + 'a>>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn close(&mut self) -> Self::CloseFuture<'_> {
|
||||
return Box::pin(async move {
|
||||
if self.terminal {
|
||||
return Err(transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, "WebTransport sender is already terminal"));
|
||||
}
|
||||
self.terminal = true;
|
||||
return match self.inner.finish() {
|
||||
Ok(()) => {
|
||||
tracing::debug!(target: TRACING_TARGET, "local WebTransport primary stream close initiated");
|
||||
Ok(())
|
||||
},
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary stream close failed");
|
||||
Err(mapped)
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> {
|
||||
return Box::pin(async move {
|
||||
if self.terminal {
|
||||
return Err(transport_error(
|
||||
game_realtime_transport_lib::TransportErrorKind::Aborted,
|
||||
"WebTransport sender is unavailable after close, abort, cancellation or terminal send failure",
|
||||
));
|
||||
}
|
||||
let payload_len = message.len();
|
||||
let frame_header = match frame_header(payload_len, self.max_message_size) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let send_timeout = self.send_timeout;
|
||||
let mut guard = SendOperationGuard::new(&mut self.inner, &mut self.terminal);
|
||||
let operation = guard.write_frame(&frame_header, message.as_bytes());
|
||||
let result = tokio::time::timeout(send_timeout, operation).await;
|
||||
return match result {
|
||||
Ok(Ok(())) => {
|
||||
guard.complete();
|
||||
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "framed WebTransport payload sent");
|
||||
Ok(())
|
||||
},
|
||||
Ok(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(), "WebTransport framed send failed");
|
||||
Err(mapped)
|
||||
},
|
||||
Err(_) => {
|
||||
guard.abort(SEND_TIMEOUT_ERROR_CODE);
|
||||
let mapped = timeout_error("WebTransport framed send", send_timeout);
|
||||
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, timeout_ms = duration_millis(send_timeout), "WebTransport framed send timed out under flow control/backpressure");
|
||||
Err(mapped)
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct SendOperationGuard<'a> {
|
||||
stream: &'a mut web_transport_quinn::SendStream,
|
||||
terminal: &'a mut bool,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<'a> SendOperationGuard<'a> {
|
||||
fn new(stream: &'a mut web_transport_quinn::SendStream, terminal: &'a mut bool) -> Self {
|
||||
return Self { stream, terminal, armed: true };
|
||||
}
|
||||
|
||||
async fn write_frame(&mut self, header: &[u8; PRIMARY_FRAME_HEADER_SIZE], payload: &[u8]) -> Result<(), web_transport_quinn::WriteError> {
|
||||
if let Err(error) = self.stream.write_all(header).await {
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = self.stream.write_all(payload).await {
|
||||
return Err(error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn abort(&mut self, code: u32) {
|
||||
if self.armed {
|
||||
let _ = self.stream.reset(code);
|
||||
*self.terminal = true;
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn complete(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SendOperationGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
let _ = self.stream.reset(STREAM_CANCELLED_ERROR_CODE);
|
||||
*self.terminal = true;
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound native WebTransport server endpoint that accepts HTTP/3 WebTransport sessions.
|
||||
pub struct WebTransportListener {
|
||||
server: web_transport_quinn::Server,
|
||||
local_addr: std::net::SocketAddr,
|
||||
transport: crate::WebTransportConfig,
|
||||
}
|
||||
|
||||
impl WebTransportListener {
|
||||
/// Binds a native WebTransport server using TLS 1.3 and the configured certificate identity.
|
||||
pub fn bind(config: WebTransportServerConfig) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
||||
if let Err(error) = config.transport.validate() {
|
||||
return Err(error);
|
||||
}
|
||||
let transport = config.transport;
|
||||
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(config.identity.certificate_der);
|
||||
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivatePkcs8KeyDer::from(config.identity.private_key_pkcs8_der);
|
||||
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivateKeyDer::Pkcs8(private_key);
|
||||
let server = match web_transport_quinn::ServerBuilder::new().with_addr(config.bind_address).with_certificate(vec![certificate], private_key) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, address = %config.bind_address, detail = mapped.detail(), "WebTransport listener bind failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
let local_addr = match server.local_addr() {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebTransport listener address lookup failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebTransport listener bound");
|
||||
return Ok(Self { server, local_addr, transport });
|
||||
}
|
||||
|
||||
/// Returns the concrete UDP socket address, including an ephemeral port selected by the OS.
|
||||
#[must_use]
|
||||
pub fn local_addr(&self) -> std::net::SocketAddr {
|
||||
return self.local_addr;
|
||||
}
|
||||
|
||||
/// Accepts one native WebTransport CONNECT request and returns the established session.
|
||||
///
|
||||
/// Waiting for the next peer remains intentionally unbounded. Once a WebTransport request is surfaced, the final server
|
||||
/// response is bounded by the configured connection deadline.
|
||||
pub async fn accept(&mut self) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
|
||||
let request = match self.server.accept().await {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let error = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, "WebTransport server stopped accepting sessions");
|
||||
tracing::warn!(target: TRACING_TARGET, detail = error.detail(), "WebTransport accept ended");
|
||||
return Err(error);
|
||||
},
|
||||
};
|
||||
let peer = request.conn().remote_address();
|
||||
let timeout = self.transport.connect_timeout();
|
||||
let accepted = tokio::time::timeout(timeout, request.ok()).await;
|
||||
let session = match accepted {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(error)) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, peer = %peer, detail = mapped.detail(), "WebTransport server handshake failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
Err(_) => {
|
||||
let mapped = timeout_error("WebTransport server handshake response", timeout);
|
||||
tracing::warn!(target: TRACING_TARGET, peer = %peer, timeout_ms = duration_millis(timeout), "WebTransport server handshake response timed out");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, peer = %peer, "WebTransport peer accepted");
|
||||
return Ok(WebTransportSession::new(session, self.transport));
|
||||
}
|
||||
}
|
||||
|
||||
/// Establishes one native WebTransport session using an exact SHA-256 certificate pin.
|
||||
pub async fn connect(config: &WebTransportClientConfig) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
|
||||
if let Err(error) = config.transport.validate() {
|
||||
return Err(error);
|
||||
}
|
||||
let client = match web_transport_quinn::ClientBuilder::new().with_server_certificate_hashes(vec![config.certificate_hash.as_bytes().to_vec()]) {
|
||||
Ok(value) => value,
|
||||
Err(error) => return Err(invalid_configuration(error.to_string())),
|
||||
};
|
||||
let timeout = config.transport.connect_timeout();
|
||||
let connected = tokio::time::timeout(timeout, client.connect(config.endpoint.clone())).await;
|
||||
let session = match connected {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(error)) => {
|
||||
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Connect, error.to_string());
|
||||
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), detail = mapped.detail(), "WebTransport client connection failed");
|
||||
return Err(mapped);
|
||||
},
|
||||
Err(_) => {
|
||||
let mapped = timeout_error("WebTransport client connect", timeout);
|
||||
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), timeout_ms = duration_millis(timeout), "WebTransport client connection timed out");
|
||||
return Err(mapped);
|
||||
},
|
||||
};
|
||||
tracing::info!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), peer = %session.remote_address(), "WebTransport client connected");
|
||||
return Ok(WebTransportSession::new(session, config.transport));
|
||||
}
|
||||
|
||||
const FRAME_PROTOCOL_ERROR_CODE: u32 = 0x10;
|
||||
const FRAME_TOO_LARGE_ERROR_CODE: u32 = 0x11;
|
||||
const SEND_FAILURE_ERROR_CODE: u32 = 0x12;
|
||||
const SEND_TIMEOUT_ERROR_CODE: u32 = 0x13;
|
||||
const STREAM_CANCELLED_ERROR_CODE: u32 = 0x14;
|
||||
|
||||
fn certificate_hash(certificate_der: &[u8]) -> Result<WebTransportCertificateHash, game_realtime_transport_lib::TransportError> {
|
||||
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(certificate_der.to_vec());
|
||||
let provider = web_transport_quinn::crypto::default_provider();
|
||||
let digest = web_transport_quinn::crypto::sha256(&provider, &certificate);
|
||||
let digest_bytes = digest.as_ref();
|
||||
if digest_bytes.len() != CERTIFICATE_HASH_SIZE {
|
||||
return Err(invalid_configuration("WebTransport certificate SHA-256 digest has an unexpected length"));
|
||||
}
|
||||
let mut bytes = [0_u8; CERTIFICATE_HASH_SIZE];
|
||||
bytes.copy_from_slice(digest_bytes);
|
||||
return Ok(WebTransportCertificateHash::from_sha256(bytes));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
let payload_len = match u32::try_from(payload_len) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Err(message_too_large(payload_len, max_message_size)),
|
||||
};
|
||||
return Ok(payload_len.to_be_bytes());
|
||||
}
|
||||
|
||||
fn invalid_configuration(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
||||
return transport_error(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
||||
}
|
||||
|
||||
fn map_read_error(error: web_transport_quinn::ReadError) -> game_realtime_transport_lib::TransportError {
|
||||
let kind = match &error {
|
||||
web_transport_quinn::ReadError::Reset(_) => game_realtime_transport_lib::TransportErrorKind::Aborted,
|
||||
web_transport_quinn::ReadError::ClosedStream => game_realtime_transport_lib::TransportErrorKind::Closed,
|
||||
web_transport_quinn::ReadError::SessionError(session) => map_session_error_kind(session),
|
||||
web_transport_quinn::ReadError::InvalidReset(_) | web_transport_quinn::ReadError::IllegalOrderedRead => {
|
||||
game_realtime_transport_lib::TransportErrorKind::Protocol
|
||||
},
|
||||
};
|
||||
return transport_error(kind, error.to_string());
|
||||
}
|
||||
|
||||
fn map_session_error_kind(error: &web_transport_quinn::SessionError) -> game_realtime_transport_lib::TransportErrorKind {
|
||||
if matches!(error, web_transport_quinn::SessionError::WebTransportError(web_transport_quinn::WebTransportError::Closed(_, _))) {
|
||||
return game_realtime_transport_lib::TransportErrorKind::Closed;
|
||||
}
|
||||
return game_realtime_transport_lib::TransportErrorKind::Io;
|
||||
}
|
||||
|
||||
fn map_write_error(error: web_transport_quinn::WriteError) -> game_realtime_transport_lib::TransportError {
|
||||
let kind = match &error {
|
||||
web_transport_quinn::WriteError::Stopped(_) => game_realtime_transport_lib::TransportErrorKind::Aborted,
|
||||
web_transport_quinn::WriteError::ClosedStream => game_realtime_transport_lib::TransportErrorKind::Closed,
|
||||
web_transport_quinn::WriteError::SessionError(session) => map_session_error_kind(session),
|
||||
web_transport_quinn::WriteError::InvalidStopped(_) => game_realtime_transport_lib::TransportErrorKind::Protocol,
|
||||
};
|
||||
return transport_error(kind, error.to_string());
|
||||
}
|
||||
|
||||
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,
|
||||
format!("WebTransport primary frame payload size {payload_len} exceeds configured maximum {max_message_size} bytes"),
|
||||
);
|
||||
}
|
||||
|
||||
fn protocol_error(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
||||
return transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, detail);
|
||||
}
|
||||
|
||||
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", duration_millis(timeout)),
|
||||
);
|
||||
}
|
||||
|
||||
fn duration_millis(duration: std::time::Duration) -> u128 {
|
||||
return duration.as_millis();
|
||||
}
|
||||
|
||||
fn transport_error(kind: game_realtime_transport_lib::TransportErrorKind, detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
||||
return game_realtime_transport_lib::TransportError::new(kind, detail);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/webtransport.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,77 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/tests/establishment.rs
|
||||
// version: 2
|
||||
|
||||
//! Deterministic native loopback proof for WebTransport session establishment and SHA-256 pinning.
|
||||
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
fn normalized_socket_addr(value: std::net::SocketAddr) -> std::net::SocketAddr {
|
||||
match value {
|
||||
std::net::SocketAddr::V4(_) => return value,
|
||||
std::net::SocketAddr::V6(ipv6) => match ipv6.ip().to_ipv4_mapped() {
|
||||
Some(ipv4) => return std::net::SocketAddr::new(std::net::IpAddr::V4(ipv4), ipv6.port()),
|
||||
None => return std::net::SocketAddr::V6(ipv6),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn pinned_client_and_server_establish_a_loopback_session() {
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("loopback identity generation failed: {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 mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport listener bind failed: {error}"),
|
||||
};
|
||||
let endpoint = format!("https://{}/establishment", listener.local_addr());
|
||||
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport client configuration failed: {error}"),
|
||||
};
|
||||
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
return tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
|
||||
})
|
||||
.await;
|
||||
let (server_session, client_session) = match pair {
|
||||
Ok((Ok(server), Ok(client))) => (server, client),
|
||||
Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"),
|
||||
Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"),
|
||||
Err(_) => panic!("WebTransport loopback establishment timed out"),
|
||||
};
|
||||
assert_eq!(client_session.request_url(), Some(endpoint.as_str()));
|
||||
assert_eq!(server_session.request_url(), Some(endpoint.as_str()));
|
||||
assert_eq!(normalized_socket_addr(client_session.remote_addr()), normalized_socket_addr(listener.local_addr()));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn incorrect_certificate_pin_rejects_establishment() {
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("loopback identity generation failed: {error}"),
|
||||
};
|
||||
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
|
||||
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport listener bind failed: {error}"),
|
||||
};
|
||||
let endpoint = format!("https://{}/wrong-pin", listener.local_addr());
|
||||
let wrong_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256([0_u8; 32]);
|
||||
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), wrong_hash) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport client configuration failed: {error}"),
|
||||
};
|
||||
let (server_result, client_result) = tokio::join!(
|
||||
tokio::time::timeout(TEST_TIMEOUT, listener.accept()),
|
||||
tokio::time::timeout(TEST_TIMEOUT, game_realtime_webtransport_lib::connect(&client_config)),
|
||||
);
|
||||
match client_result {
|
||||
Ok(Ok(_)) => panic!("WebTransport establishment unexpectedly accepted an incorrect certificate pin"),
|
||||
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Connect),
|
||||
Err(_) => panic!("incorrect-pin connection attempt timed out"),
|
||||
}
|
||||
assert!(matches!(server_result, Err(_) | Ok(Err(_))));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs
|
||||
// version: 1
|
||||
|
||||
//! Deterministic loopback proof for the primary reliable WebTransport stream and transport-neutral framing contract.
|
||||
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn primary_stream_round_trip_is_binary_ordered_and_closes_cleanly() {
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("loopback identity generation failed: {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 mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport listener bind failed: {error}"),
|
||||
};
|
||||
let endpoint = format!("https://{}/realtime", listener.local_addr());
|
||||
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport client configuration failed: {error}"),
|
||||
};
|
||||
let sessions = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
return tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
|
||||
})
|
||||
.await;
|
||||
let (server_session, client_session) = match sessions {
|
||||
Ok((Ok(server), Ok(client))) => (server, client),
|
||||
Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"),
|
||||
Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"),
|
||||
Err(_) => panic!("WebTransport loopback establishment timed out"),
|
||||
};
|
||||
let client_connection = match client_session.open_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("client primary stream open failed: {error}"),
|
||||
};
|
||||
let (mut client_sender, mut client_receiver) = game_realtime_transport_lib::RealtimeConnection::split(client_connection);
|
||||
let server_connection = match tokio::time::timeout(TEST_TIMEOUT, server_session.accept_primary_connection()).await {
|
||||
Ok(Ok(connection)) => connection,
|
||||
Ok(Err(error)) => panic!("server primary stream accept failed: {error}"),
|
||||
Err(_) => panic!("server primary stream accept timed out"),
|
||||
};
|
||||
let (mut server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
let first_payload = Vec::new();
|
||||
send_payload(&mut client_sender, first_payload.clone(), "client first send").await;
|
||||
assert_received_payload(&mut server_receiver, first_payload.clone(), "server first receive").await;
|
||||
send_payload(&mut server_sender, first_payload.clone(), "server first echo").await;
|
||||
assert_received_payload(&mut client_receiver, first_payload, "client first echo receive").await;
|
||||
let remaining_payloads = [vec![0x00, 0x7f, 0x80, 0xff], b"third-message".to_vec()];
|
||||
for payload in remaining_payloads {
|
||||
send_payload(&mut client_sender, payload.clone(), "client ordered send").await;
|
||||
assert_received_payload(&mut server_receiver, payload.clone(), "server ordered receive").await;
|
||||
send_payload(&mut server_sender, payload.clone(), "server ordered echo").await;
|
||||
assert_received_payload(&mut client_receiver, payload, "client ordered echo receive").await;
|
||||
}
|
||||
if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut client_sender).await {
|
||||
panic!("client sender close failed: {error}");
|
||||
}
|
||||
assert_closed(&mut server_receiver, "server remote close").await;
|
||||
if let Err(error) = game_realtime_transport_lib::RealtimeSender::close(&mut server_sender).await {
|
||||
panic!("server sender close failed: {error}");
|
||||
}
|
||||
assert_closed(&mut client_receiver, "client remote close").await;
|
||||
}
|
||||
|
||||
async fn assert_closed(receiver: &mut game_realtime_webtransport_lib::WebTransportReceiver, label: &str) {
|
||||
let received = match game_realtime_transport_lib::RealtimeReceiver::receive(receiver).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("{label} failed: {error}"),
|
||||
};
|
||||
assert_eq!(received, game_realtime_transport_lib::TransportReceive::Closed);
|
||||
}
|
||||
|
||||
async fn assert_received_payload(receiver: &mut game_realtime_webtransport_lib::WebTransportReceiver, payload: Vec<u8>, label: &str) {
|
||||
let received = match game_realtime_transport_lib::RealtimeReceiver::receive(receiver).await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("{label} failed: {error}"),
|
||||
};
|
||||
assert_eq!(received, game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(payload)));
|
||||
}
|
||||
|
||||
async fn send_payload(sender: &mut game_realtime_webtransport_lib::WebTransportSender, payload: Vec<u8>, label: &str) {
|
||||
let message = game_realtime_transport_lib::TransportMessage::new(payload);
|
||||
if let Err(error) = game_realtime_transport_lib::RealtimeSender::send(sender, message).await {
|
||||
panic!("{label} failed: {error}");
|
||||
}
|
||||
}
|
||||
146
crates/common/game-realtime-webtransport-lib/tests/robustness.rs
Normal file
146
crates/common/game-realtime-webtransport-lib/tests/robustness.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/tests/robustness.rs
|
||||
// version: 2
|
||||
|
||||
//! Negative and bounded lifecycle tests for the native reliable WebTransport backend.
|
||||
|
||||
const SHORT_OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
const SMALL_MESSAGE_LIMIT: usize = 32;
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn outbound_payload_over_the_configured_limit_is_rejected_before_write() {
|
||||
let config = game_realtime_webtransport_lib::WebTransportConfig::default().with_max_message_size(SMALL_MESSAGE_LIMIT);
|
||||
let (_server_connection, client_connection) = establish_backend_pair(config, config).await;
|
||||
let (mut client_sender, _client_receiver) = game_realtime_transport_lib::RealtimeConnection::split(client_connection);
|
||||
let oversized = game_realtime_transport_lib::TransportMessage::new(vec![7; SMALL_MESSAGE_LIMIT + 1]);
|
||||
let result = game_realtime_transport_lib::RealtimeSender::send(&mut client_sender, oversized).await;
|
||||
match result {
|
||||
Ok(()) => panic!("oversized outbound WebTransport 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_before_allocation() {
|
||||
let server_config = game_realtime_webtransport_lib::WebTransportConfig::default().with_max_message_size(SMALL_MESSAGE_LIMIT);
|
||||
let client_config = game_realtime_webtransport_lib::WebTransportConfig::default().with_max_message_size(SMALL_MESSAGE_LIMIT + 1);
|
||||
let (server_connection, client_connection) = establish_backend_pair(server_config, client_config).await;
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
let (mut client_sender, _client_receiver) = game_realtime_transport_lib::RealtimeConnection::split(client_connection);
|
||||
let payload = game_realtime_transport_lib::TransportMessage::new(vec![3; SMALL_MESSAGE_LIMIT + 1]);
|
||||
if let Err(error) = game_realtime_transport_lib::RealtimeSender::send(&mut client_sender, payload).await {
|
||||
panic!("client failed to send payload allowed by its local bound: {error}");
|
||||
}
|
||||
let receive = tokio::time::timeout(TEST_TIMEOUT, game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
match receive {
|
||||
Ok(Ok(value)) => panic!("oversized inbound WebTransport payload produced a successful receive: {value:?}"),
|
||||
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge),
|
||||
Err(_) => panic!("oversized inbound WebTransport payload did not complete within the test timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn explicit_sender_abort_is_observed_as_aborted_receive() {
|
||||
let config = game_realtime_webtransport_lib::WebTransportConfig::default();
|
||||
let (server_connection, client_connection) = establish_backend_pair(config, config).await;
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
let (mut client_sender, _client_receiver) = game_realtime_transport_lib::RealtimeConnection::split(client_connection);
|
||||
if let Err(error) = client_sender.abort(0x41) {
|
||||
panic!("client sender abort failed: {error}");
|
||||
}
|
||||
let receive = tokio::time::timeout(TEST_TIMEOUT, game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
match receive {
|
||||
Ok(Ok(value)) => panic!("reset WebTransport stream produced a successful receive: {value:?}"),
|
||||
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Aborted),
|
||||
Err(_) => panic!("peer reset was not observed within the test timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn explicit_receiver_abort_makes_the_local_receive_half_terminal() {
|
||||
let config = game_realtime_webtransport_lib::WebTransportConfig::default();
|
||||
let (server_connection, _client_connection) = establish_backend_pair(config, config).await;
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
if let Err(error) = server_receiver.abort(0x42) {
|
||||
panic!("server receiver abort failed: {error}");
|
||||
}
|
||||
let result = game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver).await;
|
||||
match result {
|
||||
Ok(value) => panic!("aborted WebTransport receiver produced a successful receive: {value:?}"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Aborted),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dropping_sender_without_close_resets_the_stream_instead_of_synthesizing_fin() {
|
||||
let config = game_realtime_webtransport_lib::WebTransportConfig::default();
|
||||
let (server_connection, client_connection) = establish_backend_pair(config, config).await;
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
let (client_sender, _client_receiver) = game_realtime_transport_lib::RealtimeConnection::split(client_connection);
|
||||
drop(client_sender);
|
||||
let receive = tokio::time::timeout(TEST_TIMEOUT, game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
match receive {
|
||||
Ok(Ok(value)) => panic!("dropped WebTransport sender produced a clean receive result: {value:?}"),
|
||||
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Aborted),
|
||||
Err(_) => panic!("sender drop reset was not observed within the test timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn primary_stream_accept_honors_the_configured_deadline() {
|
||||
let config = game_realtime_webtransport_lib::WebTransportConfig::default().with_primary_stream_timeout(SHORT_OPERATION_TIMEOUT);
|
||||
let (server_session, _client_session) = establish_sessions(config, config).await;
|
||||
let result = server_session.accept_primary_connection().await;
|
||||
match result {
|
||||
Ok(_) => panic!("primary stream accept unexpectedly succeeded without a peer-created stream"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
async fn establish_backend_pair(
|
||||
server_transport: game_realtime_webtransport_lib::WebTransportConfig,
|
||||
client_transport: game_realtime_webtransport_lib::WebTransportConfig,
|
||||
) -> (game_realtime_webtransport_lib::WebTransportConnection, game_realtime_webtransport_lib::WebTransportConnection) {
|
||||
let (server_session, client_session) = establish_sessions(server_transport, client_transport).await;
|
||||
let client_connection = match client_session.open_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("client primary stream open failed: {error}"),
|
||||
};
|
||||
let server_connection = match server_session.accept_primary_connection().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("server primary stream accept failed: {error}"),
|
||||
};
|
||||
return (server_connection, client_connection);
|
||||
}
|
||||
|
||||
async fn establish_sessions(
|
||||
server_transport: game_realtime_webtransport_lib::WebTransportConfig,
|
||||
client_transport: game_realtime_webtransport_lib::WebTransportConfig,
|
||||
) -> (game_realtime_webtransport_lib::WebTransportSession, game_realtime_webtransport_lib::WebTransportSession) {
|
||||
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("loopback identity generation failed: {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)
|
||||
.with_transport_config(server_transport);
|
||||
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport listener bind failed: {error}"),
|
||||
};
|
||||
let endpoint = format!("https://{}/robustness", listener.local_addr());
|
||||
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||
Ok(value) => value.with_transport_config(client_transport),
|
||||
Err(error) => panic!("WebTransport client configuration failed: {error}"),
|
||||
};
|
||||
let sessions = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
return tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
|
||||
})
|
||||
.await;
|
||||
return match sessions {
|
||||
Ok((Ok(server), Ok(client))) => (server, client),
|
||||
Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"),
|
||||
Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"),
|
||||
Err(_) => panic!("WebTransport loopback establishment timed out"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/unit_tests/config.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn defaults_are_valid_and_preserve_the_one_mib_baseline() {
|
||||
let config = super::WebTransportConfig::default();
|
||||
assert!(config.validate().is_ok());
|
||||
assert_eq!(config.max_message_size(), 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_limits_and_deadlines_are_rejected() {
|
||||
let cases = [
|
||||
super::WebTransportConfig::default().with_max_message_size(0),
|
||||
super::WebTransportConfig::default().with_connect_timeout(std::time::Duration::ZERO),
|
||||
super::WebTransportConfig::default().with_primary_stream_timeout(std::time::Duration::ZERO),
|
||||
super::WebTransportConfig::default().with_send_timeout(std::time::Duration::ZERO),
|
||||
];
|
||||
for config in cases {
|
||||
let result = config.validate();
|
||||
match result {
|
||||
Ok(()) => panic!("invalid WebTransport configuration unexpectedly accepted"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// file: crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs
|
||||
// version: 4
|
||||
|
||||
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
#[test]
|
||||
fn certificate_hash_preserves_exact_sha256_bytes() {
|
||||
let bytes = [7_u8; super::CERTIFICATE_HASH_SIZE];
|
||||
let hash = super::WebTransportCertificateHash::from_sha256(bytes);
|
||||
assert_eq!(hash.as_bytes(), &bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_config_accepts_https_and_rejects_non_secure_schemes() {
|
||||
let hash = super::WebTransportCertificateHash::from_sha256([1_u8; super::CERTIFICATE_HASH_SIZE]);
|
||||
let secure = super::WebTransportClientConfig::new("https://127.0.0.1:4433/game", hash.clone());
|
||||
assert!(secure.is_ok());
|
||||
let insecure_http = super::WebTransportClientConfig::new("http://127.0.0.1:4433/game", hash.clone());
|
||||
match insecure_http {
|
||||
Ok(_) => panic!("HTTP endpoint unexpectedly accepted"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
|
||||
}
|
||||
let websocket = super::WebTransportClientConfig::new("ws://127.0.0.1:4433/game", hash);
|
||||
match websocket {
|
||||
Ok(_) => panic!("WebSocket endpoint unexpectedly accepted"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_header_is_big_endian_and_configured_payload_bound_is_enforced() {
|
||||
let header = match super::frame_header(0x00_01_02_03, 1024 * 1024) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("valid frame header rejected: {error}"),
|
||||
};
|
||||
assert_eq!(header, [0x00, 0x01, 0x02, 0x03]);
|
||||
let oversized = super::frame_header(33, 32);
|
||||
match oversized {
|
||||
Ok(_) => panic!("oversized WebTransport frame unexpectedly accepted"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_error_mapping_distinguishes_abort_close_and_protocol_failures() {
|
||||
let stopped = super::map_write_error(web_transport_quinn::WriteError::Stopped(7));
|
||||
assert_eq!(stopped.kind(), game_realtime_transport_lib::TransportErrorKind::Aborted);
|
||||
let reset = super::map_read_error(web_transport_quinn::ReadError::Reset(8));
|
||||
assert_eq!(reset.kind(), game_realtime_transport_lib::TransportErrorKind::Aborted);
|
||||
let closed = super::map_read_error(web_transport_quinn::ReadError::SessionError(web_transport_quinn::SessionError::WebTransportError(
|
||||
web_transport_quinn::WebTransportError::Closed(9, "done".to_owned()),
|
||||
)));
|
||||
assert_eq!(closed.kind(), game_realtime_transport_lib::TransportErrorKind::Closed);
|
||||
let protocol = super::map_read_error(web_transport_quinn::ReadError::IllegalOrderedRead);
|
||||
assert_eq!(protocol.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_loopback_identity_has_sha256_fingerprint() {
|
||||
let identity = match super::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("loopback identity generation failed: {error}"),
|
||||
};
|
||||
assert_eq!(identity.certificate_hash().as_bytes().len(), super::CERTIFICATE_HASH_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_identity_rejects_empty_certificate_or_key() {
|
||||
let missing_certificate = super::WebTransportServerIdentity::from_pkcs8_der(Vec::new(), vec![1]);
|
||||
match missing_certificate {
|
||||
Ok(_) => panic!("empty certificate unexpectedly accepted"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
|
||||
}
|
||||
let missing_key = super::WebTransportServerIdentity::from_pkcs8_der(vec![1], Vec::new());
|
||||
match missing_key {
|
||||
Ok(_) => panic!("empty private key unexpectedly accepted"),
|
||||
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancelled_receive_preserves_partial_frame_state() {
|
||||
let (server_session, client_session) = establish_private_sessions().await;
|
||||
let (mut raw_sender, _raw_receiver) = match client_session.inner.open_bi().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("raw client stream open failed: {error}"),
|
||||
};
|
||||
let (server_sender, server_receiver) = match server_session.inner.accept_bi().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("raw server stream accept failed: {error}"),
|
||||
};
|
||||
let server_connection = super::WebTransportConnection::new(server_session.inner, server_sender, server_receiver, server_session.transport);
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
if let Err(error) = raw_sender.write_all(&[0x00, 0x00]).await {
|
||||
panic!("partial frame header write failed: {error}");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
let first_receive =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
assert!(first_receive.is_err());
|
||||
assert_eq!(server_receiver.header_read, 2);
|
||||
if let Err(error) = raw_sender.write_all(&[0x00, 0x03, 0x10, 0x20, 0x30]).await {
|
||||
panic!("remaining frame write failed: {error}");
|
||||
}
|
||||
let second_receive = tokio::time::timeout(TEST_TIMEOUT, game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
match second_receive {
|
||||
Ok(Ok(game_realtime_transport_lib::TransportReceive::Message(message))) => assert_eq!(message.as_bytes(), &[0x10, 0x20, 0x30]),
|
||||
Ok(Ok(value)) => panic!("resumed receive returned unexpected result: {value:?}"),
|
||||
Ok(Err(error)) => panic!("resumed receive failed: {error}"),
|
||||
Err(_) => panic!("resumed receive timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn truncated_frame_payload_is_a_protocol_failure() {
|
||||
let (server_session, client_session) = establish_private_sessions().await;
|
||||
let (mut raw_sender, _raw_receiver) = match client_session.inner.open_bi().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("raw client stream open failed: {error}"),
|
||||
};
|
||||
let (server_sender, server_receiver) = match server_session.inner.accept_bi().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("raw server stream accept failed: {error}"),
|
||||
};
|
||||
let server_connection = super::WebTransportConnection::new(server_session.inner, server_sender, server_receiver, server_session.transport);
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
if let Err(error) = raw_sender.write_all(&[0x00, 0x00, 0x00, 0x03, 0x10]).await {
|
||||
panic!("truncated frame payload write failed: {error}");
|
||||
}
|
||||
if let Err(error) = raw_sender.finish() {
|
||||
panic!("raw client stream finish failed: {error}");
|
||||
}
|
||||
let receive = tokio::time::timeout(TEST_TIMEOUT, game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
match receive {
|
||||
Ok(Ok(value)) => panic!("truncated frame payload produced a successful receive: {value:?}"),
|
||||
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol),
|
||||
Err(_) => panic!("truncated frame payload was not rejected within the test timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn truncated_frame_header_is_a_protocol_failure() {
|
||||
let (server_session, client_session) = establish_private_sessions().await;
|
||||
let (mut raw_sender, _raw_receiver) = match client_session.inner.open_bi().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("raw client stream open failed: {error}"),
|
||||
};
|
||||
let (server_sender, server_receiver) = match server_session.inner.accept_bi().await {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("raw server stream accept failed: {error}"),
|
||||
};
|
||||
let server_connection = super::WebTransportConnection::new(server_session.inner, server_sender, server_receiver, server_session.transport);
|
||||
let (_server_sender, mut server_receiver) = game_realtime_transport_lib::RealtimeConnection::split(server_connection);
|
||||
if let Err(error) = raw_sender.write_all(&[0x00, 0x00]).await {
|
||||
panic!("partial frame header write failed: {error}");
|
||||
}
|
||||
if let Err(error) = raw_sender.finish() {
|
||||
panic!("raw client stream finish failed: {error}");
|
||||
}
|
||||
let receive = tokio::time::timeout(TEST_TIMEOUT, game_realtime_transport_lib::RealtimeReceiver::receive(&mut server_receiver)).await;
|
||||
match receive {
|
||||
Ok(Ok(value)) => panic!("truncated frame header produced a successful receive: {value:?}"),
|
||||
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol),
|
||||
Err(_) => panic!("truncated frame header was not rejected within the test timeout"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn establish_private_sessions() -> (super::WebTransportSession, super::WebTransportSession) {
|
||||
let identity = match super::WebTransportServerIdentity::generate_loopback() {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("loopback identity generation failed: {error}"),
|
||||
};
|
||||
let certificate_hash = identity.certificate_hash().clone();
|
||||
let server_config = super::WebTransportServerConfig::new(std::net::SocketAddr::from(([127, 0, 0, 1], 0)), identity);
|
||||
let mut listener = match super::WebTransportListener::bind(server_config) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport listener bind failed: {error}"),
|
||||
};
|
||||
let endpoint = format!("https://{}/private-test", listener.local_addr());
|
||||
let client_config = match super::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
|
||||
Ok(value) => value,
|
||||
Err(error) => panic!("WebTransport client configuration failed: {error}"),
|
||||
};
|
||||
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
return tokio::join!(listener.accept(), super::connect(&client_config));
|
||||
})
|
||||
.await;
|
||||
return match pair {
|
||||
Ok((Ok(server), Ok(client))) => (server, client),
|
||||
Ok((Err(error), _)) => panic!("WebTransport server establishment failed: {error}"),
|
||||
Ok((_, Err(error))) => panic!("WebTransport client establishment failed: {error}"),
|
||||
Err(_) => panic!("WebTransport private loopback establishment timed out"),
|
||||
};
|
||||
}
|
||||
296
deltas/0.3.5/alpha.1.md
Normal file
296
deltas/0.3.5/alpha.1.md
Normal file
@@ -0,0 +1,296 @@
|
||||
<!-- file: deltas/0.3.5/alpha.1.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Delta 0.3.5-alpha.1
|
||||
|
||||
## Base
|
||||
|
||||
Base autoritaire : archive fournie `games-v0.3.4.zip`, annoncée comme téléchargement ZIP du tag Gitea `v0.3.4`.
|
||||
|
||||
La version workspace de la base est :
|
||||
|
||||
```text
|
||||
0.3.4
|
||||
```
|
||||
|
||||
L'archive taggée est utilisée telle quelle conformément à `CMD-GIT-003` et `CMD-GIT-004`. L'absence de `.git` est normale et aucun fichier local absent du ZIP n'est inventé.
|
||||
|
||||
## Objet
|
||||
|
||||
Ouvrir `0.3.5` par le gate obligatoire `alpha.1` : auditer la stable, relire les preuves `0.3.4`, rechercher l'écosystème WebTransport/QUIC actuel, sélectionner la stack POC primaire, challenger le contrat commun, fermer la stratégie TLS/fallback, ajouter les smoke tests nécessaires et redécouper la version afin que chaque delta reste normalement dans la cible 15 à 30 minutes.
|
||||
|
||||
Cette tranche n'ajoute aucune crate WebTransport, aucune dépendance QUIC/Rustls et aucun comportement runtime.
|
||||
|
||||
## Version
|
||||
|
||||
La version workspace passe de :
|
||||
|
||||
```text
|
||||
0.3.4
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.1
|
||||
```
|
||||
|
||||
Conformément à `VER-DOCFIX-002`, une prerelease non-fix synchronise sa version technique même si le contenu de cette tranche est principalement documentaire.
|
||||
|
||||
Aucune version npm/Tauri/Android n'est modifiée : aucun package de ces plateformes n'est touché.
|
||||
|
||||
## Audit de l'archive
|
||||
|
||||
Avant modification, l'environnement de génération a obtenu :
|
||||
|
||||
```text
|
||||
unzip -t : no errors
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
games.sasedev workspace audit: clean
|
||||
Markdown table audit: clean (5 table(s), 263 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
```
|
||||
|
||||
Inventaire indépendant :
|
||||
|
||||
```text
|
||||
437 fichiers dans le ZIP
|
||||
267 fichiers Markdown extraits
|
||||
68 fichiers Rust
|
||||
18 Cargo.toml, dont le manifest racine
|
||||
17 membres workspace
|
||||
workspace.package.version = 0.3.4
|
||||
0 symlink
|
||||
0 chemin absolu/traversal
|
||||
0 target/, node_modules/, build/ ou gen/android/ généré
|
||||
```
|
||||
|
||||
Le log utilisateur fourni à l'ouverture de session confirme également :
|
||||
|
||||
```text
|
||||
cargo fmt --all -- --check : clean
|
||||
audits Rust/Markdown/distribution : clean
|
||||
cargo check --workspace : clean
|
||||
```
|
||||
|
||||
Son audit Markdown annonce `279` fichiers contre `263` dans le scan de l'archive fournie. Le delta reste construit exclusivement depuis le ZIP autoritaire reçu et enregistre cet écart sans l'extrapoler.
|
||||
|
||||
## Audit des règles et du workflow
|
||||
|
||||
La lecture du prompt `006`, de `RULES.md`, des règles détaillées, des documents réseau/plateforme et des preuves `0.3.4` confirme :
|
||||
|
||||
- `alpha.1` doit produire le plan actif avant développement lourd ;
|
||||
- un delta vise environ 15 à 30 minutes de travail effectif ;
|
||||
- une tranche clairement trop lourde doit être scindée avant exécution ;
|
||||
- le full test workspace doit être rare et explicitement planifié ;
|
||||
- une tranche de consolidation doit apparaître avant la candidate finale ;
|
||||
- les builds/tests/smokes de validation sont attestés côté utilisateur ;
|
||||
- les audits statiques peuvent être exécutés dans l'environnement de génération ;
|
||||
- `history/` n'est créé qu'après validation réelle d'un jalon ;
|
||||
- `CHANGELOG.md` reste normalement silencieux avant la phase de consolidation/RC ;
|
||||
- `ROADMAP.md` reste macroscopique et n'a pas besoin de changer pour ce cadrage.
|
||||
|
||||
Le forecast initial du prompt est fonctionnellement correct mais trop agrégé sur trois risques : backend natif, browser/TLS et fallback/mesures. Le plan actif les sépare et ajoute `beta.2` comme consolidation explicite pré-RC.
|
||||
|
||||
## Recherche WebTransport/QUIC actuelle
|
||||
|
||||
État vérifié au 2026-09-21 :
|
||||
|
||||
```text
|
||||
web-transport 0.12.0
|
||||
web-transport-quinn 0.12.1
|
||||
web-transport-wasm 0.6.0
|
||||
wtransport 0.7.2
|
||||
```
|
||||
|
||||
Constats structurants :
|
||||
|
||||
- `web-transport` fournit une façade native + WASM, avec Quinn côté natif et API navigateur côté WASM ;
|
||||
- la documentation amont traite explicitement la différence `Send`/`!Send` entre natif et WASM, cohérente avec la frontière `0.3.4` ;
|
||||
- le build WASM nécessite actuellement `--cfg=web_sys_unstable_apis` ;
|
||||
- `wtransport` reste un candidat natif robuste et documenté, notamment pour certificats/hash W3C, mais ne fournit pas la même façade Rust WASM ;
|
||||
- MDN classe WebTransport « Baseline 2026 » depuis mars 2026 sur navigateurs récents et impose un contexte sécurisé ;
|
||||
- WebTransport/HTTP3 reste `draft-ietf-webtrans-http3-16`, Internet-Draft en WG Last Call, donc pas encore un RFC final.
|
||||
|
||||
L'étude détaillée est ajoutée dans :
|
||||
|
||||
```text
|
||||
docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md
|
||||
```
|
||||
|
||||
## Stack primaire retenue
|
||||
|
||||
Le POC commence avec la famille :
|
||||
|
||||
```text
|
||||
web-transport 0.12.x
|
||||
```
|
||||
|
||||
`wtransport 0.7.x` reste le candidat de repli si une exigence concrète échoue.
|
||||
|
||||
Aucune dépendance n'est ajoutée dans `alpha.1`; la résolution Cargo exacte et le choix des features crypto sont fermés dans `alpha.2` avec `cargo tree`.
|
||||
|
||||
## Contrat transport et framing
|
||||
|
||||
Le contrat `game-realtime-transport-lib` reste inchangé.
|
||||
|
||||
Pour préserver sa sémantique fiable/ordonnée/message-oriented, le backend WebTransport utilisera un stream bidirectionnel principal et un framing privé :
|
||||
|
||||
```text
|
||||
u32 big-endian length + payload bytes
|
||||
```
|
||||
|
||||
La taille est bornée avant allocation/écriture. Ce framing n'est pas un wire codec métier et ne contient aucun joueur, room, tick ou version de protocole gameplay.
|
||||
|
||||
Les datagrams restent hors du contrat commun parce qu'ils sont non fiables/non ordonnés. Ils seront challengés séparément sans créer une capability commune prématurée.
|
||||
|
||||
## TLS et navigateur
|
||||
|
||||
Le POC retient une stratégie de développement compatible avec les contraintes W3C :
|
||||
|
||||
- certificat self-signed court ;
|
||||
- ECDSA P-256 ;
|
||||
- validité inférieure à deux semaines ;
|
||||
- hash SHA-256 épinglé côté client ;
|
||||
- aucune clé privée durable dans le dépôt ;
|
||||
- aucune désactivation permanente de validation TLS.
|
||||
|
||||
Le smoke navigateur est désormais une preuve explicitement planifiée et distincte du simple build WASM.
|
||||
|
||||
## Fallback
|
||||
|
||||
Le fallback WebSocket est possédé par la composition/application du POC :
|
||||
|
||||
```text
|
||||
attempt WebTransport
|
||||
success -> WebTransport
|
||||
classified unavailable/establishment failure -> WebSocket
|
||||
```
|
||||
|
||||
Le plan exige une branche WebTransport forcée, une branche fallback forcée et une erreur non-fallback visible. Aucun `TransportManager` ou registry n'est créé dans ce cadrage.
|
||||
|
||||
## Forecast révisé
|
||||
|
||||
Le plan actif retient désormais :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.1 audit + recherche + design + plan
|
||||
0.3.5-alpha.2 crate/dépendances + établissement natif/TLS pinning
|
||||
0.3.5-alpha.3 stream fiable + framing + contrat commun
|
||||
0.3.5-alpha.4 robustesse/lifecycle/limites/timeouts
|
||||
0.3.5-alpha.5 smoke WebTransport natif + graphe
|
||||
0.3.5-alpha.6 chemin Rust WASM compilable
|
||||
0.3.5-alpha.7 smoke navigateur + TLS local réel
|
||||
0.3.5-alpha.8 fallback WebSocket au niveau composition
|
||||
0.3.5-alpha.9 datagram POC conditionnel et isolé
|
||||
0.3.5-alpha.10 mesures WebSocket vs WebTransport
|
||||
0.3.5-beta.1 validation large + full workspace + smokes
|
||||
0.3.5-beta.2 consolidation durable + conclusion + prompt 0.3.6
|
||||
0.3.5-rc.1 candidate gelée + gates de publication
|
||||
0.3.5 promotion stable mécanique
|
||||
```
|
||||
|
||||
Les numéros restent souples : une tranche peut être fusionnée si elle devient micro-scopique ou scindée avant exécution si elle dépasse clairement 30 minutes.
|
||||
|
||||
Le point important est que `alpha.6` et `alpha.7` sont séparées : compiler le client Rust WASM et faire fonctionner un navigateur avec certificat/UDP/serveur sont deux risques différents. De même, la consolidation n'est plus reportée dans la RC.
|
||||
|
||||
## Full tests et smoke tests planifiés
|
||||
|
||||
`cargo test --workspace --all-targets --all-features` est explicitement réservé à :
|
||||
|
||||
```text
|
||||
0.3.5-beta.1
|
||||
0.3.5-rc.1
|
||||
```
|
||||
|
||||
sauf changement transverse inattendu imposant une gate supplémentaire.
|
||||
|
||||
Smokes prévus :
|
||||
|
||||
```text
|
||||
cargo run -p game-realtime-websocket-smoke
|
||||
cargo run -p game-realtime-webtransport-smoke
|
||||
browser/WASM WebTransport smoke (workflow fixé en alpha.7)
|
||||
fallback branch smoke/proof (intégré à un launcher existant si possible)
|
||||
```
|
||||
|
||||
Le benchmark reste un outil borné, pas une infrastructure permanente disproportionnée.
|
||||
|
||||
## Fichiers
|
||||
|
||||
Ajoutés :
|
||||
|
||||
```text
|
||||
deltas/0.3.5/alpha.1.md
|
||||
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
|
||||
docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md
|
||||
```
|
||||
|
||||
Modifiés :
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
README.md
|
||||
docs/000-README.md
|
||||
docs/plans/000-README.md
|
||||
docs/studies/000-README.md
|
||||
```
|
||||
|
||||
Inchangés volontairement :
|
||||
|
||||
```text
|
||||
ROADMAP.md
|
||||
CHANGELOG.md
|
||||
prompts/006-V0_3_5_START_PROMPT.md
|
||||
crates/**
|
||||
Android/**
|
||||
Web/**
|
||||
```
|
||||
|
||||
## Validations exécutées dans l'environnement de génération
|
||||
|
||||
Après constitution de l'état livré, le générateur a exécuté uniquement les audits statiques autorisés :
|
||||
|
||||
```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), 266 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
alpha.1 targeted consistency: clean
|
||||
```
|
||||
|
||||
Le contrôle ciblé confirme en particulier `workspace.package.version = 0.3.5-alpha.1`, la présence du plan/étude/delta, l'absence volontaire de `history/0.3.5/alpha.1.md` avant validation et l'absence d'entrée `0.3.5-alpha.1` dans `CHANGELOG.md`.
|
||||
|
||||
Aucun `cargo check`, Clippy, test, benchmark ou smoke post-delta n'est attribué au générateur.
|
||||
|
||||
## Validation utilisateur demandée
|
||||
|
||||
`Cargo.toml` change uniquement pour la version workspace et les fichiers Markdown changent. Aucune dépendance ni source Rust n'est encore ajoutée.
|
||||
|
||||
Depuis la racine :
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Clippy et tests ne sont pas requis par cérémonie dans `alpha.1` car aucun code Rust, feature ou dépendance fonctionnelle n'est modifié. Le log d'ouverture a déjà confirmé `cargo check --workspace` propre sur la stable `0.3.4`; la commande ci-dessus valide la synchronisation de version du nouvel état.
|
||||
|
||||
## Suite après validation
|
||||
|
||||
Si cette gate est propre, `alpha.2` :
|
||||
|
||||
- crée `history/0.3.5/alpha.1.md` à partir de la sortie réelle ;
|
||||
- ajoute `game-realtime-webtransport-lib` ;
|
||||
- introduit la stack primaire avec features minimales ;
|
||||
- ferme la configuration TLS/hash et l'établissement natif ;
|
||||
- ne cherche pas encore à absorber framing, robustesse, browser smoke et fallback dans la même tranche.
|
||||
|
||||
Un échec du cadrage, des audits ou de la synchronisation de version produit d'abord `0.3.5-alpha.1.fix.N`.
|
||||
91
deltas/0.3.5/alpha.2.fix.1.md
Normal file
91
deltas/0.3.5/alpha.2.fix.1.md
Normal file
@@ -0,0 +1,91 @@
|
||||
<!-- file: deltas/0.3.5/alpha.2.fix.1.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.3.5-alpha.2.fix.1
|
||||
|
||||
## Base requise
|
||||
|
||||
`0.3.5-alpha.2`, candidate dont la gate utilisateur du 2026-09-21 a atteint les tests d’intégration WebTransport après fmt, audits, check workspace et Clippy propres.
|
||||
|
||||
## Cause
|
||||
|
||||
Le test positif d’établissement ouvre correctement la session WebTransport mais échoue sur l’assertion d’adresse distante :
|
||||
|
||||
```text
|
||||
left: [::ffff:127.0.0.1]:54430
|
||||
right: 127.0.0.1:54430
|
||||
```
|
||||
|
||||
Quinn expose ici l’adresse IPv4 loopback sous forme IPv4-mapped IPv6. Les deux valeurs désignent la même IP et le même port ; l’assertion brute sur `SocketAddr` confond donc différence de représentation et défaut de transport.
|
||||
|
||||
La même gate confirme parallèlement que :
|
||||
|
||||
- les quatre tests unitaires WebTransport passent ;
|
||||
- le test de mauvais pin passe ;
|
||||
- la compilation workspace et Clippy sont propres ;
|
||||
- les audits Rust/workspace, Markdown et distribution sont propres.
|
||||
|
||||
`alpha.2` n’est pas historisée comme validée : le test d’intégration positif reste rouge jusqu’à ce correctif.
|
||||
|
||||
## Correction
|
||||
|
||||
La version technique passe à :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.2.fix.1
|
||||
```
|
||||
|
||||
`tests/establishment.rs` normalise uniquement les `SocketAddr` observées avant comparaison :
|
||||
|
||||
- une adresse IPv4 reste inchangée ;
|
||||
- une IPv6 IPv4-mapped est ramenée à son IPv4 canonique avec le même port ;
|
||||
- une IPv6 native reste inchangée.
|
||||
|
||||
L’assertion continue donc de détecter un port différent, une IPv4 différente ou une IPv6 réellement différente. Elle cesse seulement de considérer `127.0.0.1` et `::ffff:127.0.0.1` comme deux endpoints distincts.
|
||||
|
||||
Aucun code de production WebTransport, certificat, pinning, configuration Quinn, mapping d’erreur ou API publique n’est modifié.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
README.md
|
||||
crates/common/game-realtime-webtransport-lib/tests/establishment.rs
|
||||
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
|
||||
```
|
||||
|
||||
Nouveau fichier :
|
||||
|
||||
```text
|
||||
deltas/0.3.5/alpha.2.fix.1.md
|
||||
```
|
||||
|
||||
Aucun fichier `history/0.3.5/alpha.2.md` n’est créé avant une gate entièrement verte.
|
||||
|
||||
## Plan
|
||||
|
||||
Le forecast fonctionnel ne change pas. Après validation de ce fix, `0.3.5-alpha.3` reste la tranche dédiée au stream bidirectionnel principal, au framing borné `u32 big-endian + payload` et à l’adaptation `RealtimeConnection`.
|
||||
|
||||
## Validation attendue
|
||||
|
||||
Le correctif touche un test Rust et la version Cargo ; la gate repart depuis le début :
|
||||
|
||||
```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-webtransport-lib --all-targets --all-features
|
||||
```
|
||||
|
||||
Aucun `cargo tree` supplémentaire n’est requis : le graphe de dépendances n’a pas changé et la gate `alpha.2` l’a déjà produit.
|
||||
|
||||
## Après validation
|
||||
|
||||
Créer `history/0.3.5/alpha.2.fix.1.md` à partir des résultats réellement fournis, puis ouvrir directement `0.3.5-alpha.3`.
|
||||
203
deltas/0.3.5/alpha.2.md
Normal file
203
deltas/0.3.5/alpha.2.md
Normal file
@@ -0,0 +1,203 @@
|
||||
<!-- file: deltas/0.3.5/alpha.2.md -->
|
||||
<!-- version: 2 -->
|
||||
|
||||
# Delta 0.3.5-alpha.2
|
||||
|
||||
## Base
|
||||
|
||||
Base : `0.3.5-alpha.1` validée par l'utilisateur le 2026-09-21.
|
||||
|
||||
Cette tranche reste limitée à la fondation WebTransport native prévue par le plan : dépendances minimales, identité TLS, pin SHA-256, bind QUIC/HTTP3 et établissement d'une session client/server. Elle ne contient encore ni stream applicatif principal, ni framing `u32 + payload`, ni adaptation `RealtimeConnection`, ni datagram, ni fallback.
|
||||
|
||||
## Historique fermé
|
||||
|
||||
Ajout de :
|
||||
|
||||
```text
|
||||
history/0.3.5/alpha.1.md
|
||||
```
|
||||
|
||||
L'entrée enregistre exactement la gate utilisateur reçue : fmt check, trois audits propres et `cargo check --workspace` propre sur `0.3.5-alpha.1`. Le plan révisé a été explicitement accepté avant le passage à cette tranche.
|
||||
|
||||
## Version
|
||||
|
||||
La version workspace passe de :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.1
|
||||
```
|
||||
|
||||
à :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.2
|
||||
```
|
||||
|
||||
Aucune version Android, npm ou Tauri indépendante n'est modifiée.
|
||||
|
||||
## Nouvelle crate WebTransport
|
||||
|
||||
Ajout de :
|
||||
|
||||
```text
|
||||
crates/common/game-realtime-webtransport-lib/Cargo.toml
|
||||
crates/common/game-realtime-webtransport-lib/README.md
|
||||
crates/common/game-realtime-webtransport-lib/src/lib.rs
|
||||
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
||||
crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs
|
||||
crates/common/game-realtime-webtransport-lib/tests/establishment.rs
|
||||
```
|
||||
|
||||
La crate est placée sous `crates/common/` au même niveau que le contrat transport-neutral et le backend WebSocket. Elle ne contient aucune sémantique de jeu.
|
||||
|
||||
## Dépendances et features
|
||||
|
||||
Les contraintes nouvelles sont centralisées dans `[workspace.dependencies]` :
|
||||
|
||||
```text
|
||||
rcgen = 0.14.10, default-features = false
|
||||
url = 2.5.8
|
||||
web-transport-quinn = 0.12.1, default-features = false
|
||||
```
|
||||
|
||||
La crate consommatrice active localement uniquement `ring` sur `rcgen` et `web-transport-quinn`.
|
||||
|
||||
`web-transport-quinn` est utilisé directement dans cette tranche native. La façade multiplateforme `web-transport` reste différée jusqu'au chemin WASM, afin de ne pas introduire une dépendance sans consommateur réel.
|
||||
|
||||
Le backend crypto par défaut `aws-lc-rs` de `web-transport-quinn` est donc désactivé. `ring` devient le provider unique du POC natif initial.
|
||||
|
||||
## Identité TLS et pinning
|
||||
|
||||
`WebTransportServerIdentity` accepte deux chemins :
|
||||
|
||||
```text
|
||||
generate_loopback()
|
||||
from_pkcs8_der(certificate_der, private_key_pkcs8_der)
|
||||
```
|
||||
|
||||
La génération loopback produit en mémoire :
|
||||
|
||||
- une clé ECDSA P-256 ;
|
||||
- un certificat self-signed avec SHA-256 ;
|
||||
- les SAN `localhost`, `127.0.0.1` et `::1` ;
|
||||
- une validité de sept jours avec 60 secondes de marge avant l'heure courante ;
|
||||
- aucun PEM ni fichier de clé versionné.
|
||||
|
||||
`WebTransportCertificateHash` porte exactement les 32 octets SHA-256 du certificat. Le client configure `ClientBuilder::with_server_certificate_hashes(...)` ; aucune option de TLS permissif n'est exposée.
|
||||
|
||||
L'injection DER ne prétend pas parser ou certifier la cohérence clé/certificat avant le bind : le builder TLS natif reste l'autorité qui rejette une paire incompatible.
|
||||
|
||||
## Configuration et établissement natif
|
||||
|
||||
`WebTransportClientConfig` impose un endpoint `https://` valide et un hash épinglé.
|
||||
|
||||
`WebTransportServerConfig` possède l'adresse UDP et l'identité TLS.
|
||||
|
||||
`WebTransportListener::bind(...)` :
|
||||
|
||||
- construit le serveur Quinn/WebTransport ;
|
||||
- supporte le port `0` pour une allocation éphémère ;
|
||||
- expose l'adresse effectivement bindée ;
|
||||
- mappe les erreurs vers `TransportErrorKind::Bind`.
|
||||
|
||||
`WebTransportListener::accept(...)` accepte le CONNECT HTTP/3 et retourne une `WebTransportSession`.
|
||||
|
||||
`connect(...)` construit un client pinned et retourne également une `WebTransportSession`. Les erreurs d'établissement client sont mappées vers `Connect`; les erreurs serveur vers `Accept`.
|
||||
|
||||
La session expose uniquement des diagnostics d'établissement (`remote_addr`, URL CONNECT). Le stream fiable applicatif appartient explicitement à `alpha.3`.
|
||||
|
||||
## Tests ajoutés
|
||||
|
||||
Les tests unitaires couvrent :
|
||||
|
||||
- conservation exacte d'un SHA-256 de 32 octets ;
|
||||
- acceptation d'un endpoint HTTPS ;
|
||||
- rejet HTTP/WebSocket ;
|
||||
- rejet d'une identité injectée sans certificat ou sans clé ;
|
||||
- génération d'une identité loopback avec fingerprint SHA-256.
|
||||
|
||||
Le test d'intégration `establishment.rs` couvre :
|
||||
|
||||
- bind sur `127.0.0.1:0` ;
|
||||
- génération d'identité éphémère ;
|
||||
- pin SHA-256 transmis au client ;
|
||||
- établissement client/server concurrent borné par timeout ;
|
||||
- URL CONNECT observée des deux côtés ;
|
||||
- rejet d'un mauvais pin côté client.
|
||||
|
||||
Il ne transmet volontairement aucun payload : ce serait anticiper `alpha.3`.
|
||||
|
||||
## Tracing
|
||||
|
||||
Le nouveau backend utilise :
|
||||
|
||||
```text
|
||||
games::realtime::webtransport
|
||||
```
|
||||
|
||||
pour bind, connexion, accept et diagnostics d'échec d'établissement.
|
||||
|
||||
## Documentation et plan
|
||||
|
||||
`README.md` racine annonce `0.3.5-alpha.2` et la nouvelle frontière native.
|
||||
|
||||
Le README local documente la responsabilité de la crate, le TLS de développement et les frontières encore exclues.
|
||||
|
||||
Le plan `005` est réconcilié avec les choix réellement fermés : dépendances natives directes, provider `ring`, identité sept jours et absence volontaire de façade `web-transport` avant le chemin WASM.
|
||||
|
||||
`ROADMAP.md` et `CHANGELOG.md` restent inchangés : le scope macro de `0.3.5` ne change pas et cette alpha n'est pas un jalon de changelog.
|
||||
|
||||
## Validation exécutée dans l'environnement de génération
|
||||
|
||||
L'environnement de génération ne possède pas de toolchain Rust. Il ne doit donc attribuer aucun `cargo fmt`, `cargo check`, Clippy, test ou `cargo tree` à cette livraison.
|
||||
|
||||
Les audits Python et contrôles statiques sont exécutés après constitution du delta. Sur la reconstruction locale issue du ZIP taggé `v0.3.4` puis du delta `alpha.1`, ils donnent :
|
||||
|
||||
```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), 269 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
TOML/workspace consistency: clean
|
||||
```
|
||||
|
||||
Le compteur de fichiers Markdown de cette reconstruction n'est pas utilisé comme référence pour le checkout utilisateur : la gate `alpha.1` de l'utilisateur comptait déjà davantage de fichiers (`282`) que la reconstruction autoritaire ZIP + delta. Seul le statut clean est comparé. Ces contrôles restent distincts de la gate Cargo utilisateur.
|
||||
|
||||
## Validation utilisateur demandée
|
||||
|
||||
Cette tranche modifie Rust et le graphe de dépendances ; la gate ciblée est donc :
|
||||
|
||||
```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-webtransport-lib --all-targets --all-features
|
||||
|
||||
cargo tree -p game-realtime-webtransport-lib --edges normal
|
||||
cargo tree -p game-realtime-webtransport-lib --edges features
|
||||
cargo tree -i web-transport-quinn --workspace --edges normal
|
||||
```
|
||||
|
||||
Le full `cargo test --workspace --all-targets --all-features` reste réservé au jalon large `beta.1` conformément au plan.
|
||||
|
||||
Aucun smoke executable n'est requis dans `alpha.2` : le test d'intégration prouve l'établissement natif sous harness, tandis que le smoke WebTransport public reste la responsabilité explicite de `alpha.5` après le framing fiable et la robustesse.
|
||||
|
||||
## Suite après validation
|
||||
|
||||
Si la gate est propre, `0.3.5-alpha.3` peut :
|
||||
|
||||
- ouvrir/ accepter le stream bidirectionnel principal ;
|
||||
- ajouter le framing privé borné `u32 big-endian + payload` ;
|
||||
- implémenter `RealtimeConnection`, sender et receiver ;
|
||||
- prouver le round-trip binaire ordonné et plusieurs messages ;
|
||||
- conserver datagrams, robustesse avancée et smoke public hors de cette tranche.
|
||||
|
||||
Un défaut fermé de cette tranche produit d'abord `0.3.5-alpha.2.fix.N` au lieu d'ouvrir `alpha.3`.
|
||||
184
deltas/0.3.5/alpha.3.md
Normal file
184
deltas/0.3.5/alpha.3.md
Normal file
@@ -0,0 +1,184 @@
|
||||
<!-- file: deltas/0.3.5/alpha.3.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.3.5-alpha.3
|
||||
|
||||
## Base requise
|
||||
|
||||
`0.3.5-alpha.2.fix.1`, validée par l'utilisateur le 2026-09-21 avec fmt, audits, check workspace, Clippy strict et les six tests de `game-realtime-webtransport-lib` entièrement verts.
|
||||
|
||||
La validation réellement fournie est conservée dans `history/0.3.5/alpha.2.fix.1.md`.
|
||||
|
||||
## Objectif
|
||||
|
||||
Fermer la première adaptation fiable WebTransport vers `game-realtime-transport-lib` sans introduire encore la robustesse/lifecycle avancés, le navigateur/WASM, les datagrams ou le smoke public.
|
||||
|
||||
La version passe à :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.3
|
||||
```
|
||||
|
||||
## Stream primaire
|
||||
|
||||
Une `WebTransportSession` peut désormais être consommée de deux façons :
|
||||
|
||||
```text
|
||||
client -> open_primary_connection()
|
||||
server -> accept_primary_connection()
|
||||
```
|
||||
|
||||
Chaque chemin sélectionne exactement un stream bidirectionnel fiable et retourne `WebTransportConnection`.
|
||||
|
||||
Le backend `web-transport-quinn` écrit lui-même l'en-tête WebTransport nécessaire pendant `open_bi()`. Le serveur peut donc accepter le stream avant toute frame applicative. Aucun préambule games.sasedev supplémentaire n'est ajouté : après l'en-tête protocolaire géré par la dépendance, le premier octet applicatif appartient directement au framing prévu.
|
||||
|
||||
## Framing fiable privé
|
||||
|
||||
Le framing du stream primaire est :
|
||||
|
||||
```text
|
||||
u32 big-endian payload length
|
||||
payload bytes
|
||||
```
|
||||
|
||||
Il reste privé à `game-realtime-webtransport-lib` et n'est pas un wire codec gameplay.
|
||||
|
||||
La borne POC actuelle est de 1 MiB par message. Elle est vérifiée :
|
||||
|
||||
- avant écriture côté sender ;
|
||||
- immédiatement après décodage des quatre octets de longueur et avant toute allocation côté receiver.
|
||||
|
||||
Une longueur hors limite produit `TransportErrorKind::MessageTooLarge`.
|
||||
|
||||
La limite n'est pas encore exposée comme configuration produit. `alpha.4` possède cette décision avec les autres limites et deadlines.
|
||||
|
||||
## Contrat commun
|
||||
|
||||
`WebTransportConnection` implémente `RealtimeConnection` et produit :
|
||||
|
||||
```text
|
||||
WebTransportSender
|
||||
WebTransportReceiver
|
||||
```
|
||||
|
||||
Les deux moitiés conservent chacune une référence à la session WebTransport. Le `split()` ne ferme donc pas accidentellement la session au moment où l'objet connexion est consommé.
|
||||
|
||||
`WebTransportSender::send(...)` écrit le header puis le payload sur le stream fiable et respecte la backpressure naturelle de QUIC pendant les écritures asynchrones.
|
||||
|
||||
`WebTransportReceiver::receive(...)` reconstruit exactement un `TransportMessage`, y compris les payloads vides et binaires non UTF-8.
|
||||
|
||||
`RealtimeSender::close()` termine proprement la direction locale du stream primaire. Lorsque le pair a consommé les messages précédents puis atteint le FIN, `RealtimeReceiver::receive()` retourne `TransportReceive::Closed`.
|
||||
|
||||
La fermeture complète de session, reset, abort, cancellation, deadlines et mapping fin des erreurs restent hors de cette tranche.
|
||||
|
||||
## API et documentation
|
||||
|
||||
`src/lib.rs` réexporte les nouveaux types publics conformément aux règles du workspace.
|
||||
|
||||
Le README local est réconcilié avec le stream primaire et ses frontières. Un `USAGE.md` durable est ajouté car l'ordre session -> stream primaire -> `RealtimeConnection` et la contrainte d'accept serveur nécessitent désormais un guide d'utilisation distinct du delta.
|
||||
|
||||
Le plan `005` enregistre les décisions réellement matérialisées sans modifier le forecast de `alpha.4+`.
|
||||
|
||||
`ROADMAP.md` et `CHANGELOG.md` restent inchangés : cette alpha ne modifie ni la mission macro de `0.3.5` ni une livraison stable.
|
||||
|
||||
## Tests
|
||||
|
||||
Le test unitaire du backend ajoute la preuve que :
|
||||
|
||||
- la longueur est encodée en `u32` big-endian ;
|
||||
- la borne de frame refuse une taille supérieure à 1 MiB avec `MessageTooLarge`.
|
||||
|
||||
Le nouveau test d'intégration `tests/realtime_connection.rs` prouve en loopback :
|
||||
|
||||
- ouverture du stream primaire côté client ;
|
||||
- ouverture cliente puis accept du stream primaire côté serveur avant la première frame applicative ;
|
||||
- adaptation `RealtimeConnection` ;
|
||||
- payload vide ;
|
||||
- payload binaire non UTF-8 ;
|
||||
- ordre de plusieurs messages ;
|
||||
- echo bidirectionnel ;
|
||||
- FIN client observé comme `TransportReceive::Closed` côté serveur ;
|
||||
- FIN serveur observé comme `TransportReceive::Closed` côté client.
|
||||
|
||||
Les sessions restent vivantes pendant toute la preuve afin que le test ne confonde pas FIN du stream logique et drop de session.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
README.md
|
||||
crates/common/game-realtime-webtransport-lib/README.md
|
||||
crates/common/game-realtime-webtransport-lib/src/lib.rs
|
||||
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
||||
crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs
|
||||
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
|
||||
```
|
||||
|
||||
Nouveaux fichiers :
|
||||
|
||||
```text
|
||||
crates/common/game-realtime-webtransport-lib/USAGE.md
|
||||
crates/common/game-realtime-webtransport-lib/tests/realtime_connection.rs
|
||||
history/0.3.5/alpha.2.fix.1.md
|
||||
deltas/0.3.5/alpha.3.md
|
||||
```
|
||||
|
||||
Aucune dépendance Cargo n'est ajoutée ou modifiée dans cette tranche.
|
||||
|
||||
## Validation exécutée dans l'environnement de génération
|
||||
|
||||
L'environnement de génération ne possède pas de toolchain Rust. Aucun `cargo fmt`, `cargo check`, Clippy ou test n'est donc attribué à cette livraison.
|
||||
|
||||
Les audits statiques disponibles ont été exécutés sur le candidat final :
|
||||
|
||||
```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), 273 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
```
|
||||
|
||||
Le compteur de fichiers Markdown correspond à la reconstruction de travail issue de l'archive taggée et des deltas fournis. Il n'est pas utilisé comme invariant contre le checkout utilisateur, qui contient davantage de fichiers suivis localement.
|
||||
|
||||
## Validation utilisateur demandée
|
||||
|
||||
Cette tranche modifie le backend Rust et ajoute un test d'intégration. La gate ciblée est :
|
||||
|
||||
```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-webtransport-lib --all-targets --all-features
|
||||
|
||||
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
|
||||
```
|
||||
|
||||
Le contrat commun est retesté parce que `alpha.3` en devient un nouvel implémenteur, même si sa crate n'est pas modifiée.
|
||||
|
||||
Le `cargo tree` inverse vérifie qu'aucune crate moteur ou gameplay n'a acquis de dépendance vers le backend concret. Les arbres complets `web-transport-quinn` de `alpha.2` ne sont pas répétés puisque le graphe de dépendances n'a pas changé.
|
||||
|
||||
Aucun smoke executable n'est encore attendu : le smoke natif public reste `alpha.5`, après la tranche de robustesse `alpha.4`.
|
||||
|
||||
## Suite après validation
|
||||
|
||||
Si la gate est propre, ouvrir `0.3.5-alpha.4` pour :
|
||||
|
||||
- limites configurables si justifiées ;
|
||||
- deadlines ;
|
||||
- backpressure/erreurs observables ;
|
||||
- close/reset/abort ;
|
||||
- cancellation/drop ;
|
||||
- cas négatifs de framing ;
|
||||
- mapping d'erreurs détaillé ;
|
||||
- tests de robustesse ciblés.
|
||||
|
||||
Un défaut fermé de cette tranche produit d'abord `0.3.5-alpha.3.fix.N` au lieu d'ouvrir `alpha.4`.
|
||||
96
deltas/0.3.5/alpha.4.fix.1.md
Normal file
96
deltas/0.3.5/alpha.4.fix.1.md
Normal file
@@ -0,0 +1,96 @@
|
||||
<!-- file: deltas/0.3.5/alpha.4.fix.1.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.3.5-alpha.4.fix.1
|
||||
|
||||
## Base requise
|
||||
|
||||
`0.3.5-alpha.4`, candidate dont la gate utilisateur du 2026-09-21 a passé fmt, audits, `cargo check`, les sept tests du contrat realtime commun et l'ensemble des vingt tests WebTransport, mais a échoué sur Clippy strict.
|
||||
|
||||
`alpha.4` n'est pas historisée comme validée : `cargo clippy --workspace --all-targets --all-features -- -D warnings` reste rouge jusqu'à ce correctif.
|
||||
|
||||
## Cause
|
||||
|
||||
Clippy signale une unique violation `clippy::implicit-return` dans l'implémentation de `RealtimeReceiver::receive()` :
|
||||
|
||||
```text
|
||||
error: missing `return` statement
|
||||
--> crates/common/game-realtime-webtransport-lib/src/webtransport.rs:449:38
|
||||
|
||||
return Box::pin(async move { self.receive_frame().await });
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
```
|
||||
|
||||
Le `return` externe est déjà explicite, mais la closure `async move` contient encore une expression terminale implicite, interdite par les règles Clippy strictes du workspace.
|
||||
|
||||
Les mêmes résultats fournis par l'utilisateur confirment parallèlement que :
|
||||
|
||||
- les audits Rust/workspace, Markdown et distribution sont propres ;
|
||||
- `cargo check --workspace` est propre ;
|
||||
- les sept tests de `game-realtime-transport-lib` passent ;
|
||||
- les onze tests unitaires, deux tests d'établissement, un test de round-trip et six tests de robustesse de `game-realtime-webtransport-lib` passent ;
|
||||
- le graphe direct/inverse du backend WebTransport reste celui attendu.
|
||||
|
||||
Le défaut est donc purement un écart de style/lint ; aucun défaut fonctionnel WebTransport n'est observé dans cette gate.
|
||||
|
||||
## Correction
|
||||
|
||||
La version technique passe à :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.4.fix.1
|
||||
```
|
||||
|
||||
La future retournée par `RealtimeReceiver::receive()` explicite aussi son `return` interne :
|
||||
|
||||
```rust
|
||||
return Box::pin(async move { return self.receive_frame().await });
|
||||
```
|
||||
|
||||
Aucun comportement runtime, état du parseur incrémental, framing, deadline, mapping d'erreur, lifecycle, API publique ou dépendance n'est modifié.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
README.md
|
||||
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
||||
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
|
||||
```
|
||||
|
||||
Nouveau fichier :
|
||||
|
||||
```text
|
||||
deltas/0.3.5/alpha.4.fix.1.md
|
||||
```
|
||||
|
||||
Aucun fichier `history/0.3.5/alpha.4.md` n'est créé avant une gate entièrement verte du correctif.
|
||||
|
||||
## Plan
|
||||
|
||||
Le forecast fonctionnel ne change pas. Après validation de ce fix, `0.3.5-alpha.5` reste strictement la tranche du smoke WebTransport natif hors harness.
|
||||
|
||||
## Validation attendue
|
||||
|
||||
Le correctif touche du code Rust et la version workspace ; la gate repart depuis le début :
|
||||
|
||||
```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-webtransport-lib --all-targets --all-features
|
||||
```
|
||||
|
||||
Aucun `cargo tree` supplémentaire n'est requis : ce fix ne change ni dépendance, ni feature, ni frontière de crate, et le tree de `alpha.4` a déjà été produit.
|
||||
|
||||
## Après validation
|
||||
|
||||
Créer `history/0.3.5/alpha.4.fix.1.md` à partir des résultats réellement fournis, puis ouvrir directement `0.3.5-alpha.5`.
|
||||
208
deltas/0.3.5/alpha.4.md
Normal file
208
deltas/0.3.5/alpha.4.md
Normal file
@@ -0,0 +1,208 @@
|
||||
<!-- file: deltas/0.3.5/alpha.4.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta 0.3.5-alpha.4
|
||||
|
||||
## Base requise
|
||||
|
||||
`0.3.5-alpha.3`, validée par l'utilisateur le 2026-09-21 avec fmt, audits, check workspace, Clippy strict, les sept tests du contrat realtime commun et les huit tests WebTransport entièrement verts.
|
||||
|
||||
La validation réellement fournie est conservée dans `history/0.3.5/alpha.3.md`.
|
||||
|
||||
## Objectif
|
||||
|
||||
Fermer la robustesse du chemin fiable WebTransport natif avant de créer le smoke runtime public. Cette tranche porte limites/deadlines, lifecycle, cancellation et cas négatifs de framing, sans introduire encore WASM, datagrams, fallback ou benchmark.
|
||||
|
||||
La version passe à :
|
||||
|
||||
```text
|
||||
0.3.5-alpha.4
|
||||
```
|
||||
|
||||
## Configuration et limites
|
||||
|
||||
Nouveau `WebTransportConfig` partagé par les configs client/server, avec defaults POC explicites :
|
||||
|
||||
```text
|
||||
max_message_size = 1 MiB
|
||||
connect_timeout = 10 s
|
||||
primary_stream_timeout = 5 s
|
||||
send_timeout = 5 s
|
||||
```
|
||||
|
||||
La configuration est immutable par builders copiés et validée avant bind/connect. Sont refusés :
|
||||
|
||||
- `max_message_size == 0` ;
|
||||
- une borne qui ne tient pas dans le champ de longueur `u32` sur les plateformes où cela peut arriver ;
|
||||
- une deadline nulle.
|
||||
|
||||
La borne n'est plus une constante cachée : sender et receiver utilisent chacun la configuration transport associée à leur session. Le receiver contrôle toujours la longueur avant allocation.
|
||||
|
||||
## Deadlines et backpressure
|
||||
|
||||
Le client borne la tentative d'établissement complète par `connect_timeout`.
|
||||
|
||||
Le listener conserve une attente non bornée du prochain pair, comportement normal d'un serveur idle. Une fois la requête WebTransport CONNECT matérialisée, la réponse serveur est bornée par `connect_timeout`.
|
||||
|
||||
Ouverture et accept du stream bidirectionnel primaire sont bornés par `primary_stream_timeout`.
|
||||
|
||||
Un send complet — header puis payload — est borné par `send_timeout`. La backpressure reste celle du flow-control QUIC : aucune queue applicative artificielle n'est introduite. Si le flow-control empêche l'envoi de terminer dans la deadline, le résultat observable est `TransportErrorKind::Timeout`.
|
||||
|
||||
`receive()` n'impose volontairement aucun timeout d'idle. L'absence de message n'est pas une erreur transport ; la future peut être bornée/annulée par l'appelant.
|
||||
|
||||
## Cancellation sûre du receive
|
||||
|
||||
Le receiver n'utilise plus une lecture monolithique header/payload. Il garde un état incrémental :
|
||||
|
||||
```text
|
||||
header bytes read
|
||||
payload allocation after validated length
|
||||
payload bytes read
|
||||
```
|
||||
|
||||
La primitive de lecture amont utilisée est cancel-safe. Si une future `receive()` est abandonnée après une partie de la frame, l'état acquis reste dans `WebTransportReceiver` et l'appel suivant reprend au bon octet.
|
||||
|
||||
Cela permet de combiner un wait externe borné avec le contrat `RealtimeReceiver` sans perdre de bytes ni désynchroniser le framing.
|
||||
|
||||
## Close, reset, abort et drop
|
||||
|
||||
Le FIN propre reste `RealtimeSender::close()`.
|
||||
|
||||
Le backend concret expose en complément :
|
||||
|
||||
```text
|
||||
WebTransportSender::abort(code)
|
||||
WebTransportReceiver::abort(code)
|
||||
```
|
||||
|
||||
Le premier reset la direction d'envoi ; le second stoppe la direction de réception.
|
||||
|
||||
Un sender ou receiver encore actif au moment de son drop est aborté explicitement au lieu de laisser le backend transformer implicitement le drop en FIN propre.
|
||||
|
||||
Un `send()` annulé en cours d'écriture est terminal : un guard reset le stream, car une frame dont seulement le header ou une partie du payload a été écrit ne peut pas être reprise sans ambiguïté. Une cancellation de `receive()` reste au contraire reprenable grâce au parseur incrémental.
|
||||
|
||||
## Framing négatif et mapping d'erreurs
|
||||
|
||||
Les nouvelles branches explicites sont :
|
||||
|
||||
- longueur entrante supérieure à `max_message_size` -> `MessageTooLarge`, avant allocation, puis stop de la direction ;
|
||||
- EOF à frontière de frame -> `TransportReceive::Closed` ;
|
||||
- EOF au milieu du header ou du payload -> `Protocol` ;
|
||||
- reset/STOP distant -> `Aborted` ;
|
||||
- stream/session fermé proprement -> `Closed` ;
|
||||
- état de stream invalide rapporté par le backend -> `Protocol` ;
|
||||
- deadline dépassée -> `Timeout` ;
|
||||
- autre erreur de session -> `Io`.
|
||||
|
||||
Les types d'erreurs Quinn/WebTransport restent privés à la crate backend.
|
||||
|
||||
## Tests
|
||||
|
||||
Les tests unitaires ajoutent :
|
||||
|
||||
- validation des defaults et configurations invalides ;
|
||||
- mapping reset/STOP, close et erreur protocolaire ;
|
||||
- cancellation d'un `receive()` après deux octets de header puis reprise exacte ;
|
||||
- header ou payload tronqué par FIN -> `Protocol`.
|
||||
|
||||
Le nouveau `tests/robustness.rs` prouve en loopback :
|
||||
|
||||
- rejet outbound d'un message supérieur à la borne avant écriture ;
|
||||
- rejet inbound d'une longueur supérieure à la borne avant allocation ;
|
||||
- reset explicite sender observé comme `Aborted` par le pair ;
|
||||
- abort explicite receiver rendant immédiatement sa moitié locale terminale ;
|
||||
- drop sender sans `close()` observé comme `Aborted`, pas comme FIN propre ;
|
||||
- deadline d'accept du stream primaire lorsque le pair n'en ouvre aucun.
|
||||
|
||||
Aucun test de saturation artificielle n'est ajouté : provoquer de façon déterministe le flow-control QUIC sans s'appuyer sur des internals amont élargirait inutilement la tranche. Le mécanisme est borné par `send_timeout` et sera aussi exercé indirectement par les smokes/mesures ultérieurs.
|
||||
|
||||
## API et documentation
|
||||
|
||||
`src/lib.rs` réexporte `WebTransportConfig` conformément aux règles d'exports du workspace.
|
||||
|
||||
`README.md` et `USAGE.md` de la crate documentent les limites, le scope exact des deadlines, les différences FIN/abort/drop et les garanties de cancellation.
|
||||
|
||||
Le plan `005` est réconcilié avec les décisions réellement matérialisées. `ROADMAP.md` et `CHANGELOG.md` restent inchangés : la mission macro de `0.3.5` n'est pas modifiée par cette alpha.
|
||||
|
||||
## Dépendances
|
||||
|
||||
Aucune nouvelle crate n'est introduite.
|
||||
|
||||
`tokio`, déjà présent dans le workspace et déjà utilisé comme dev-dependency de la crate, devient aussi une dépendance runtime locale avec la feature `time`, car le backend porte désormais ses deadlines opérationnelles.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
README.md
|
||||
crates/common/game-realtime-webtransport-lib/Cargo.toml
|
||||
crates/common/game-realtime-webtransport-lib/README.md
|
||||
crates/common/game-realtime-webtransport-lib/USAGE.md
|
||||
crates/common/game-realtime-webtransport-lib/src/lib.rs
|
||||
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
||||
crates/common/game-realtime-webtransport-lib/unit_tests/webtransport.rs
|
||||
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
|
||||
```
|
||||
|
||||
Nouveaux fichiers :
|
||||
|
||||
```text
|
||||
crates/common/game-realtime-webtransport-lib/src/config.rs
|
||||
crates/common/game-realtime-webtransport-lib/tests/robustness.rs
|
||||
crates/common/game-realtime-webtransport-lib/unit_tests/config.rs
|
||||
history/0.3.5/alpha.3.md
|
||||
deltas/0.3.5/alpha.4.md
|
||||
```
|
||||
|
||||
## Validation exécutée dans l'environnement de génération
|
||||
|
||||
L'environnement de génération ne possède pas de toolchain Rust. Aucun `cargo fmt`, `cargo check`, Clippy ou test n'est donc attribué à cette livraison.
|
||||
|
||||
Les audits statiques disponibles ont été exécutés sur le candidat final :
|
||||
|
||||
```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), 275 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
Targeted alpha.4 consistency audit: clean
|
||||
```
|
||||
|
||||
Le compteur Markdown correspond à la reconstruction de travail issue du ZIP taggé et des deltas appliqués ; il n'est pas utilisé comme invariant contre le checkout utilisateur.
|
||||
|
||||
Le ZIP delta a ensuite été appliqué sur une copie propre de `0.3.5-alpha.3`. L'overlay reproduit exactement le candidat `alpha.4`, contient 14 fichiers utiles et repasse les mêmes audits Rust/workspace, Markdown et distribution, ainsi que le contrôle ciblé de version/cohérence. `unzip -t` ne signale aucune erreur.
|
||||
|
||||
## Validation utilisateur demandée
|
||||
|
||||
Cette tranche modifie le backend runtime et son graphe local de features Tokio. La gate ciblée est :
|
||||
|
||||
```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-webtransport-lib --all-targets --all-features
|
||||
|
||||
cargo tree -p game-realtime-webtransport-lib --edges normal
|
||||
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
|
||||
```
|
||||
|
||||
Le contrat commun est retesté pour garantir que les nouvelles catégories de lifecycle du backend restent compatibles avec son API transport-neutral.
|
||||
|
||||
Le tree direct vérifie le passage de Tokio en dépendance runtime locale ; le tree inverse conserve la preuve qu'aucune crate engine/gameplay n'a acquis le backend concret.
|
||||
|
||||
Aucun smoke executable n'est attendu dans cette tranche. Le smoke natif hors harness reste strictement réservé à `alpha.5` afin de respecter la taille d'un delta.
|
||||
|
||||
## Suite après validation
|
||||
|
||||
Si la gate est propre, ouvrir `0.3.5-alpha.5` pour créer/finaliser `game-realtime-webtransport-smoke`, effectuer un round-trip localhost hors `#[test]`, produire un `PASS` déterministe et contrôler le graphe runtime.
|
||||
|
||||
Un défaut fermé de cette tranche produit d'abord `0.3.5-alpha.4.fix.N` au lieu d'ouvrir `alpha.5`.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/000-README.md -->
|
||||
<!-- version: 34 -->
|
||||
<!-- version: 35 -->
|
||||
|
||||
# Documentation games.sasedev
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
- [`ideas/000-README.md`](ideas/000-README.md) — rôle des idées non engagées et règles de maturation.
|
||||
- [`studies/000-README.md`](studies/000-README.md) — rôle des études comparatives non normatives.
|
||||
- [`studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`](studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md) — audit `0.3.5` des stacks WebTransport/QUIC, du contrat transport, de TLS et des plateformes.
|
||||
|
||||
## Plans
|
||||
|
||||
@@ -19,6 +20,7 @@
|
||||
- [`plans/002-V0_3_1_TAURI_ANDROID_SNAKE_PLAN.md`](plans/002-V0_3_1_TAURI_ANDROID_SNAKE_PLAN.md) — plan clôturé de `0.3.1`, second host Snake Tauri Android.
|
||||
- [`plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md`](plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md) — plan clôturé de `0.3.3`, pipeline Android SDL3 natif Gradle/Cargo multi-ABI.
|
||||
- [`plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md`](plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md) — plan clôturé de `0.3.4`, API de transport realtime et baseline WebSocket Tokio/tokio-tungstenite.
|
||||
- [`plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md`](plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md) — plan actif de `0.3.5`, POC WebTransport/QUIC, navigateur, fallback WebSocket et mesures comparatives.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/000-README.md -->
|
||||
<!-- version: 6 -->
|
||||
<!-- version: 7 -->
|
||||
|
||||
# Plans de versions games.sasedev
|
||||
|
||||
@@ -15,3 +15,4 @@ Le `ROADMAP.md` reste la trajectoire macroscopique du projet ; les deltas décri
|
||||
- [`002-V0_3_1_TAURI_ANDROID_SNAKE_PLAN.md`](002-V0_3_1_TAURI_ANDROID_SNAKE_PLAN.md) — plan clôturé de `0.3.1`, second host Snake Tauri Android.
|
||||
- [`003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md`](003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md) — plan clôturé de `0.3.3`, pipeline Android SDL3 natif Gradle/Cargo multi-ABI.
|
||||
- [`004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md`](004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md) — plan clôturé de `0.3.4`, API de transport realtime et baseline WebSocket Tokio/tokio-tungstenite.
|
||||
- [`005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md`](005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md) — plan actif de `0.3.5`, POC WebTransport/QUIC, navigateur, fallback WebSocket et comparaison mesurée.
|
||||
|
||||
633
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
Normal file
633
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
Normal file
@@ -0,0 +1,633 @@
|
||||
<!-- file: docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md -->
|
||||
<!-- version: 6 -->
|
||||
|
||||
# Plan 0.3.5 — POC WebTransport/QUIC et fallback WebSocket
|
||||
|
||||
## Statut
|
||||
|
||||
Plan actif créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`, puis réconcilié pour l'implémentation native de `0.3.5-alpha.2`, son correctif de validation `0.3.5-alpha.2.fix.1`, le chemin fiable validé de `0.3.5-alpha.3`, la robustesse candidate de `0.3.5-alpha.4` et son correctif de gate `0.3.5-alpha.4.fix.1`.
|
||||
|
||||
Le cadrage détaillé et la comparaison des stacks actuelles sont conservés dans `docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`. Le présent document porte les décisions opérationnelles, le scope, les gates et le forecast vivant de la version.
|
||||
|
||||
## Mission
|
||||
|
||||
`0.3.5` doit déterminer par des preuves reproductibles si WebTransport/QUIC mérite de devenir un second backend realtime aux côtés de WebSocket.
|
||||
|
||||
La version doit :
|
||||
|
||||
- conserver `game-realtime-transport-lib` comme frontière transport-neutral tant qu'aucun besoin commun ne justifie son évolution ;
|
||||
- implémenter un chemin WebTransport fiable compatible avec `TransportMessage` ;
|
||||
- prouver client/server natifs en loopback ;
|
||||
- prouver un chemin navigateur/WASM réel si la stack retenue reste viable ;
|
||||
- prouver le traitement des certificats de développement sans désactivation permanente de TLS ;
|
||||
- exercer le fallback WebSocket au niveau composition ;
|
||||
- comparer WebSocket et WebTransport avec des mesures locales bornées ;
|
||||
- challenger séparément les datagrams sans les forcer dans le contrat fiable ;
|
||||
- conclure `retained`, `deferred` ou `rejected` pour la trajectoire produit.
|
||||
|
||||
La frontière reste :
|
||||
|
||||
```text
|
||||
transport
|
||||
↓
|
||||
wire codec
|
||||
↓
|
||||
session protocol
|
||||
↓
|
||||
synchronization
|
||||
↓
|
||||
authoritative simulation
|
||||
```
|
||||
|
||||
Aucune couche supérieure n'est introduite dans `0.3.5`.
|
||||
|
||||
## Décisions acquises en alpha.1
|
||||
|
||||
### Stack POC primaire
|
||||
|
||||
Famille retenue :
|
||||
|
||||
```text
|
||||
web-transport 0.12.x
|
||||
native -> web-transport-quinn 0.12.x
|
||||
wasm32 -> web-transport-wasm 0.6.x
|
||||
```
|
||||
|
||||
La version exacte résolue par Cargo sera enregistrée dans le delta qui introduit les dépendances. Les contraintes restent centralisées sous `[workspace.dependencies]` conformément à `RUST-DEP-001` et les features sont choisies localement par la crate consommatrice.
|
||||
|
||||
`wtransport 0.7.x` reste le candidat de repli prioritaire si une exigence concrète bloque la famille primaire. Quinn/H3 brut n'est pas le premier choix.
|
||||
|
||||
### Fermeture de dépendances en alpha.2
|
||||
|
||||
La première tranche native utilise directement :
|
||||
|
||||
```text
|
||||
web-transport-quinn 0.12.1, default-features = false, feature ring
|
||||
rcgen 0.14.10, default-features = false, feature ring
|
||||
url 2.5.8
|
||||
```
|
||||
|
||||
La façade `web-transport` n'est pas ajoutée avant le chemin WASM : `alpha.2` ne possède qu'un backend natif et n'a pas besoin d'une abstraction multiplateforme encore inutilisée. `ring` est choisi explicitement et seul pour éviter le backend crypto par défaut `aws-lc-rs` de `web-transport-quinn` et garder le graphe POC plus petit et déterministe.
|
||||
|
||||
L'identité locale générée est ECDSA P-256/SHA-256, contient les SAN `localhost`, `127.0.0.1` et `::1`, vit sept jours avec une petite marge de clock skew et reste uniquement en mémoire. Une identité X.509 DER + PKCS#8 DER peut aussi être injectée. Le client accepte uniquement le hash SHA-256 explicitement configuré ; aucune API de désactivation de validation TLS n'est exposée.
|
||||
|
||||
`alpha.2` retourne une session WebTransport établie mais n'ouvre encore aucun stream applicatif et n'implémente pas `RealtimeConnection`. Cette séparation ferme la preuve QUIC/TLS avant le framing de `alpha.3`.
|
||||
|
||||
La gate de `alpha.2` a confirmé compilation, Clippy, configuration TLS/pinning et rejet d’un mauvais pin, mais le test positif comparait strictement `127.0.0.1:port` à sa représentation IPv4-mapped IPv6 `[::ffff:127.0.0.1]:port` remontée par Quinn. `alpha.2.fix.1` normalise uniquement cette représentation dans le test d’intégration ; aucun contrat, comportement transport ou scope de `alpha.3` n’est modifié.
|
||||
|
||||
### Fermeture du chemin fiable en alpha.3
|
||||
|
||||
`alpha.3` conserve `game-realtime-transport-lib` inchangé et adapte le backend concret à ses traits. Une `WebTransportSession` devient une `WebTransportConnection` après sélection d'un unique stream bidirectionnel primaire : le client l'ouvre, le serveur l'accepte. Le `split()` conserve une copie de la session dans chaque moitié afin que la session QUIC/WebTransport ne soit pas fermée au moment où l'objet connexion est consommé.
|
||||
|
||||
Le framing privé est exactement :
|
||||
|
||||
```text
|
||||
u32 big-endian payload length
|
||||
payload bytes
|
||||
```
|
||||
|
||||
La borne POC est fixée à 1 MiB par message dans cette tranche. Elle est vérifiée avant écriture et, surtout, avant allocation côté réception. Cette limite reste interne : `alpha.4` décide sa configuration produit avec deadlines, backpressure, reset/abort/cancellation et mapping d'erreurs détaillé.
|
||||
|
||||
Le FIN du stream primaire constitue la fermeture logique de base de `alpha.3` et produit `TransportReceive::Closed` après consommation des messages déjà écrits. La fermeture de session complète et les scénarios de lifecycle avancés restent explicitement réservés à `alpha.4`.
|
||||
|
||||
`web-transport-quinn` écrit l'en-tête WebTransport du stream durant `open_bi()` avant de rendre le stream au code appelant. L'accept serveur peut donc terminer avant la première frame applicative ; le framing games.sasedev commence directement au premier octet applicatif et n'ajoute aucun préambule de visibilité.
|
||||
|
||||
La gate utilisateur de `alpha.3` a ensuite confirmé fmt, audits, check workspace, Clippy strict, les sept tests du contrat commun, les sept tests unitaires/établissement WebTransport déjà présents et le round-trip primaire dédié. Le graphe inverse confirme aussi que `game-realtime-webtransport-lib` reste un backend feuille et ne remonte pas dans engine/gameplay.
|
||||
|
||||
### Fermeture robustesse et lifecycle en alpha.4
|
||||
|
||||
`alpha.4` remplace la borne interne figée par `WebTransportConfig`, partagé par les configs client/server. Les valeurs par défaut restent volontairement conservatrices pour le POC : 1 MiB par message, 10 s pour la connexion/fin de réponse CONNECT, 5 s pour l'ouverture ou l'accept du stream primaire et 5 s pour un envoi complet. Les valeurs nulles ou une taille incompatible avec le champ de longueur `u32` sont rejetées avant démarrage.
|
||||
|
||||
La notion de deadline est volontairement opérationnelle et non un timeout d'inactivité global :
|
||||
|
||||
- le client borne l'établissement de session ;
|
||||
- côté serveur, l'attente du prochain pair reste non bornée comme un listener normal, puis la réponse WebTransport après matérialisation de la requête CONNECT est bornée ;
|
||||
- ouverture/accept du stream primaire sont bornés ;
|
||||
- un envoi header + payload est borné, de sorte qu'un blocage durable sous flow-control/backpressure devient un `TransportErrorKind::Timeout` visible ;
|
||||
- `receive()` n'a pas de timeout d'idle implicite : l'absence de message n'est pas une panne de transport et l'appelant peut annuler sa future sans perdre l'état du framing.
|
||||
|
||||
Le receiver utilise donc un parseur incrémental persistant fondé sur la lecture cancel-safe du backend. Une future `receive()` abandonnée après une partie du header ou du payload peut être relancée et reprend au bon octet. EOF au milieu d'une frame devient `Protocol`, tandis qu'un EOF à frontière de frame reste `TransportReceive::Closed`. Une longueur supérieure à la borne est refusée avant allocation puis la direction de réception est stoppée.
|
||||
|
||||
Le lifecycle distingue désormais explicitement FIN propre et abandon :
|
||||
|
||||
- `RealtimeSender::close()` émet le FIN propre ;
|
||||
- `WebTransportSender::abort(code)` reset la direction d'envoi ;
|
||||
- `WebTransportReceiver::abort(code)` stoppe la direction de réception ;
|
||||
- drop d'un sender/receiver encore actif provoque reset/stop au lieu de synthétiser une fermeture propre ;
|
||||
- cancellation d'un `send()` en cours arme un guard terminal qui reset le stream, car une frame partiellement écrite ne peut pas être reprise sans ambiguïté ;
|
||||
- cancellation d'un `receive()` reste non terminale grâce au parseur incrémental.
|
||||
|
||||
Les erreurs amont restent confinées au backend : reset/STOP observables -> `Aborted`, FIN/stream/session fermé proprement -> `Closed`, framing/état de stream invalide -> `Protocol`, dépassement -> `MessageTooLarge`, deadline -> `Timeout`, et autres erreurs de session -> `Io`. Aucun type Quinn/WebTransport n'entre dans `game-realtime-transport-lib`.
|
||||
|
||||
Aucune file applicative n'est ajoutée pour fabriquer artificiellement `Backpressure` : la pression est celle du flow-control QUIC. Lorsqu'elle empêche un envoi de terminer dans sa deadline, l'erreur observable est `Timeout`. Un test synthétique de saturation n'est pas imposé tant qu'il ne peut pas être rendu déterministe sans dépendre d'internals amont.
|
||||
|
||||
### Ownership physique
|
||||
|
||||
Nouveau backend durable candidat :
|
||||
|
||||
```text
|
||||
crates/common/game-realtime-webtransport-lib
|
||||
```
|
||||
|
||||
Responsabilités :
|
||||
|
||||
- établissement WebTransport natif et, si viable, client WASM ;
|
||||
- adaptation du chemin fiable vers `game-realtime-transport-lib` ;
|
||||
- framing binaire privé du stream principal ;
|
||||
- TLS/certificat/hash nécessaires au transport ;
|
||||
- limites, timeouts, close/reset/cancellation et mapping d'erreurs ;
|
||||
- tracing `games::realtime::webtransport` ;
|
||||
- tests natifs déterministes ;
|
||||
- aucune notion joueur/room/tick/snapshot.
|
||||
|
||||
Launchers techniques candidats :
|
||||
|
||||
```text
|
||||
crates/apps/game-realtime-webtransport-smoke
|
||||
crates/apps/game-realtime-transport-benchmark
|
||||
```
|
||||
|
||||
Un launcher séparé de fallback n'est créé que si le smoke WebTransport ou le benchmark ne peut pas porter proprement cette preuve. Ne pas créer plusieurs exécutables uniquement pour refléter chaque alpha.
|
||||
|
||||
Pour le navigateur, le frontend technique exact est décidé au moment de `alpha.7` après validation du chemin WASM. S'il faut un host Vite direct, il doit rester explicitement technique et ne pas contaminer `Web/game-snake-poc` ou le gameplay Snake.
|
||||
|
||||
### Contrat commun
|
||||
|
||||
`game-realtime-transport-lib` reste inchangé dans le plan initial.
|
||||
|
||||
Le chemin fiable utilise :
|
||||
|
||||
```text
|
||||
one WebTransport session
|
||||
-> one primary bidirectional reliable stream
|
||||
-> u32 big-endian length
|
||||
-> payload bytes
|
||||
```
|
||||
|
||||
Le framing est privé au backend, borné avant allocation et distinct du futur wire codec.
|
||||
|
||||
Le client ouvre le stream principal ; le serveur l'accepte avant de retourner une connexion utilisable. Le split commun mappe ensuite write/read du stream principal vers `RealtimeSender`/`RealtimeReceiver`.
|
||||
|
||||
Toute modification du contrat commun requiert un besoin impossible à satisfaire proprement par WebSocket et WebTransport autrement ; elle n'est pas autorisée pour harmoniser les noms d'API.
|
||||
|
||||
### Datagrams
|
||||
|
||||
Les datagrams restent hors `RealtimeConnection` parce qu'ils sont non fiables et non ordonnés.
|
||||
|
||||
Le POC les exerce uniquement comme capacité backend-spécifique/mesure. Aucune API commune durable n'est créée sans second consommateur réel.
|
||||
|
||||
### TLS de développement
|
||||
|
||||
Le POC privilégie :
|
||||
|
||||
- certificat self-signed X.509v3 court ;
|
||||
- ECDSA P-256 ;
|
||||
- validité totale inférieure à deux semaines ;
|
||||
- pin SHA-256 côté client natif et navigateur ;
|
||||
- aucune clé privée durable versionnée ;
|
||||
- aucune désactivation globale de validation TLS.
|
||||
|
||||
Le mode PKI publique, ACME et reverse proxy HTTP/3 restent hors scope de `0.3.5`.
|
||||
|
||||
### Fallback
|
||||
|
||||
Le fallback vit au niveau composition/application :
|
||||
|
||||
```text
|
||||
WebTransport attempt
|
||||
-> success: WebTransport
|
||||
-> classified unavailable/establishment failure: WebSocket
|
||||
```
|
||||
|
||||
Le test doit pouvoir forcer les deux branches.
|
||||
|
||||
Pas de `TransportManager`, registry de plugins ou sélection dynamique générique dans `game-realtime-transport-lib`.
|
||||
|
||||
## Plateformes et preuves
|
||||
|
||||
### Linux natif — obligatoire
|
||||
|
||||
- compilation backend ;
|
||||
- client/server loopback sur adresse/port éphémère ;
|
||||
- round-trip binaire ;
|
||||
- limites/timeouts/close/reset ;
|
||||
- smoke runtime hors `#[test]` ;
|
||||
- mesures locales WebSocket vs WebTransport.
|
||||
|
||||
### WASM/navigateur — obligatoire si la stack primaire reste viable
|
||||
|
||||
Deux gates distinctes :
|
||||
|
||||
1. build/check réel `wasm32-unknown-unknown` du chemin WebTransport Rust ;
|
||||
2. smoke runtime dans un navigateur récent vers le serveur Rust local.
|
||||
|
||||
Le cfg `web_sys_unstable_apis` doit être ciblé sur WASM uniquement.
|
||||
|
||||
### Android — trajectoire, pas intégration produit
|
||||
|
||||
- vérifier la compatibilité des dépendances et, si raisonnable, compiler le backend pour au moins `aarch64-linux-android` ;
|
||||
- ne modifier ni Java, ni Gradle, ni JNI sans besoin concret découvert ;
|
||||
- ne pas rejouer APK/AAB quatre ABI si aucun chemin Android produit n'est touché.
|
||||
|
||||
### Apple — documentation uniquement
|
||||
|
||||
macOS/iOS restent non validés sans environnement Apple. La version documente la dépendance supposée mais n'attribue aucun smoke.
|
||||
|
||||
## Métriques
|
||||
|
||||
Mesures obligatoires si les deux transports fonctionnent :
|
||||
|
||||
```text
|
||||
establishment latency
|
||||
RTT: small payload
|
||||
throughput: bounded medium payload
|
||||
several messages in flight
|
||||
```
|
||||
|
||||
Jeu d'essai candidat :
|
||||
|
||||
```text
|
||||
32 B
|
||||
256 B
|
||||
1 KiB
|
||||
16 KiB
|
||||
```
|
||||
|
||||
La taille exacte peut évoluer selon les limites observées, mais reste identique entre transports.
|
||||
|
||||
Les résultats doivent inclure au minimum nombre d'itérations et une statistique robuste simple, par exemple médiane et p95. Aucun benchmark loopback ne conclut à lui seul sur Internet/mobile.
|
||||
|
||||
CPU/mémoire sont optionnels si la mesure n'est pas stable/reproductible dans la session.
|
||||
|
||||
## Smoke tests prévus
|
||||
|
||||
### Smoke WebSocket hérité
|
||||
|
||||
```bash
|
||||
cargo run -p game-realtime-websocket-smoke
|
||||
```
|
||||
|
||||
Reste le témoin de fallback et doit être rejoué aux jalons larges où le fallback est concerné.
|
||||
|
||||
### Smoke WebTransport natif
|
||||
|
||||
Cible prévue :
|
||||
|
||||
```bash
|
||||
cargo run -p game-realtime-webtransport-smoke
|
||||
```
|
||||
|
||||
Il doit :
|
||||
|
||||
- générer/charger uniquement l'identité de développement nécessaire ;
|
||||
- binder localement sans port fixe ;
|
||||
- établir un client WebTransport ;
|
||||
- échanger au moins un payload dans chaque sens ;
|
||||
- fermer proprement ;
|
||||
- afficher un résultat `PASS` déterministe ;
|
||||
- ne dépendre ni d'Internet ni d'un secret versionné.
|
||||
|
||||
### Smoke navigateur
|
||||
|
||||
Le smoke navigateur doit prouver réellement :
|
||||
|
||||
- chargement du client WASM retenu ;
|
||||
- création WebTransport vers le serveur Rust ;
|
||||
- hash certificat transmis explicitement ;
|
||||
- round-trip binaire ;
|
||||
- close propre ;
|
||||
- absence de fallback silencieux vers WebSocket pendant la preuve WebTransport.
|
||||
|
||||
Le workflow exact est fixé dans `alpha.7` après la preuve de compilation WASM.
|
||||
|
||||
### Smoke fallback
|
||||
|
||||
La preuve de fallback doit couvrir :
|
||||
|
||||
- WebTransport disponible -> chemin WebTransport ;
|
||||
- WebTransport volontairement indisponible/endpoint invalide -> WebSocket ;
|
||||
- erreur non classée comme fallback -> erreur visible, pas masquée.
|
||||
|
||||
Cette preuve peut être intégrée à un launcher technique existant si cela garde le code plus petit et plus clair.
|
||||
|
||||
## Tests automatisés prévus
|
||||
|
||||
### Backend natif
|
||||
|
||||
Au minimum :
|
||||
|
||||
- configuration valide/invalide ;
|
||||
- établissement loopback ;
|
||||
- payload vide si le contrat l'autorise ;
|
||||
- payload binaire normal ;
|
||||
- plusieurs messages ordonnés ;
|
||||
- taille maximale et dépassement ;
|
||||
- longueur de frame malformée/overflow impossible ;
|
||||
- timeout d'établissement ;
|
||||
- timeout send/receive lorsque reproductible ;
|
||||
- fermeture locale ;
|
||||
- fermeture distante ;
|
||||
- reset/abort du stream principal ;
|
||||
- drop/cancellation sans task détachée ;
|
||||
- mapping des erreurs sans fuite des types amont dans le contrat commun.
|
||||
|
||||
### WASM
|
||||
|
||||
Les tests unitaires qui n'exigent pas de navigateur restent ciblés. L'interop navigateur est un smoke runtime distinct et ne doit pas être simulée par un test natif.
|
||||
|
||||
### Fallback
|
||||
|
||||
Tester la classification et la décision de composition sans créer une abstraction runtime générique.
|
||||
|
||||
## Graphe de dépendances attendu
|
||||
|
||||
```text
|
||||
game-realtime-transport-lib
|
||||
↑
|
||||
├── game-realtime-websocket-lib
|
||||
└── game-realtime-webtransport-lib
|
||||
```
|
||||
|
||||
Aucune crate sous :
|
||||
|
||||
```text
|
||||
crates/engines/
|
||||
crates/games/
|
||||
```
|
||||
|
||||
doit dépendre de `web-transport`, Quinn, Rustls ou d'un backend concret.
|
||||
|
||||
Les launchers techniques peuvent dépendre des backends nécessaires à leur preuve.
|
||||
|
||||
## Hors scope
|
||||
|
||||
- wire codec définitif ;
|
||||
- session joueur/room ;
|
||||
- matchmaking/auth ;
|
||||
- snapshots/deltas gameplay ;
|
||||
- prediction/reconciliation/rollback ;
|
||||
- simulation authoritative ;
|
||||
- Uroburas Mode 3 ;
|
||||
- persistence gameplay ;
|
||||
- cluster/sharding/regions ;
|
||||
- Redis/NATS/Kafka ;
|
||||
- PKI/ACME produit ;
|
||||
- reverse proxy HTTP/3 définitif ;
|
||||
- CDN/edge ;
|
||||
- framework générique multi-transport ;
|
||||
- réécriture Actix Web ;
|
||||
- modification du gameplay pour sélectionner un transport.
|
||||
|
||||
## Risques et critères de replanification
|
||||
|
||||
La version est replanifiée avant exécution lorsqu'une tranche dépasse clairement 30 minutes de scope attendu.
|
||||
|
||||
Déclencheurs explicites :
|
||||
|
||||
- compilation de la stack primaire impossible avec les règles Rust du dépôt ;
|
||||
- besoin d'un fork amont ;
|
||||
- browser smoke exigeant une infrastructure externe ou PKI disproportionnée ;
|
||||
- dépendance crypto rendant Android ou Linux non praticable ;
|
||||
- évolution du contrat commun requise ;
|
||||
- framing fiable beaucoup plus complexe que prévu ;
|
||||
- fallback nécessitant une abstraction partagée nouvelle ;
|
||||
- benchmark devenant un sous-projet de performance.
|
||||
|
||||
Dans ces cas, le delta courant reste fermé sur sa preuve et le plan est révisé avant la tranche suivante.
|
||||
|
||||
## Forecast révisé
|
||||
|
||||
Chaque tranche vise environ 15 à 30 minutes de travail effectif et une preuve indépendante. Les numéros restent prévisionnels conformément à `SESSION-007`.
|
||||
|
||||
### `0.3.5-alpha.1` — cadrage, audit et plan
|
||||
|
||||
- audit archive/règles/historique `0.3.4` ;
|
||||
- recherche stacks WebTransport/QUIC ;
|
||||
- choix primaire + fallback technique ;
|
||||
- décision contrat/framing/datagram ;
|
||||
- matrice TLS/plateforme ;
|
||||
- smoke/tests/metrics ;
|
||||
- plan vivant.
|
||||
|
||||
Aucun backend ni dépendance WebTransport ajouté.
|
||||
|
||||
### `0.3.5-alpha.2` — crate backend, dépendances et établissement natif
|
||||
|
||||
- créer `game-realtime-webtransport-lib` ;
|
||||
- ajouter uniquement les dépendances/features nécessaires ;
|
||||
- config native client/server ;
|
||||
- génération ou injection d'identité de test ;
|
||||
- hash pinning ;
|
||||
- établir une session native client/server ;
|
||||
- tests d'établissement/configuration ;
|
||||
- README initial de responsabilité/frontières.
|
||||
|
||||
Pas encore d'implémentation complète `RealtimeConnection` si cela rend la tranche trop lourde.
|
||||
|
||||
### `0.3.5-alpha.3` — stream fiable et contrat commun
|
||||
|
||||
Tranche validée avec :
|
||||
|
||||
- stream bidirectionnel principal ;
|
||||
- framing `u32 + payload` borné avant allocation ;
|
||||
- borne POC interne de 1 MiB ;
|
||||
- `RealtimeConnection`, sender et receiver ;
|
||||
- round-trip ordonné multi-message, y compris payload vide et octets non UTF-8 ;
|
||||
- fermeture distante de base par FIN du stream primaire ;
|
||||
- tests loopback ciblés ;
|
||||
- `USAGE.md` pour la séquence session -> stream primaire -> contrat commun.
|
||||
|
||||
La gate utilisateur du 2026-09-21 est conservée dans `history/0.3.5/alpha.3.md`.
|
||||
|
||||
### `0.3.5-alpha.4` — robustesse et lifecycle
|
||||
|
||||
Tranche candidate matérialisée avec :
|
||||
|
||||
- `WebTransportConfig` et borne de message configurable, validée avant démarrage ;
|
||||
- deadlines de connexion/réponse CONNECT, stream primaire et send ;
|
||||
- flow-control QUIC conservé comme backpressure naturelle, avec timeout observable si un send reste bloqué ;
|
||||
- FIN propre distinct des reset/stop explicites ;
|
||||
- drop actif -> abort au lieu de FIN synthétique ;
|
||||
- cancellation d'un send partiel -> reset terminal ;
|
||||
- receive incrémental cancel-safe et reprenable ;
|
||||
- EOF au milieu d'une frame et framing invalide -> `Protocol` ;
|
||||
- dépassement entrant refusé avant allocation ;
|
||||
- mapping backend -> `Timeout`, `MessageTooLarge`, `Closed`, `Protocol`, `Aborted` ou `Io` ;
|
||||
- tests ciblés sur limites, reset/drop, deadline du stream, cancellation de receive et frame tronquée.
|
||||
|
||||
Un timeout d'idle de `receive()` n'est volontairement pas imposé au contrat : l'appelant peut borner/annuler l'attente sans corrompre l'état du frame parser. Un test artificiel de saturation/backpressure n'est pas ajouté tant qu'il n'est pas déterministe.
|
||||
|
||||
La gate utilisateur de `alpha.4` a confirmé fmt, audits, `cargo check`, tous les tests du contrat commun et les vingt tests WebTransport, mais Clippy strict a détecté un unique `clippy::implicit-return` dans la future retournée par `RealtimeReceiver::receive()`. `alpha.4.fix.1` explicite ce `return` sans changement sémantique, d'API, de framing, de lifecycle ou de dépendance. La gate complète du fix reste requise avant d'ouvrir `alpha.5`.
|
||||
|
||||
### `0.3.5-alpha.5` — smoke natif hors harness
|
||||
|
||||
- créer ou finaliser `game-realtime-webtransport-smoke` ;
|
||||
- round-trip réel localhost ;
|
||||
- certificat/hash de développement reproductible ;
|
||||
- `PASS` déterministe ;
|
||||
- contrôle du graphe de dépendances.
|
||||
|
||||
Cette tranche reste distincte pour ne pas mélanger robustesse de bibliothèque et preuve runtime publique.
|
||||
|
||||
### `0.3.5-alpha.6` — chemin WASM compilable
|
||||
|
||||
- activer `web_sys_unstable_apis` uniquement pour `wasm32-unknown-unknown` ;
|
||||
- implémenter/adapter le client WASM ;
|
||||
- prouver le build/check WASM ciblé ;
|
||||
- ne pas modifier Snake ou Reflex ;
|
||||
- documenter les différences native/WASM réellement rencontrées.
|
||||
|
||||
Si la famille `web-transport` échoue ici, évaluer `wtransport` serveur + API navigateur directe avant de conclure.
|
||||
|
||||
### `0.3.5-alpha.7` — interop navigateur et TLS local
|
||||
|
||||
- host technique minimal si nécessaire ;
|
||||
- navigateur récent -> serveur Rust WebTransport ;
|
||||
- hash certificat W3C ;
|
||||
- round-trip binaire ;
|
||||
- close propre ;
|
||||
- smoke documenté et reproductible ;
|
||||
- aucune désactivation permanente de TLS.
|
||||
|
||||
Cette tranche est volontairement séparée de `alpha.6` car certificat, serveur local et browser tooling constituent un risque opérationnel distinct.
|
||||
|
||||
### `0.3.5-alpha.8` — fallback WebSocket au niveau composition
|
||||
|
||||
- tentative WebTransport ;
|
||||
- fallback WebSocket uniquement sur erreurs classifiées ;
|
||||
- branche WebTransport forcée ;
|
||||
- branche fallback forcée ;
|
||||
- erreur non-fallback visible ;
|
||||
- aucun couplage gameplay ;
|
||||
- pas de registry/framework générique.
|
||||
|
||||
### `0.3.5-alpha.9` — datagram POC isolé
|
||||
|
||||
Uniquement si le backend fiable et le navigateur sont suffisamment stables :
|
||||
|
||||
- émission/réception datagram ;
|
||||
- pertes/ordre non garantis documentés ;
|
||||
- preuve locale bornée ;
|
||||
- aucune modification du contrat commun ;
|
||||
- décision explicite : utile pour future capability ou simple constat.
|
||||
|
||||
Si la valeur est déjà claire sans API supplémentaire, cette tranche peut être fusionnée avec la mesure ou supprimée.
|
||||
|
||||
### `0.3.5-alpha.10` — mesures comparatives bornées
|
||||
|
||||
- launcher/outil minimal de mesure ;
|
||||
- WebSocket vs WebTransport fiable ;
|
||||
- établissement, RTT, throughput, messages en vol ;
|
||||
- datagram seulement s'il existe réellement ;
|
||||
- résultats et limites de méthode documentés ;
|
||||
- conclusion technique provisoire `retain/defer/reject`.
|
||||
|
||||
### `0.3.5-beta.1` — validation large
|
||||
|
||||
Jalon rare de full workspace :
|
||||
|
||||
```bash
|
||||
cargo test --workspace --all-targets --all-features
|
||||
```
|
||||
|
||||
Plus :
|
||||
|
||||
- fmt/check/Clippy/audits ;
|
||||
- tests ciblés realtime ;
|
||||
- smoke WebSocket ;
|
||||
- smoke WebTransport natif ;
|
||||
- smoke navigateur si retenu ;
|
||||
- preuve fallback ;
|
||||
- `cargo tree` direct/inverse des deux backends ;
|
||||
- vérification qu'aucun gameplay/engine ne dépend d'un backend concret.
|
||||
|
||||
Toute capacité fonctionnelle majeure manquante réouvre une alpha ; un défaut fermé produit `beta.1.fix.N`.
|
||||
|
||||
### `0.3.5-beta.2` — consolidation pré-RC
|
||||
|
||||
Tranche explicitement réservée par `SESSION-009` / `VER-PHASE-010` :
|
||||
|
||||
- réconcilier README/USAGE et docs durables ;
|
||||
- figer la conclusion WebTransport ;
|
||||
- documenter plateformes réellement validées/non validées ;
|
||||
- mettre à jour `CHANGELOG.md` si la transition vers RC est préparée ;
|
||||
- réconcilier `ROADMAP.md` seulement si le statut macro change ;
|
||||
- écrire le prompt `0.3.6` ;
|
||||
- enregistrer l'historique `beta.1` après validation ;
|
||||
- aucune nouvelle fonctionnalité majeure.
|
||||
|
||||
### `0.3.5-rc.1` — candidate gelée
|
||||
|
||||
- aucun comportement volontaire nouveau ;
|
||||
- full workspace conformément à `CMD-RC-002` ;
|
||||
- gates realtime de publication ;
|
||||
- smokes retenus ;
|
||||
- graphe de dépendances ;
|
||||
- vérification du prompt `0.3.6` ;
|
||||
- corrections uniquement selon `VER-RC-*`.
|
||||
|
||||
### `0.3.5` — stable
|
||||
|
||||
Promotion mécanique autant que possible :
|
||||
|
||||
- version stable ;
|
||||
- historique RC ;
|
||||
- changelog stable ;
|
||||
- clôture du plan ;
|
||||
- roadmap si nécessaire ;
|
||||
- delta final ;
|
||||
- ajustement mécanique du prompt `0.3.6`.
|
||||
|
||||
## Gates Cargo planifiées
|
||||
|
||||
### Tranches Rust ordinaires
|
||||
|
||||
Dès qu'un fichier Rust/Cargo change :
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo fmt --all -- --check
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
```
|
||||
|
||||
Puis tests ciblés des crates touchées/consommateurs affectés.
|
||||
|
||||
### Full workspace
|
||||
|
||||
`cargo test --workspace --all-targets --all-features` est réservé explicitement à :
|
||||
|
||||
- `beta.1` ;
|
||||
- `rc.1` ;
|
||||
- éventuellement une tranche plus tôt uniquement si un changement transverse rend les tests ciblés insuffisants.
|
||||
|
||||
Il n'est pas répété à chaque alpha.
|
||||
|
||||
### Cargo tree
|
||||
|
||||
À exécuter :
|
||||
|
||||
- après introduction/changement de dépendances WebTransport ;
|
||||
- à `alpha.5` pour prouver la frontière ;
|
||||
- à `beta.1` et `rc.1` pour les graphes de publication.
|
||||
|
||||
## Nettoyage Cargo
|
||||
|
||||
Aucun `cargo clean` n'est requis dans `alpha.1`.
|
||||
|
||||
Le plan réserve un nettoyage complet au plus tard avant la validation RC si l'accumulation du target-dir ou un doute de reproductibilité le justifie. Un nettoyage ciblé reste préférable pendant les alphas.
|
||||
|
||||
## Critère de réussite de 0.3.5
|
||||
|
||||
La version est réussie si elle fournit une conclusion reproductible parmi :
|
||||
|
||||
```text
|
||||
retained -> second backend utile, conserver pour 0.4.x
|
||||
deferred -> viable mais bénéfice/portabilité/infrastructure insuffisants aujourd'hui
|
||||
rejected -> coût ou incompatibilité disproportionnés pour la trajectoire actuelle
|
||||
```
|
||||
|
||||
Aucune conclusion n'est imposée à l'avance.
|
||||
|
||||
Même en cas de report/rejet, la baseline WebSocket `0.3.4` reste fonctionnelle et le POC ne doit pas laisser une abstraction commune artificiellement déformée.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/studies/000-README.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Études
|
||||
|
||||
@@ -65,3 +65,7 @@ Ces documents étudient les POC à réaliser ; ils ne lancent encore aucune impl
|
||||
## Étude de cadrage 0.3.3
|
||||
|
||||
- [`025-V0_3_3_ANDROID_NATIVE_MULTI_ABI_AUDIT.md`](025-V0_3_3_ANDROID_NATIVE_MULTI_ABI_AUDIT.md) — audit de la baseline `0.3.1`, toolchain Android native actuelle, choix ABI/API, packaging APK/AAB et contraintes 16 KB.
|
||||
|
||||
## Étude de cadrage 0.3.5
|
||||
|
||||
- [`026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md`](026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md) — audit de la stable `0.3.4`, comparaison des stacks WebTransport/QUIC actuelles, compatibilité du contrat realtime, TLS, navigateurs et sizing de `0.3.5`.
|
||||
|
||||
300
docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md
Normal file
300
docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md
Normal file
@@ -0,0 +1,300 @@
|
||||
<!-- file: docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Audit WebTransport/QUIC pour 0.3.5
|
||||
|
||||
## Statut et objet
|
||||
|
||||
Cette étude cadre `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4`. Elle reste non normative : les décisions opérationnelles retenues pour la version sont portées par `docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md`.
|
||||
|
||||
L'objectif est de déterminer, sur l'état réel de l'écosystème au 2026-09-21, quelle pile WebTransport/QUIC mérite un POC, comment elle se confronte au contrat `game-realtime-transport-lib` livré en `0.3.4`, quelles preuves navigateur/TLS sont réalistes et où doit vivre le fallback WebSocket.
|
||||
|
||||
## Base auditée
|
||||
|
||||
L'archive fournie est annoncée comme le téléchargement ZIP du tag Gitea `v0.3.4` et déclare :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.3.4
|
||||
17 membres workspace
|
||||
```
|
||||
|
||||
Avant modification, les contrôles statiques exécutables dans l'environnement de génération donnent :
|
||||
|
||||
```text
|
||||
unzip -t : no errors
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
games.sasedev workspace audit: clean
|
||||
Markdown table audit: clean (5 table(s), 263 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
```
|
||||
|
||||
L'inventaire indépendant confirme :
|
||||
|
||||
```text
|
||||
437 fichiers dans le ZIP
|
||||
267 fichiers Markdown extraits
|
||||
68 fichiers Rust
|
||||
18 Cargo.toml, dont le manifest racine
|
||||
17 membres workspace
|
||||
0 symlink
|
||||
0 chemin absolu ou traversal détecté
|
||||
0 target/, node_modules/, build/ ou gen/android/ généré
|
||||
```
|
||||
|
||||
La sortie utilisateur fournie à l'ouverture de session confirme également `cargo fmt --all -- --check`, les trois audits et `cargo check --workspace` propres sur son checkout `0.3.4`. Son audit Markdown annonce `279` fichiers contre `263` dans le périmètre scanné du ZIP fourni. La baseline taggée reçue reste l'autorité de génération conformément à `CMD-GIT-003` et `CMD-GIT-004`; l'écart est enregistré sans inventer les fichiers locaux absents de l'archive.
|
||||
|
||||
## État hérité de 0.3.4
|
||||
|
||||
Les preuves de `history/0.3.4/` et `deltas/0.3.4/rel.001.md` confirment :
|
||||
|
||||
- `game-realtime-transport-lib` sans Tokio, WebSocket, QUIC, HTTP ni TLS ;
|
||||
- `TransportMessage` binaire opaque et contrat message-oriented ;
|
||||
- split `RealtimeConnection -> Sender + Receiver` ;
|
||||
- fermeture distante propre distincte d'une erreur ;
|
||||
- erreurs transport-neutral ;
|
||||
- backend `game-realtime-websocket-lib` Tokio/tokio-tungstenite ;
|
||||
- tests loopback et robustesse ;
|
||||
- smoke runtime `game-realtime-websocket-smoke` hors harness `#[test]` ;
|
||||
- `beta.1` validée avec 53 tests workspace ;
|
||||
- `rc.1` validée avec 18 tests realtime ciblés, smoke `PASS` et arbre inverse où seul le launcher de smoke consomme le backend WebSocket.
|
||||
|
||||
Cette frontière constitue la baseline de comparaison. `0.3.5` n'a pas besoin de la redessiner avant d'avoir une preuve WebTransport concrète.
|
||||
|
||||
## État du protocole au 2026-09-21
|
||||
|
||||
WebTransport côté navigateur est désormais classé « Baseline 2026 » par MDN : l'API fonctionne sur les versions récentes des principaux navigateurs depuis mars 2026, avec la réserve habituelle sur les versions anciennes et certaines sous-capacités. L'API exige un contexte sécurisé et expose streams bidirectionnels/unidirectionnels fiables ainsi que datagrams non fiables.
|
||||
|
||||
Sources :
|
||||
|
||||
- <https://developer.mozilla.org/en-US/docs/Web/API/WebTransport_API>
|
||||
- <https://developer.mozilla.org/en-US/docs/Web/API/WebTransport>
|
||||
|
||||
Le binding WebTransport over HTTP/3 n'est cependant pas encore un RFC final. `draft-ietf-webtrans-http3-16`, daté du 2026-07-06, est toujours un Internet-Draft en WG Last Call avec statut visé Proposed Standard.
|
||||
|
||||
Source : <https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/>
|
||||
|
||||
Conséquence pour le POC : l'interopérabilité navigateur est suffisamment réelle pour être testée, mais la version ne doit pas présenter le protocole ni une crate comme une dépendance produit définitivement stabilisée.
|
||||
|
||||
## Candidats Rust actuels
|
||||
|
||||
### Famille `web-transport`
|
||||
|
||||
État observé :
|
||||
|
||||
```text
|
||||
web-transport 0.12.0 (2026-08-20)
|
||||
web-transport-quinn 0.12.1 (2026-08-20)
|
||||
web-transport-wasm 0.6.0 (2026-08)
|
||||
```
|
||||
|
||||
`web-transport` fournit une API générique qui sélectionne :
|
||||
|
||||
```text
|
||||
native -> web-transport-quinn
|
||||
wasm32 -> web-transport-wasm
|
||||
```
|
||||
|
||||
La crate native s'appuie sur Quinn, Rustls, Tokio et expose streams + datagrams. La crate WASM enveloppe l'API WebTransport du navigateur. Le projet amont est sous licence `MIT OR Apache-2.0`.
|
||||
|
||||
Sources :
|
||||
|
||||
- <https://docs.rs/crate/web-transport/latest>
|
||||
- <https://docs.rs/crate/web-transport-quinn/latest>
|
||||
- <https://docs.rs/crate/web-transport-wasm/latest>
|
||||
- <https://github.com/moq-dev/web-transport>
|
||||
|
||||
Point particulièrement pertinent pour `games.sasedev` : la documentation `web-transport` explique explicitement le problème `Send` entre natif et WASM et contourne ce problème par sélection de l'implémentation selon la cible. Cela rejoint la décision prise en `0.3.4` de ne pas imposer `Send` aux futures du contrat transport-neutral.
|
||||
|
||||
La voie WASM impose actuellement :
|
||||
|
||||
```text
|
||||
--cfg=web_sys_unstable_apis
|
||||
```
|
||||
|
||||
car les bindings WebTransport de `web-sys` restent derrière ce cfg. Ce flag doit être fourni par le build final et ne peut pas être activé par une dépendance. Une éventuelle modification `.cargo/config.toml` devra donc être ciblée sur `wasm32-unknown-unknown`, pas appliquée globalement au workspace.
|
||||
|
||||
### `wtransport`
|
||||
|
||||
État observé :
|
||||
|
||||
```text
|
||||
wtransport 0.7.2 (2026-08-11)
|
||||
```
|
||||
|
||||
La crate fournit une implémentation WebTransport/HTTP3 pure Rust, client et serveur natifs, fondée notamment sur Quinn, Rustls et Tokio. Sa documentation est plus riche et elle fournit des helpers explicites pour certificat self-signed, hash SHA-256 et contraintes W3C. La branche `0.7.x` annonce un MSRV au moins Rust 1.88 dans la metadata consultée de `0.7.1`; la compilation exacte de `0.7.2` reste à prouver sur le toolchain du projet.
|
||||
|
||||
Sources :
|
||||
|
||||
- <https://docs.rs/crate/wtransport/latest>
|
||||
- <https://docs.rs/wtransport/latest/wtransport/>
|
||||
|
||||
Sa limite pour ce projet est l'absence d'une façade Rust WASM équivalente : l'intégration navigateur documentée utilise directement l'API JavaScript `WebTransport`. Cela reste viable pour un serveur Rust, mais apporte moins de valeur au POC si l'objectif est aussi de challenger le contrat Rust commun sur `wasm32`.
|
||||
|
||||
### Quinn / H3 de plus bas niveau
|
||||
|
||||
Quinn est la base QUIC commune à plusieurs stacks, mais l'utiliser directement imposerait de réimplémenter le binding WebTransport/HTTP3, la négociation et les détails de session déjà possédés par les crates spécialisées.
|
||||
|
||||
Cette voie reste un recours si les wrappers retenus bloquent une exigence essentielle ; elle n'est pas retenue comme premier POC, conformément au principe d'éviter une infrastructure disproportionnée.
|
||||
|
||||
## Choix de POC retenu
|
||||
|
||||
La famille `web-transport` est retenue en premier pour `0.3.5`, avec :
|
||||
|
||||
```text
|
||||
game-realtime-webtransport-lib
|
||||
-> web-transport
|
||||
-> native: web-transport-quinn
|
||||
-> wasm32: web-transport-wasm
|
||||
```
|
||||
|
||||
Raisons :
|
||||
|
||||
- façade native + WASM déjà pensée par l'amont ;
|
||||
- alignement direct avec la contrainte `!Send` possible du contrat `0.3.4` ;
|
||||
- serveur natif et client natif/WASM disponibles dans la même famille ;
|
||||
- streams et datagrams disponibles pour le POC ;
|
||||
- Quinn/Rustls/Tokio restent contenus dans le backend concret ;
|
||||
- licence compatible avec le dépôt ;
|
||||
- activité amont récente en août 2026.
|
||||
|
||||
`wtransport` reste le candidat de repli technique prioritaire si `web-transport` échoue sur une exigence concrète de `alpha.2` à `alpha.4`, notamment configuration TLS, lifecycle ou interop navigateur. Un échec d'implémentation ne justifie pas de basculer silencieusement : le plan et le delta doivent enregistrer la raison.
|
||||
|
||||
## Compatibilité avec le contrat 0.3.4
|
||||
|
||||
### Chemin fiable
|
||||
|
||||
Le contrat commun est message-oriented alors qu'un stream QUIC/WebTransport est un flux d'octets fiable, ordonné et flow-controlled. La différence de sémantique est réelle mais ne nécessite pas de modifier `RealtimeConnection`.
|
||||
|
||||
Le POC retiendra un stream bidirectionnel principal par connexion logique :
|
||||
|
||||
```text
|
||||
WebTransport session
|
||||
-> one primary bidirectional reliable stream
|
||||
-> bounded backend framing
|
||||
-> TransportMessage
|
||||
```
|
||||
|
||||
Le framing interne candidat est :
|
||||
|
||||
```text
|
||||
u32 big-endian payload length
|
||||
payload bytes
|
||||
```
|
||||
|
||||
avec une limite produit vérifiée avant allocation/écriture. Ce framing sert uniquement à reconstruire les frontières `TransportMessage` sur un byte stream ; il n'est pas le futur wire codec métier, ne porte aucune version de protocole joueur/room et reste privé au backend.
|
||||
|
||||
Le client ouvre le stream principal ; le serveur l'accepte avant de considérer la `RealtimeConnection` établie. Ce choix conserve l'ordre des messages et permet au split commun de mapper naturellement le côté write/read du même stream.
|
||||
|
||||
### Datagrams
|
||||
|
||||
Les datagrams WebTransport sont non fiables, non ordonnés et non flow-controlled. Ils ne satisfont donc pas le contrat fiable/ordonné livré en `0.3.4`.
|
||||
|
||||
Ils seront exercés séparément dans le POC, sans élargir `RealtimeConnection` et sans créer une capability commune tant qu'un second consommateur réel et un besoin sémantique durable ne sont pas démontrés.
|
||||
|
||||
Une mesure backend-spécifique peut utiliser la session amont directement ou une surface expérimentale locale au launcher de mesure. Elle ne doit pas devenir une API durable uniquement pour permettre le benchmark.
|
||||
|
||||
## TLS et certificats de développement
|
||||
|
||||
Le navigateur impose une URL `https://` vers le serveur WebTransport. L'option `serverCertificateHashes` permet de faire confiance à un certificat connu sans PKI publique lorsque la connexion est dédiée.
|
||||
|
||||
La documentation MDN impose notamment pour ce mode :
|
||||
|
||||
- hash SHA-256 ;
|
||||
- certificat X.509v3 ;
|
||||
- validité totale inférieure à deux semaines ;
|
||||
- date courante dans la période de validité ;
|
||||
- ECDSA P-256 comme choix interopérable minimal.
|
||||
|
||||
Source : <https://developer.mozilla.org/en-US/docs/Web/API/WebTransport/WebTransport>
|
||||
|
||||
`web-transport-quinn` fournit `ClientBuilder::with_server_certificate_hashes`, ce qui permet de reprendre la même stratégie de pinning côté client natif sans désactiver la validation TLS.
|
||||
|
||||
Source : <https://docs.rs/web-transport-quinn/latest/web_transport_quinn/struct.ClientBuilder.html>
|
||||
|
||||
Le POC doit donc privilégier une identité self-signed courte générée pour localhost et un pin de hash explicite. Une API « dangerous/no certificate verification » ne devient pas le chemin normal du smoke.
|
||||
|
||||
Aucun certificat privé, clé privée durable ou secret ne doit être commité.
|
||||
|
||||
## Plateformes
|
||||
|
||||
### Linux natif
|
||||
|
||||
Cible de référence pour :
|
||||
|
||||
- serveur local UDP/QUIC ;
|
||||
- client natif ;
|
||||
- tests loopback déterministes ;
|
||||
- smoke runtime hors harness ;
|
||||
- benchmark local borné.
|
||||
|
||||
### Navigateur / WASM
|
||||
|
||||
Cible obligatoire du POC parce que WebTransport apporte une valeur spécifique au navigateur. Deux preuves distinctes sont nécessaires :
|
||||
|
||||
1. compilation `wasm32-unknown-unknown` du chemin Rust choisi ;
|
||||
2. interop runtime d'un navigateur récent avec le serveur Rust local.
|
||||
|
||||
Le smoke navigateur ne doit pas être confondu avec un simple `cargo check` WASM.
|
||||
|
||||
### Android
|
||||
|
||||
La pile native Quinn/Rustls rend Android plausible, mais `0.3.5-alpha.1` ne le déclare pas validé. Le plan réserve au minimum un contrôle de compilation ciblé si la dépendance choisie ne force pas une réouverture disproportionnée du pipeline Android.
|
||||
|
||||
Aucun changement Java/Gradle/JNI n'est prévu pour le POC ; la matrice quatre ABI `0.3.3` n'est donc pas rejouée par cérémonie.
|
||||
|
||||
### macOS / iOS
|
||||
|
||||
La trajectoire est documentée mais non validée en l'absence d'environnement Apple. Une compatibilité supposée depuis Quinn/Rustls ne doit pas être présentée comme un smoke réel.
|
||||
|
||||
## Fallback WebSocket
|
||||
|
||||
Le fallback est retenu au niveau composition/application du POC, pas dans le gameplay et pas dans `game-realtime-transport-lib`.
|
||||
|
||||
La preuve visée est :
|
||||
|
||||
```text
|
||||
attempt WebTransport
|
||||
success -> use WebTransport path
|
||||
classified establishment failure / unsupported path -> WebSocket baseline
|
||||
```
|
||||
|
||||
Le POC doit également permettre de forcer chaque branche afin de vérifier le fallback de façon déterministe. Il ne doit pas créer de registry dynamique, `TransportManager` générique ni système de plugins.
|
||||
|
||||
Aucune règle n'impose encore que cette logique devienne une crate réutilisable : un launcher technique suffit tant qu'un second consommateur réel ne justifie pas l'extraction.
|
||||
|
||||
## Mesures retenues
|
||||
|
||||
Les mesures minimales sont :
|
||||
|
||||
- temps d'établissement ;
|
||||
- RTT de petits payloads ;
|
||||
- débit sur payloads bornés ;
|
||||
- plusieurs messages en vol ;
|
||||
- comparaison WebSocket vs stream WebTransport fiable ;
|
||||
- comparaison datagram uniquement si la preuve datagram est effectivement réalisée ;
|
||||
- coût opérationnel observé : certificat, UDP, port, debug et build WASM.
|
||||
|
||||
Les résultats loopback sont décrits comme des mesures locales contrôlées. Ils ne seront jamais extrapolés en gains Internet/mobile sans test réseau correspondant.
|
||||
|
||||
CPU/mémoire peuvent être relevés si la méthode est stable, mais ne sont pas une gate de release.
|
||||
|
||||
## Risques principaux
|
||||
|
||||
- protocole HTTP/3 WebTransport encore en Internet-Draft ;
|
||||
- APIs amont actives mais encore susceptibles de casser ;
|
||||
- `web_sys_unstable_apis` imposé au build WASM ;
|
||||
- contraintes de certificats plus lourdes que la baseline `ws://` ;
|
||||
- UDP parfois bloqué par réseau, firewall ou infrastructure ;
|
||||
- browser smoke nécessitant plusieurs processus/outils locaux ;
|
||||
- framing message interne nécessaire sur le stream fiable ;
|
||||
- différences de close/reset entre session WebTransport et stream principal ;
|
||||
- dépendances crypto natives pouvant compliquer certains targets ;
|
||||
- Android plausible mais non prouvé ;
|
||||
- benchmark loopback trop bruité pour porter seul une décision produit.
|
||||
|
||||
## Conclusion de cadrage
|
||||
|
||||
Le POC est viable et justifie `0.3.5`, mais le forecast initial du prompt est trop grossier pour la règle de 15 à 30 minutes par delta. En particulier, backend natif, robustesse, smoke runtime, WASM, interop navigateur/TLS, fallback et mesures ne doivent pas être agrégés en deux grosses alpha.
|
||||
|
||||
Le plan actif découpe ces preuves en tranches verticales indépendantes et ajoute une consolidation explicite avant RC, conformément à `SESSION-009` et `VER-PHASE-010`.
|
||||
39
history/0.3.5/alpha.1.md
Normal file
39
history/0.3.5/alpha.1.md
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- file: history/0.3.5/alpha.1.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Historique 0.3.5-alpha.1
|
||||
|
||||
## Statut
|
||||
|
||||
`0.3.5-alpha.1` a été validée par l'utilisateur le 2026-09-21. Le cadrage WebTransport/QUIC, le plan actif et la synchronisation de version sont acceptés sans fix.
|
||||
|
||||
La suite peut ouvrir `0.3.5-alpha.2` pour introduire le backend natif minimal et prouver l'établissement TLS/WebTransport avec pinning de certificat.
|
||||
|
||||
## Gates fournies
|
||||
|
||||
Les commandes exécutées par l'utilisateur :
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
ont terminé proprement avec :
|
||||
|
||||
```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), 282 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
cargo check --workspace: clean
|
||||
```
|
||||
|
||||
Le workspace compilé porte `0.3.5-alpha.1`, y compris les deux POC Tauri affichés dans la sortie finale.
|
||||
|
||||
## Conséquence
|
||||
|
||||
Le forecast révisé de `docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md` est retenu. `alpha.2` reste limitée à la crate backend, aux dépendances/features minimales, à l'identité TLS/hash pinning et à l'établissement natif ; streams fiables, framing et contrat commun restent réservés à `alpha.3`.
|
||||
59
history/0.3.5/alpha.2.fix.1.md
Normal file
59
history/0.3.5/alpha.2.fix.1.md
Normal file
@@ -0,0 +1,59 @@
|
||||
<!-- file: history/0.3.5/alpha.2.fix.1.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Historique 0.3.5-alpha.2.fix.1
|
||||
|
||||
## Statut
|
||||
|
||||
`0.3.5-alpha.2.fix.1` a été validée par l'utilisateur le 2026-09-21. Le correctif ferme l'unique défaut de la gate `alpha.2` : la comparaison d'adresse distante accepte désormais la représentation IPv4-mapped IPv6 remontée par Quinn sans relâcher la comparaison du port ou d'une adresse réellement différente.
|
||||
|
||||
Aucun autre fix n'est requis. La suite peut ouvrir directement `0.3.5-alpha.3` pour le stream bidirectionnel principal, le framing fiable borné et l'adaptation au contrat commun.
|
||||
|
||||
## Gates statiques et compilation
|
||||
|
||||
Les commandes utilisateur ont terminé proprement :
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
La sortie confirme :
|
||||
|
||||
```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), 286 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
cargo check --workspace: clean
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
|
||||
```
|
||||
|
||||
Le workspace compilé porte `0.3.5-alpha.2.fix.1`, y compris `game-realtime-webtransport-lib` et les autres targets workspace affichées par la gate.
|
||||
|
||||
## Tests WebTransport
|
||||
|
||||
La commande :
|
||||
|
||||
```bash
|
||||
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
|
||||
```
|
||||
|
||||
est entièrement propre :
|
||||
|
||||
```text
|
||||
unit tests: 4 passed; 0 failed
|
||||
establishment integration tests: 2 passed; 0 failed
|
||||
```
|
||||
|
||||
Le test positif `pinned_client_and_server_establish_a_loopback_session` passe désormais, tout comme le rejet d'un mauvais pin. La preuve d'établissement natif TLS/WebTransport de `alpha.2` est donc fermée.
|
||||
|
||||
## Conséquence
|
||||
|
||||
Le forecast reste inchangé fonctionnellement : `alpha.3` peut introduire un unique stream bidirectionnel fiable principal, le framing privé `u32` big-endian + payload borné avant allocation, `RealtimeConnection`/sender/receiver, le round-trip multi-message ordonné et la fermeture distante de base.
|
||||
92
history/0.3.5/alpha.3.md
Normal file
92
history/0.3.5/alpha.3.md
Normal file
@@ -0,0 +1,92 @@
|
||||
<!-- file: history/0.3.5/alpha.3.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Historique 0.3.5-alpha.3
|
||||
|
||||
## Statut
|
||||
|
||||
`0.3.5-alpha.3` a été validée par l'utilisateur le 2026-09-21. Le stream fiable principal, le framing privé borné et l'implémentation du contrat realtime commun sont donc fermés sans correctif.
|
||||
|
||||
La suite peut ouvrir `0.3.5-alpha.4` pour la robustesse et le lifecycle sans modifier le scope de `alpha.5`, qui reste le smoke natif hors harness.
|
||||
|
||||
## Gates statiques et compilation
|
||||
|
||||
Les commandes utilisateur ont terminé proprement :
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
La sortie confirme :
|
||||
|
||||
```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), 289 file(s))
|
||||
Distribution layout audit: clean (50 required path(s), 8 forbidden path(s) absent)
|
||||
cargo check --workspace: clean
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
|
||||
```
|
||||
|
||||
Le workspace compilé porte `0.3.5-alpha.3`, y compris `game-realtime-webtransport-lib` et l'ensemble des targets workspace affichées par la gate.
|
||||
|
||||
## Tests du contrat realtime commun
|
||||
|
||||
La commande :
|
||||
|
||||
```bash
|
||||
cargo test -p game-realtime-transport-lib --all-targets --all-features
|
||||
```
|
||||
|
||||
est entièrement propre :
|
||||
|
||||
```text
|
||||
7 passed; 0 failed
|
||||
```
|
||||
|
||||
Les preuves couvrent notamment split, fermeture distante propre, payload vide/binaire et stabilité des catégories d'erreurs du contrat commun.
|
||||
|
||||
## Tests WebTransport
|
||||
|
||||
La commande :
|
||||
|
||||
```bash
|
||||
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
|
||||
```
|
||||
|
||||
est entièrement propre :
|
||||
|
||||
```text
|
||||
unit tests: 5 passed; 0 failed
|
||||
establishment integration tests: 2 passed; 0 failed
|
||||
realtime_connection integration tests: 1 passed; 0 failed
|
||||
```
|
||||
|
||||
Le test `primary_stream_round_trip_is_binary_ordered_and_closes_cleanly` confirme le stream bidirectionnel principal, les messages binaires ordonnés et le FIN propre du chemin fiable.
|
||||
|
||||
## Graphe de dépendances
|
||||
|
||||
La commande :
|
||||
|
||||
```bash
|
||||
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
|
||||
```
|
||||
|
||||
ne remonte que :
|
||||
|
||||
```text
|
||||
game-realtime-webtransport-lib v0.3.5-alpha.3
|
||||
```
|
||||
|
||||
Le backend concret ne fuite donc pas vers une crate engine, gameplay ou application existante.
|
||||
|
||||
## Conséquence
|
||||
|
||||
`alpha.4` peut matérialiser limites configurables, deadlines, flow-control/backpressure observable, reset/abort/drop/cancellation, cas négatifs de framing et mapping d'erreurs. Le smoke natif public reste séparé en `alpha.5` afin de conserver des deltas de taille bornée.
|
||||
Reference in New Issue
Block a user